feat: 添加消息历史持久化和可视化查看功能

- 新增 messageStore.js 消息存储模块,支持自动保存所有收发消息
- 修改 main.js,在消息转发时自动记录到本地存储
- 修改 preload.js,暴露消息管理 IPC API
- 修改 App.js,添加消息历史查看界面
  - 统计信息面板(总数/接收/发送/会话数)
  - 会话列表和消息详情
  - 搜索、过滤、分页功能
  - 导出 JSON 和清空历史
- 新增完整文档(MESSAGE_HISTORY.md 等)
- 新增测试脚本 test-message-history.js

版本:v1.0.1
This commit is contained in:
2026-03-10 04:09:26 +08:00
parent 7c826af5d1
commit 0880813355
9 changed files with 1809 additions and 3 deletions

View File

@@ -17,16 +17,31 @@ function App() {
const [testMessage, setTestMessage] = useState({ botId: '', chatId: '', text: '' });
const [testOpenClawMessage, setTestOpenClawMessage] = useState('');
const [testOpenClawResult, setTestOpenClawResult] = useState(null);
// 消息历史相关状态
const [showMessageHistory, setShowMessageHistory] = useState(false);
const [messages, setMessages] = useState([]);
const [sessions, setSessions] = useState([]);
const [selectedSession, setSelectedSession] = useState(null);
const [messageStats, setMessageStats] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [messageFilter, setMessageFilter] = useState({ direction: '', source: '' });
const [messagePage, setMessagePage] = useState({ offset: 0, limit: 50, total: 0 });
// 加载配置
useEffect(() => {
loadConfig();
setupEventListeners();
loadMessageStats();
// 定期更新连接状态
const statusInterval = setInterval(async () => {
const status = await window.electronAPI.getConnectionStatus();
setConnectionStatus(status);
// 定期刷新统计数据
if (showMessageHistory) {
loadMessageStats();
}
}, 3000);
return () => {
@@ -36,6 +51,15 @@ function App() {
};
}, []);
// 当打开消息历史时加载数据
useEffect(() => {
if (showMessageHistory) {
loadMessages();
loadSessions();
loadMessageStats();
}
}, [showMessageHistory, selectedSession, messageFilter]);
const loadConfig = async () => {
try {
const savedConfig = await window.electronAPI.getConfig();
@@ -46,6 +70,56 @@ function App() {
}
};
// 加载消息历史
const loadMessages = async (options = {}) => {
try {
const result = await window.electronAPI.getMessages({
...options,
offset: messagePage.offset,
limit: messagePage.limit
});
setMessages(result.messages);
setMessagePage({ ...messagePage, total: result.total });
} catch (error) {
addLog('error', `加载消息失败:${error.message}`);
}
};
// 加载会话列表
const loadSessions = async () => {
try {
const sessionList = await window.electronAPI.getSessions();
setSessions(sessionList);
} catch (error) {
addLog('error', `加载会话失败:${error.message}`);
}
};
// 加载统计数据
const loadMessageStats = async () => {
try {
const stats = await window.electronAPI.getMessageStats();
setMessageStats(stats);
} catch (error) {
addLog('error', `加载统计失败:${error.message}`);
}
};
// 搜索消息
const handleSearchMessages = async () => {
if (!searchQuery.trim()) {
loadMessages();
return;
}
try {
const result = await window.electronAPI.searchMessages(searchQuery, { limit: 50 });
setMessages(result.messages);
setMessagePage({ offset: 0, limit: 50, total: result.total });
} catch (error) {
addLog('error', `搜索失败:${error.message}`);
}
};
const setupEventListeners = () => {
window.electronAPI.onWeComEvent((event) => {
const { eventType, data } = event;
@@ -54,6 +128,10 @@ function App() {
const body = data.frame?.body;
const text = body?.text?.content || body?.voice?.content || '[媒体消息]';
addLog('info', `[WeCom] 收到消息 from ${body?.from?.userid}: ${text.substring(0, 50)}`);
// 如果在消息历史页面,刷新消息列表
if (showMessageHistory) {
setTimeout(() => loadMessages(), 500);
}
} else if (eventType === 'connected') {
addLog('success', `[WeCom] Bot ${data.botId} 已连接`);
} else if (eventType === 'disconnected') {
@@ -231,8 +309,19 @@ function App() {
return (
<div className="app">
<div className="header">
<h1>🤖 WeCom OpenClaw Client</h1>
<p>企业微信智能机器人 - 双向消息桥接 <span style={{ color: '#00d8ff', fontSize: '0.8em' }}>v1.0.0-fix7</span></p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1>🤖 WeCom OpenClaw Client</h1>
<p>企业微信智能机器人 - 双向消息桥接 <span style={{ color: '#00d8ff', fontSize: '0.8em' }}>v1.0.0-fix7</span></p>
</div>
<button
className="btn btn-secondary"
onClick={() => { setShowMessageHistory(true); loadMessages(); loadSessions(); loadMessageStats(); }}
style={{ marginLeft: '20px' }}
>
💬 消息历史
</button>
</div>
</div>
<div className="container">
@@ -509,6 +598,299 @@ function App() {
</div>
)}
{/* 消息历史模态框 */}
{showMessageHistory && (
<div className="modal-overlay" onClick={() => setShowMessageHistory(false)} style={{ maxWidth: '1400px' }}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ width: '90%', maxWidth: '1400px', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 style={{ margin: 0 }}>💬 消息历史</h2>
<button className="btn btn-secondary" onClick={() => setShowMessageHistory(false)}> 关闭</button>
</div>
{/* 统计信息 */}
{messageStats && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
marginBottom: '20px'
}}>
<div style={{ background: '#0f3460', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2em', color: '#00d8ff', fontWeight: 'bold' }}>{messageStats.total}</div>
<div style={{ color: '#888', fontSize: '0.85em' }}>总消息数</div>
</div>
<div style={{ background: '#0f3460', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2em', color: '#00c853', fontWeight: 'bold' }}>{messageStats.inbound}</div>
<div style={{ color: '#888', fontSize: '0.85em' }}>接收消息</div>
</div>
<div style={{ background: '#0f3460', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2em', color: '#ffc107', fontWeight: 'bold' }}>{messageStats.outbound}</div>
<div style={{ color: '#888', fontSize: '0.85em' }}>发送消息</div>
</div>
<div style={{ background: '#0f3460', padding: '15px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '2em', color: '#9c27b0', fontWeight: 'bold' }}>{messageStats.sessions}</div>
<div style={{ color: '#888', fontSize: '0.85em' }}>会话数</div>
</div>
</div>
)}
<div style={{ display: 'flex', gap: '20px', flex: 1, minHeight: '500px', overflow: 'hidden' }}>
{/* 左侧:会话列表 */}
<div style={{ width: '300px', flexShrink: 0, display: 'flex', flexDirection: 'column' }}>
<h3 style={{ color: '#00d8ff', margin: '0 0 12px 0' }}>📋 会话列表</h3>
<div style={{
flex: 1,
overflowY: 'auto',
background: '#0f3460',
borderRadius: '8px',
padding: '10px',
maxHeight: '600px'
}}>
{sessions.length === 0 ? (
<div style={{ color: '#888', textAlign: 'center', padding: '20px' }}>暂无会话</div>
) : (
sessions.map(session => (
<div
key={session.sessionId}
onClick={() => setSelectedSession(session.sessionId === selectedSession ? null : session.sessionId)}
style={{
padding: '12px',
marginBottom: '8px',
borderRadius: '6px',
cursor: 'pointer',
background: selectedSession === session.sessionId ? '#00d8ff33' : 'transparent',
border: selectedSession === session.sessionId ? '1px solid #00d8ff' : '1px solid transparent',
transition: 'all 0.2s'
}}
>
<div style={{ fontWeight: 'bold', color: '#fff', marginBottom: '4px' }}>
{session.chatId}
</div>
<div style={{ fontSize: '0.8em', color: '#888' }}>
{session.messageCount} 条消息
</div>
<div style={{ fontSize: '0.75em', color: '#666', marginTop: '4px' }}>
{new Date(session.lastMessageTime).toLocaleString('zh-CN')}
</div>
</div>
))
)}
</div>
</div>
{/* 右侧:消息列表 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: '500px' }}>
{/* 搜索和过滤 */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchMessages()}
placeholder="搜索消息内容..."
style={{
flex: 1,
padding: '10px',
background: '#0f3460',
border: '1px solid #1a1a2e',
borderRadius: '6px',
color: '#fff',
fontSize: '0.95em'
}}
/>
<button className="btn btn-primary" onClick={handleSearchMessages}>🔍 搜索</button>
<select
value={messageFilter.direction}
onChange={(e) => setMessageFilter({ ...messageFilter, direction: e.target.value })}
style={{
padding: '10px',
background: '#0f3460',
border: '1px solid #1a1a2e',
borderRadius: '6px',
color: '#fff',
fontSize: '0.9em'
}}
>
<option value="">全部方向</option>
<option value="inbound">接收</option>
<option value="outbound">发送</option>
</select>
<select
value={messageFilter.source}
onChange={(e) => setMessageFilter({ ...messageFilter, source: e.target.value })}
style={{
padding: '10px',
background: '#0f3460',
border: '1px solid #1a1a2e',
borderRadius: '6px',
color: '#fff',
fontSize: '0.9em'
}}
>
<option value="">全部来源</option>
<option value="wecom">企业微信</option>
<option value="openclaw">OpenClaw</option>
</select>
<button className="btn btn-secondary" onClick={() => { setMessageFilter({ direction: '', source: '' }); setSearchQuery(''); loadMessages(); }}>
重置
</button>
</div>
{/* 消息列表 */}
<div style={{
flex: 1,
overflowY: 'auto',
background: '#0f3460',
borderRadius: '8px',
padding: '15px',
maxHeight: '600px'
}}>
{messages.length === 0 ? (
<div style={{ color: '#888', textAlign: 'center', padding: '40px' }}>暂无消息</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{messages.map((msg, index) => (
<div
key={msg.id || index}
style={{
padding: '12px',
borderRadius: '8px',
background: msg.direction === 'inbound' ? 'rgba(0, 200, 83, 0.1)' : 'rgba(0, 216, 255, 0.1)',
border: `1px solid ${msg.direction === 'inbound' ? '#00c853' : '#00d8ff'}`,
borderLeft: msg.direction === 'inbound' ? '3px solid #00c853' : '3px solid #00d8ff'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: '0.75em',
padding: '2px 6px',
borderRadius: '4px',
background: msg.source === 'wecom' ? '#07c160' : '#9c27b0',
color: '#fff'
}}>
{msg.source === 'wecom' ? 'WeCom' : 'OpenClaw'}
</span>
<span style={{ fontSize: '0.75em', color: '#888' }}>
{msg.direction === 'inbound' ? '← 接收' : '→ 发送'}
</span>
<span style={{ fontSize: '0.85em', fontWeight: 'bold', color: '#fff' }}>
{msg.senderName || msg.senderId}
</span>
</div>
<span style={{ fontSize: '0.8em', color: '#666' }}>
{new Date(msg.timestamp).toLocaleString('zh-CN')}
</span>
</div>
{msg.content && (
<div style={{
color: '#fff',
marginBottom: '8px',
padding: '8px',
background: 'rgba(0,0,0,0.2)',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '0.9em',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
}}>
{msg.content}
</div>
)}
{msg.attachments && msg.attachments.length > 0 && (
<div style={{ fontSize: '0.85em', color: '#ffc107', marginTop: '8px' }}>
📎 附件{msg.attachments.map(a => a.type).join(', ')}
</div>
)}
{msg.metadata && (
<div style={{ fontSize: '0.75em', color: '#666', marginTop: '8px' }}>
会话{msg.sessionId}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* 分页 */}
{messagePage.total > messagePage.limit && (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '16px',
padding: '12px',
background: '#0f3460',
borderRadius: '8px'
}}>
<div style={{ color: '#888', fontSize: '0.9em' }}>
{messagePage.total} 条消息显示 {messagePage.offset + 1} - {Math.min(messagePage.offset + messagePage.limit, messagePage.total)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-secondary"
onClick={() => setMessagePage({ ...messagePage, offset: Math.max(0, messagePage.offset - messagePage.limit) })}
disabled={messagePage.offset === 0}
style={{ opacity: messagePage.offset === 0 ? 0.5 : 1 }}
>
上一页
</button>
<button
className="btn btn-secondary"
onClick={() => setMessagePage({ ...messagePage, offset: messagePage.offset + messagePage.limit })}
disabled={messagePage.offset + messagePage.limit >= messagePage.total}
style={{ opacity: messagePage.offset + messagePage.limit >= messagePage.total ? 0.5 : 1 }}
>
下一页
</button>
</div>
</div>
)}
</div>
</div>
{/* 底部操作按钮 */}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #1a1a2e' }}>
<button className="btn btn-secondary" onClick={() => { loadMessages(); loadSessions(); }}>
🔄 刷新
</button>
<button
className="btn btn-secondary"
onClick={async () => {
const data = await window.electronAPI.exportMessages({});
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `messages_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}}
>
📥 导出 JSON
</button>
<button
className="btn btn-danger"
onClick={async () => {
if (confirm('确定要清空所有消息历史吗?此操作不可恢复!')) {
await window.electronAPI.clearMessages({});
loadMessages();
loadSessions();
loadMessageStats();
}
}}
>
🗑 清空历史
</button>
</div>
</div>
</div>
)}
{/* 测试消息模态框 */}
{showTestMessage && (
<div className="modal-overlay" onClick={() => setShowTestMessage(false)}>