feat: 初始版本 - 企业微信 OpenClaw 图形配置客户端

- 基于 Electron + React 的跨平台桌面应用
- 支持多 Bot ID 和 Secret 配置
- 双 WebSocket 长连接(企业微信 + OpenClaw Gateway)
- 图形化配置界面,实时连接状态显示
- 自动重连机制
- 支持 Windows/macOS/Linux 打包

技术栈:
- Electron 28
- React 18
- @wecom/aibot-node-sdk
- electron-store 配置持久化
This commit is contained in:
2026-03-09 20:30:56 +08:00
commit 9cce1e76fc
11 changed files with 18458 additions and 0 deletions

17069
renderer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
renderer/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "renderer",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#00d8ff" />
<meta name="description" content="WeCom OpenClaw Client - 企业微信智能机器人配置客户端" />
<title>WeCom OpenClaw Client</title>
</head>
<body>
<noscript>您需要启用 JavaScript 来运行此应用。</noscript>
<div id="root"></div>
</body>
</html>

323
renderer/src/App.js Normal file
View File

@@ -0,0 +1,323 @@
import React, { useState, useEffect } from 'react';
function App() {
const [config, setConfig] = useState({
bots: [],
openclaw: {
url: 'ws://localhost:18789',
token: '',
enabled: true
}
});
const [connectionStatus, setConnectionStatus] = useState({ wecom: {}, openclaw: { connected: false } });
const [logs, setLogs] = useState([]);
const [showAddBot, setShowAddBot] = useState(false);
const [newBot, setNewBot] = useState({ botId: '', secret: '', name: '', enabled: true });
// 加载配置
useEffect(() => {
loadConfig();
setupEventListeners();
// 定期更新连接状态
const statusInterval = setInterval(async () => {
const status = await window.electronAPI.getConnectionStatus();
setConnectionStatus(status);
}, 5000);
return () => {
clearInterval(statusInterval);
window.electronAPI.removeAllListeners('wecom-event');
window.electronAPI.removeAllListeners('openclaw-event');
};
}, []);
const loadConfig = async () => {
try {
const savedConfig = await window.electronAPI.getConfig();
setConfig(savedConfig);
addLog('info', '配置加载成功');
} catch (error) {
addLog('error', `加载配置失败:${error.message}`);
}
};
const setupEventListeners = () => {
window.electronAPI.onWeComEvent((event) => {
const { eventType, data } = event;
addLog('info', `[WeCom] ${eventType}: ${JSON.stringify(data)}`);
if (eventType === 'error') {
addLog('error', `[WeCom] 错误:${data.error}`);
}
// 更新连接状态
setTimeout(async () => {
const status = await window.electronAPI.getConnectionStatus();
setConnectionStatus(status);
}, 100);
});
window.electronAPI.onOpenClawEvent((event) => {
const { eventType, data } = event;
addLog('info', `[OpenClaw] ${eventType}`);
if (eventType === 'error') {
addLog('error', `[OpenClaw] 错误:${data?.error}`);
}
// 更新连接状态
setTimeout(async () => {
const status = await window.electronAPI.getConnectionStatus();
setConnectionStatus(status);
}, 100);
});
};
const addLog = (type, message) => {
const timestamp = new Date().toLocaleTimeString();
setLogs(prev => [...prev.slice(-99), { type, message, timestamp }]);
};
const saveConfig = async () => {
try {
await window.electronAPI.saveConfig(config);
addLog('success', '配置保存成功');
} catch (error) {
addLog('error', `保存配置失败:${error.message}`);
}
};
const handleAddBot = async () => {
if (!newBot.botId || !newBot.secret) {
addLog('warning', '请填写 Bot ID 和 Secret');
return;
}
const updatedBots = [...config.bots, { ...newBot, id: Date.now().toString() }];
setConfig({ ...config, bots: updatedBots });
setNewBot({ botId: '', secret: '', name: '', enabled: true });
setShowAddBot(false);
await saveConfig();
addLog('success', `添加机器人:${newBot.name || newBot.botId}`);
};
const handleDeleteBot = async (botId) => {
await window.electronAPI.disconnectWeCom(botId);
const updatedBots = config.bots.filter(b => b.id !== botId);
setConfig({ ...config, bots: updatedBots });
await saveConfig();
addLog('info', `删除机器人:${botId}`);
};
const handleConnectWeCom = async (bot) => {
try {
await window.electronAPI.connectWeCom(bot);
addLog('info', `连接企业微信 Bot: ${bot.botId}`);
} catch (error) {
addLog('error', `连接失败:${error.message}`);
}
};
const handleDisconnectWeCom = async (botId) => {
await window.electronAPI.disconnectWeCom(botId);
addLog('info', `断开企业微信 Bot: ${botId}`);
};
const handleConnectOpenClaw = async () => {
try {
await window.electronAPI.connectOpenClaw(config.openclaw);
addLog('info', '连接 OpenClaw Gateway');
} catch (error) {
addLog('error', `连接失败:${error.message}`);
}
};
const handleDisconnectOpenClaw = async () => {
await window.electronAPI.disconnectOpenClaw();
addLog('info', '断开 OpenClaw Gateway');
};
const getStatus = (botId) => {
return connectionStatus.wecom[botId]?.connected ? 'connected' : 'disconnected';
};
return (
<div className="app">
<div className="header">
<h1>🤖 WeCom OpenClaw Client</h1>
<p>企业微信智能机器人图形配置客户端</p>
</div>
<div className="container">
{/* OpenClaw 配置 */}
<div className="section">
<h2 className="section-title">OpenClaw Gateway 配置</h2>
<div className="form-row">
<div className="form-group">
<label>Gateway 地址</label>
<input
type="text"
value={config.openclaw.url}
onChange={(e) => setConfig({
...config,
openclaw: { ...config.openclaw, url: e.target.value }
})}
placeholder="ws://localhost:18789"
/>
</div>
<div className="form-group">
<label>Token (可选)</label>
<input
type="password"
value={config.openclaw.token}
onChange={(e) => setConfig({
...config,
openclaw: { ...config.openclaw, token: e.target.value }
})}
placeholder="Gateway Token"
/>
</div>
</div>
<div className="checkbox-group" style={{ marginBottom: '20px' }}>
<input
type="checkbox"
id="openclaw-enabled"
checked={config.openclaw.enabled}
onChange={(e) => setConfig({
...config,
openclaw: { ...config.openclaw, enabled: e.target.checked }
})}
/>
<label htmlFor="openclaw-enabled">启用 OpenClaw 连接</label>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
{connectionStatus.openclaw.connected ? (
<button className="btn btn-danger" onClick={handleDisconnectOpenClaw}>
断开连接
</button>
) : (
<button className="btn btn-success" onClick={handleConnectOpenClaw}>
连接 OpenClaw
</button>
)}
<div className="status-item">
<span className={`status-indicator ${connectionStatus.openclaw.connected ? 'connected' : 'disconnected'}`}></span>
<span>{connectionStatus.openclaw.connected ? '已连接' : '未连接'}</span>
</div>
</div>
</div>
{/* 企业微信机器人配置 */}
<div className="section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="section-title" style={{ margin: 0 }}>企业微信机器人配置</h2>
<button className="btn btn-primary" onClick={() => setShowAddBot(true)}>
+ 添加机器人
</button>
</div>
{config.bots.length === 0 ? (
<div className="empty-state">
<p>暂无配置的机器人</p>
<button className="btn btn-primary" onClick={() => setShowAddBot(true)}>
添加第一个机器人
</button>
</div>
) : (
<div className="bot-list">
{config.bots.map(bot => (
<div key={bot.id} className="bot-card">
<div className="bot-info">
<h3>{bot.name || bot.botId}</h3>
<p>Bot ID: {bot.botId}</p>
</div>
<div className="bot-actions">
<div className="bot-status">
<span className={`status-indicator ${getStatus(bot.botId)}`}></span>
<span>{getStatus(bot.botId) === 'connected' ? '已连接' : '未连接'}</span>
</div>
{getStatus(bot.botId) === 'connected' ? (
<button className="btn btn-danger" onClick={() => handleDisconnectWeCom(bot.botId)}>
断开
</button>
) : (
<button className="btn btn-success" onClick={() => handleConnectWeCom(bot)}>
连接
</button>
)}
<button className="btn btn-secondary" onClick={() => handleDeleteBot(bot.id)}>
删除
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 日志控制台 */}
<div className="section">
<h2 className="section-title">连接日志</h2>
<div className="log-console">
{logs.length === 0 ? (
<div style={{ color: '#666' }}>暂无日志</div>
) : (
logs.map((log, index) => (
<div key={index} className={`log-entry ${log.type}`}>
[{log.timestamp}] {log.message}
</div>
))
)}
</div>
</div>
</div>
{/* 添加机器人模态框 */}
{showAddBot && (
<div className="modal-overlay" onClick={() => setShowAddBot(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>添加企业微信机器人</h2>
<div className="form-group">
<label>机器人名称 (可选)</label>
<input
type="text"
value={newBot.name}
onChange={(e) => setNewBot({ ...newBot, name: e.target.value })}
placeholder="例如:客服机器人"
/>
</div>
<div className="form-group">
<label>Bot ID *</label>
<input
type="text"
value={newBot.botId}
onChange={(e) => setNewBot({ ...newBot, botId: e.target.value })}
placeholder="从企业微信管理后台获取"
/>
</div>
<div className="form-group">
<label>Secret *</label>
<input
type="password"
value={newBot.secret}
onChange={(e) => setNewBot({ ...newBot, secret: e.target.value })}
placeholder="从企业微信管理后台获取"
/>
</div>
<div className="modal-actions">
<button className="btn btn-secondary" onClick={() => setShowAddBot(false)}>
取消
</button>
<button className="btn btn-primary" onClick={handleAddBot}>
添加
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default App;

312
renderer/src/index.css Normal file
View File

@@ -0,0 +1,312 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #1a1a2e;
color: #eee;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.app {
min-height: 100vh;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
color: #00d8ff;
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
color: #888;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.section {
background: #16213e;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.section-title {
font-size: 1.5em;
color: #00d8ff;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 24px;
background: #00d8ff;
border-radius: 2px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #aaa;
font-size: 0.9em;
}
.form-group input {
width: 100%;
padding: 12px;
background: #0f3460;
border: 1px solid #1a1a2e;
border-radius: 6px;
color: #fff;
font-size: 1em;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #00d8ff;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: #00d8ff;
color: #1a1a2e;
}
.btn-primary:hover {
background: #00b8e6;
}
.btn-success {
background: #00c853;
color: #fff;
}
.btn-success:hover {
background: #00a843;
}
.btn-danger {
background: #ff5252;
color: #fff;
}
.btn-danger:hover {
background: #ff1744;
}
.btn-secondary {
background: #424242;
color: #fff;
}
.btn-secondary:hover {
background: #616161;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bot-list {
display: grid;
gap: 16px;
}
.bot-card {
background: #0f3460;
border-radius: 8px;
padding: 20px;
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: center;
}
.bot-info h3 {
color: #fff;
margin-bottom: 8px;
}
.bot-info p {
color: #888;
font-size: 0.9em;
}
.bot-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #888;
}
.status-indicator.connected {
background: #00c853;
box-shadow: 0 0 8px #00c853;
}
.status-indicator.disconnected {
background: #ff5252;
}
.bot-actions {
display: flex;
gap: 8px;
}
.status-bar {
background: #0f3460;
border-radius: 8px;
padding: 16px;
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.log-console {
background: #0a0a0a;
border-radius: 8px;
padding: 16px;
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.85em;
}
.log-entry {
margin-bottom: 4px;
padding: 4px 8px;
border-radius: 4px;
}
.log-entry.info {
color: #00d8ff;
}
.log-entry.success {
color: #00c853;
}
.log-entry.error {
color: #ff5252;
background: rgba(255, 82, 82, 0.1);
}
.log-entry.warning {
color: #ffc107;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #16213e;
border-radius: 12px;
padding: 32px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.modal h2 {
color: #00d8ff;
margin-bottom: 24px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #888;
}
.empty-state p {
margin-bottom: 20px;
}

11
renderer/src/index.js Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);