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:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user