初始化提交

This commit is contained in:
2026-04-07 14:11:45 +08:00
parent 46342134b6
commit c412e4de5f
69 changed files with 19321 additions and 1 deletions
+685
View File
@@ -0,0 +1,685 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f6fa;
color: #333;
min-height: 100vh;
}
.app-container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
left: 0;
top: 0;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar-header h1 {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.sidebar-header span {
font-size: 12px;
color: rgba(255,255,255,0.6);
}
.sidebar-nav {
flex: 1;
padding: 15px 0;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 20px;
color: rgba(255,255,255,0.7);
text-decoration: none;
transition: all 0.2s;
border-left: 3px solid transparent;
}
.nav-item:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.nav-item.active {
background: rgba(255,255,255,0.15);
color: #fff;
border-left-color: #4a9eff;
}
.nav-icon {
margin-right: 12px;
font-size: 16px;
}
.sidebar-footer {
padding: 15px 20px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.server-status {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.server-status.online { color: #52c41a; }
.server-status.offline { color: #f5222d; }
.main-content {
flex: 1;
margin-left: 220px;
padding: 25px;
max-width: calc(100% - 220px);
}
.page {
display: none;
}
.page.active {
display: block;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
}
.header-actions {
display: flex;
gap: 10px;
}
.section {
background: #fff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.section h3 {
font-size: 16px;
font-weight: 500;
margin-bottom: 15px;
color: #333;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.stat-icon {
font-size: 32px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #333;
}
.stat-label {
font-size: 13px;
color: #888;
}
.stat-card.success .stat-icon { color: #52c41a; }
.stat-card.warning .stat-icon { color: #fa8c16; }
.stat-card.danger .stat-icon { color: #f5222d; }
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}
.node-card {
background: #fff;
border-radius: 10px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
border-left: 4px solid #52c41a;
}
.node-card.warning { border-left-color: #fa8c16; }
.node-card.offline { border-left-color: #f5222d; }
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.node-header h3 {
font-size: 16px;
font-weight: 500;
}
.status-badge {
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.status-badge.online { background: #e6f7e6; color: #52c41a; }
.status-badge.warning { background: #fff1e6; color: #fa8c16; }
.status-badge.offline { background: #fff1f0; color: #f5222d; }
.node-info {
font-size: 13px;
color: #666;
margin-bottom: 12px;
}
.node-info p {
margin-bottom: 4px;
}
.node-actions {
display: flex;
gap: 8px;
}
.nodes-table {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th, .table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.table th {
background: #fafafa;
font-weight: 500;
color: #666;
font-size: 13px;
}
.table tr:hover {
background: #fafafa;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.config-item {
display: flex;
align-items: center;
gap: 10px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-size: 13px;
color: #666;
margin-bottom: 6px;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.input-group {
display: flex;
gap: 10px;
align-items: center;
}
.input, .select, .textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #e8e8e8;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.input:focus, .select:focus, .textarea:focus {
outline: none;
border-color: #4a9eff;
}
.textarea {
min-height: 100px;
resize: vertical;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #4a9eff;
color: #fff;
}
.btn:hover {
background: #3a8eff;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-secondary:hover {
background: #e8e8e8;
}
.btn-danger {
background: #fff1f0;
color: #f5222d;
}
.btn-danger:hover {
background: #ffccc7;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-lg {
padding: 12px 30px;
font-size: 15px;
}
.btn-xs {
padding: 3px 8px;
font-size: 11px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
font-size: 13px;
}
.checkbox-inline {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 15px;
font-size: 13px;
}
.event-types {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.send-form, .broadcast-form {
max-width: 500px;
}
.send-form .btn-lg, .broadcast-form .btn-lg {
width: 100%;
}
.api-info {
max-width: 700px;
}
.api-endpoint {
margin-bottom: 20px;
}
.api-endpoint label, .api-example label {
display: block;
font-size: 13px;
color: #666;
margin-bottom: 8px;
}
.code-block {
background: #f5f5f5;
border-radius: 6px;
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.code-block code {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: #333;
word-break: break-all;
}
.code-block pre {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #333;
white-space: pre-wrap;
margin: 0;
}
.docs-content h3 {
margin-top: 25px;
margin-bottom: 12px;
}
.docs-content h3:first-child {
margin-top: 0;
}
.docs-content p {
color: #666;
margin-bottom: 10px;
}
.docs-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.docs-table th, .docs-table td {
padding: 10px 12px;
text-align: left;
border: 1px solid #e8e8e8;
}
.docs-table th {
background: #fafafa;
font-weight: 500;
}
.about-info {
color: #666;
line-height: 1.8;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 25px;
width: 450px;
max-width: 90%;
max-height: 85vh;
overflow-y: auto;
position: relative;
}
.modal-content h2 {
margin-bottom: 20px;
font-size: 18px;
}
.close {
position: absolute;
right: 20px;
top: 20px;
font-size: 24px;
cursor: pointer;
color: #999;
}
.close:hover {
color: #333;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 15px 0 10px;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: var(--text-color);
}
.section-header:hover {
background: #e8e8e8;
}
.section-arrow {
font-size: 12px;
color: #999;
}
.section-content {
padding-left: 5px;
}
.field-desc {
color: var(--text-secondary);
font-size: 12px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.toast {
position: fixed;
bottom: 30px;
right: 30px;
padding: 12px 25px;
border-radius: 8px;
font-size: 14px;
opacity: 0;
transform: translateY(20px);
transition: all 0.3s;
z-index: 2000;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success {
background: #e6f7e6;
color: #52c41a;
}
.toast.error {
background: #fff1f0;
color: #f5222d;
}
.status-online { color: #52c41a; }
.status-inactive { color: #999; }
.status-error { color: #f5222d; }
.status-wechat-online { color: #52c41a; }
.status-wechat-offline { color: #f5222d; }
.status-wechat-unknown { color: #999; }
small {
font-size: 12px;
color: #999;
display: block;
margin-top: 4px;
}
.empty {
text-align: center;
color: #999;
padding: 40px;
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.sidebar-header h1, .sidebar-header span, .nav-item span {
display: none;
}
.nav-item {
justify-content: center;
padding: 15px;
}
.nav-icon {
margin-right: 0;
}
.main-content {
margin-left: 60px;
max-width: calc(100% - 60px);
}
.config-grid {
grid-template-columns: 1fr;
}
}
.log-section {
max-height: calc(100vh - 200px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.log-container {
flex: 1;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.6;
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
min-height: 400px;
max-height: calc(100vh - 250px);
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid #333;
display: flex;
gap: 10px;
align-items: flex-start;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #888;
white-space: nowrap;
}
.log-level {
font-weight: 600;
min-width: 60px;
}
.log-source {
color: #9cdcfe;
min-width: 80px;
}
.log-message {
flex: 1;
word-break: break-all;
}
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WXAuto Center - 中控系统</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div id="app"></div>
<div id="modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span>
<h2 id="modalTitle">添加节点</h2>
<form id="nodeForm">
<input type="hidden" id="nodeIdOld">
<div class="form-group">
<label>节点ID</label>
<input type="text" id="nodeId" class="input" placeholder="唯一标识符,如: wx1" required>
</div>
<div class="form-group">
<label>节点名称</label>
<input type="text" id="nodeName" class="input" placeholder="显示名称,如: 微信1" required>
</div>
<div class="form-group">
<label>API URL</label>
<input type="text" id="nodeApiUrl" class="input" placeholder="http://localhost:5000" required>
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" id="nodeApiKey" class="input" required>
</div>
<div class="form-group">
<label>分组</label>
<input type="text" id="nodeGroup" class="input" value="default">
</div>
<div class="form-group">
<label>描述</label>
<input type="text" id="nodeDesc" class="input">
</div>
<div class="form-actions">
<button type="submit" class="btn">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</div>
</form>
</div>
</div>
<div id="toast" class="toast"></div>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/pages/templates.js"></script>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
const API_KEY = 'your-external-api-key';
const API_BASE = '/api/v1';
const REQUEST_TIMEOUT = 30000;
let pendingRequests = 0;
let loadingToast = null;
let loadingTimer = null;
function showNonBlockingLoading(message = '加载中...') {
pendingRequests++;
updateLoadingUI(message);
if (loadingTimer) clearTimeout(loadingTimer);
loadingTimer = setTimeout(() => {
console.warn('Some requests may be stuck, force clearing');
}, 60000);
}
function hideNonBlockingLoading() {
pendingRequests = Math.max(0, pendingRequests - 1);
updateLoadingUI(pendingRequests > 0 ? '处理中...' : null);
}
function updateLoadingUI(message) {
if (!message) {
if (loadingToast) {
loadingToast.remove();
loadingToast = null;
}
return;
}
if (!loadingToast) {
loadingToast = document.createElement('div');
loadingToast.id = 'loading-toast';
loadingToast.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 10000;
display: flex;
align-items: center;
gap: 10px;
`;
document.body.appendChild(loadingToast);
}
const spinner = '<div style="width:16px;height:16px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin 0.8s linear infinite"></div>';
const style = '<style>@keyframes spin{to{transform:rotate(360deg)}}</style>';
if (!document.getElementById('loading-spinner-style')) {
document.head.insertAdjacentHTML('beforeend', style);
}
loadingToast.innerHTML = spinner + `<span>${message}</span><span style="font-size:12px;opacity:0.7">(${pendingRequests})</span>`;
}
async function api(endpoint, options = {}) {
const method = options.method || 'GET';
const isMutation = ['POST', 'PUT', 'DELETE'].includes(method);
const startMsg = `请求${isMutation ? '修改' : '获取'}中...`;
showNonBlockingLoading(startMsg);
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
showToast('请求超时,请稍后重试', 'error');
}, REQUEST_TIMEOUT);
try {
const headers = {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
...options.headers
};
const response = await fetch(API_BASE + endpoint, {
...options,
headers,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = `HTTP ${response.status}: ${response.statusText}`;
if (isMutation) {
showToast('操作失败: ' + error, 'error');
}
return { success: false, error };
}
const data = await response.json();
if (isMutation && data.success === false && data.error) {
showToast(data.error, 'error');
}
return data;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
return { success: false, error: '请求超时' };
}
const errorMsg = err.message || '网络错误';
if (isMutation) {
showToast(errorMsg, 'error');
}
return { success: false, error: errorMsg };
} finally {
hideNonBlockingLoading();
}
}
const apiClient = {
get: (endpoint) => api(endpoint),
post: (endpoint, data) => api(endpoint, { method: 'POST', body: JSON.stringify(data) }),
put: (endpoint, data) => api(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
delete: (endpoint) => api(endpoint, { method: 'DELETE' }),
nodes: {
list: () => api('/nodes/'),
get: (name) => api(`/nodes/${name}`),
create: (data) => api('/nodes/', { method: 'POST', body: JSON.stringify(data) }),
update: (name, data) => api(`/nodes/${name}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (name) => api(`/nodes/${name}`, { method: 'DELETE' }),
status: (name) => api(`/nodes/${name}/status`)
},
messages: {
send: (data) => api('/messages/send', { method: 'POST', body: JSON.stringify(data) }),
broadcast: (data) => api('/messages/broadcast', { method: 'POST', body: JSON.stringify(data) })
},
config: {
getMonitor: () => api('/config/monitor'),
updateMonitor: (data) => api('/config/monitor', { method: 'POST', body: JSON.stringify(data) }),
getWebhook: () => api('/config/webhook'),
updateWebhook: (data) => api('/config/webhook', { method: 'POST', body: JSON.stringify(data) })
},
logs: {
recent: (count = 50) => api(`/logs/recent?count=${count}`)
}
};
window.api = api;
window.apiClient = apiClient;
+759
View File
@@ -0,0 +1,759 @@
// ///
// app.js
// 描述:中控系统前端主逻辑
// 作者:AI Generated
// 创建日期:2026-04-05
// ///
let currentPage = 'dashboard';
let logPollInterval = null;
let lastLogTime = null;
document.addEventListener('DOMContentLoaded', () => {
renderApp();
});
function renderApp() {
document.getElementById('app').innerHTML = APP_TEMPLATE;
initNavEvents();
showPage('dashboard');
}
function initNavEvents() {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
showPage(item.dataset.page);
});
});
}
function showPage(pageName) {
currentPage = pageName;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector(`[data-page="${pageName}"]`).classList.add('active');
const content = document.getElementById('page-content');
content.innerHTML = `<div class="page active">${PAGES[pageName] || '<p>页面不存在</p>'}</div>`;
if (pageName === 'dashboard') loadDashboard();
if (pageName === 'nodes') loadNodesForTable();
if (pageName === 'monitor') { loadMonitorConfig(); loadWebhookTable(); }
if (pageName === 'send') loadNodesForSend();
if (pageName === 'logs') startLogStream();
if (pageName === 'external') initExternalPage();
if (pageName === 'plugins') setTimeout(loadPluginTable, 50);
}
async function loadDashboard() {
const result = await api('/nodes/');
if (result.success) renderDashboard(result.data);
}
async function loadNodesForTable() {
const result = await api('/nodes/');
if (result.success) renderNodesTable(result.data);
}
function renderDashboard(nodes) {
const total = nodes.length;
const online = nodes.filter(n => n.status === 'active' && n.wechat_status === 'online').length;
const offline = nodes.filter(n => n.status !== 'active' || n.wechat_status !== 'online').length;
const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
setText('onlineCount', online);
setText('offlineCount', offline);
setText('totalMessages', total);
setText('todayMessages', '-');
}
function renderNodesTable(nodes) {
const container = document.getElementById('nodesTable');
if (!container) return;
if (!nodes || !nodes.length) {
container.innerHTML = '<p class="empty">暂无节点</p>';
return;
}
container.innerHTML = `
<table class="table">
<thead>
<tr><th>ID</th><th>名称</th><th>分组</th><th>API状态</th><th>微信状态</th><th>操作</th></tr>
</thead>
<tbody>
${nodes.map(n => {
const statusClass = n.status !== 'active' ? 'offline' : (n.wechat_status !== 'online' ? 'warning' : 'online');
return `<tr>
<td>${n.node_id}</td>
<td>${n.name}</td>
<td>${n.group}</td>
<td><span class="status-${n.status}">${n.status}</span></td>
<td><span class="status-wechat-${n.wechat_status}">${n.wechat_status}</span></td>
<td>
<button class="btn btn-xs" onclick="checkNodeStatus('${n.node_id}')">检测</button>
<button class="btn btn-xs btn-secondary" onclick="editNode('${n.node_id}')">编辑</button>
<button class="btn btn-xs btn-danger" onclick="deleteNode('${n.node_id}')">删除</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
}
async function loadNodesForSend() {
const result = await api('/nodes/');
if (result.success) {
const options = '<option value="">选择节点</option>' +
result.data.map(n => `<option value="${n.node_id}">${n.name} (${n.node_id})</option>`).join('');
document.getElementById('sendNode').innerHTML = options;
}
}
async function refreshAll() {
await loadDashboard();
showToast('刷新完成');
}
async function checkNodeStatus(nodeId) {
const result = await api(`/nodes/${nodeId}/status`);
if (result.success) {
showToast(`检测完成: API=${result.data.api_status}, 微信=${result.data.wechat_status || 'N/A'}`);
}
await loadNodesForTable();
}
async function sendQuickMessage() {
const nodeEl = document.getElementById('sendNode');
const whoEl = document.getElementById('sendWho');
const msgEl = document.getElementById('quickMsg');
if (!nodeEl || !whoEl || !msgEl) {
showToast('页面未正确加载', 'error');
return;
}
const nodeId = nodeEl.value;
const who = whoEl.value;
const msg = msgEl.value;
if (!nodeId || !who || !msg) {
showToast('请填写完整信息', 'error');
return;
}
const result = await api('/messages/send', {
method: 'POST',
body: JSON.stringify({ node_id: nodeId, who, msg })
});
if (result.success) {
showToast('发送成功');
msgEl.value = '';
} else {
showToast('发送失败: ' + (result.error || result.detail), 'error');
}
}
async function loadMonitorConfig() {
const result = await api('/config/monitor');
if (result.success) {
const enabledEl = document.getElementById('monitorEnabled');
const intervalEl = document.getElementById('checkInterval');
if (enabledEl) enabledEl.checked = result.data.enabled;
if (intervalEl) intervalEl.value = result.data.check_interval;
}
}
async function updateMonitorConfig() {
const config = {
enabled: document.getElementById('monitorEnabled').checked,
check_interval: parseInt(document.getElementById('checkInterval').value) || 60
};
const result = await api('/config/monitor', { method: 'POST', body: JSON.stringify(config) });
if (result.success) showToast('监控配置已更新');
}
async function loadWebhookTable() {
const result = await api('/webhook/');
if (result.success) renderWebhookTable(result.data || []);
}
function renderWebhookTable(webhooks) {
const container = document.getElementById('webhookTable');
if (!container) return;
if (!webhooks.length) {
container.innerHTML = '<p class="empty">暂无 Webhook,请点击添加</p>';
return;
}
container.innerHTML = `
<table class="table">
<thead>
<tr><th>名称</th><th>URL</th><th>格式</th><th>事件</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
${webhooks.map(w => {
const eventLabels = {
'api_error': 'API错误',
'wechat_offline': '微信离线',
'wechat_online': '微信上线',
'node_offline': '节点离线',
'node_online': '节点上线'
};
const events = Array.isArray(w.event_types) ? w.event_types.map(e => eventLabels[e] || e).join(', ') : '-';
return `<tr>
<td>${w.name || '-'}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${w.url || '-'}</td>
<td>${w.format === 'bark' ? 'Bark' : '企业微信'}</td>
<td style="font-size: 12px;">${events}</td>
<td><span class="status-${w.enabled ? 'online' : 'offline'}">${w.enabled ? '启用' : '禁用'}</span></td>
<td>
<button class="btn btn-xs" onclick="editWebhook(${w.id})">编辑</button>
<button class="btn btn-xs btn-danger" onclick="deleteWebhook(${w.id})">删除</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
`;
}
function showAddWebhookModal(webhook = null) {
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const form = document.getElementById('nodeForm');
title.textContent = webhook ? '编辑 Webhook' : '添加 Webhook';
form.innerHTML = `
<input type="hidden" id="webhookId" value="${webhook ? webhook.id : ''}">
<div class="form-group">
<label>名称</label>
<input type="text" id="webhookName" class="input" value="${webhook ? webhook.name : ''}" placeholder="如:Bark告警">
</div>
<div class="form-group">
<label>URL</label>
<input type="text" id="webhookUrl" class="input" value="${webhook ? webhook.url : ''}" placeholder="https://api.day.app/xxx">
</div>
<div class="form-group">
<label>格式</label>
<select id="webhookFormat" class="select">
<option value="bark" ${webhook && webhook.format === 'bark' ? 'selected' : ''}>Bark</option>
<option value="wechat" ${webhook && webhook.format === 'wechat' ? 'selected' : ''}>企业微信</option>
</select>
</div>
<div class="form-group">
<label>启用</label>
<label class="checkbox-label">
<input type="checkbox" id="webhookEnabled" ${!webhook || webhook.enabled ? 'checked' : ''}>
启用此 Webhook
</label>
</div>
<div class="form-group">
<label>接收事件</label>
<div class="event-types">
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="api_error" ${webhook && webhook.event_types && webhook.event_types.includes('api_error') ? 'checked' : ''}> API错误</label>
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="wechat_offline" ${webhook && webhook.event_types && webhook.event_types.includes('wechat_offline') ? 'checked' : ''}> 微信离线</label>
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="wechat_online" ${webhook && webhook.event_types && webhook.event_types.includes('wechat_online') ? 'checked' : ''}> 微信上线</label>
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="node_offline" ${webhook && webhook.event_types && webhook.event_types.includes('node_offline') ? 'checked' : ''}> 节点离线</label>
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="node_online" ${webhook && webhook.event_types && webhook.event_types.includes('node_online') ? 'checked' : ''}> 节点上线</label>
<label class="checkbox-label"><input type="checkbox" class="webhook-event" value="plugin_notification" ${webhook && webhook.event_types && webhook.event_types.includes('plugin_notification') ? 'checked' : ''}> 插件通知</label>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="saveWebhook()">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</div>
`;
modal.style.display = 'flex';
}
async function editWebhook(id) {
const result = await api(`/webhook/${id}`);
if (result.success && result.data) {
showAddWebhookModal(result.data);
}
}
async function deleteWebhook(id) {
if (!confirm('确定删除此 Webhook')) return;
const result = await api(`/webhook/${id}`, { method: 'DELETE' });
if (result.success) {
showToast('Webhook 已删除');
loadWebhookTable();
} else {
showToast('删除失败', 'error');
}
}
async function saveWebhook() {
const id = document.getElementById('webhookId').value;
const eventTypes = Array.from(document.querySelectorAll('.webhook-event:checked')).map(cb => cb.value);
const data = {
name: document.getElementById('webhookName').value,
url: document.getElementById('webhookUrl').value,
format: document.getElementById('webhookFormat').value,
enabled: document.getElementById('webhookEnabled').checked,
event_types: eventTypes
};
if (!data.url) {
showToast('请输入 URL', 'error');
return;
}
let result;
if (id) {
result = await api(`/webhook/${id}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
result = await api('/webhook/', { method: 'POST', body: JSON.stringify(data) });
}
if (result.success) {
showToast(id ? 'Webhook 已更新' : 'Webhook 已添加');
closeModal();
loadWebhookTable();
} else {
showToast('保存失败: ' + (result.error || ''), 'error');
}
}
async function startLogStream() {
const result = await api('/logs/recent?count=200');
if (result.success && result.data) {
const container = document.getElementById('logContainer');
if (container) {
container.innerHTML = '';
if (result.data.length > 0) {
lastLogTime = result.data[0].timestamp;
result.data.forEach(entry => addLogEntry(entry));
}
}
}
if (logPollInterval) clearInterval(logPollInterval);
logPollInterval = setInterval(async () => {
const res = await api('/logs/recent?count=20');
if (res.success && res.data && res.data.length > 0) {
const newLogs = res.data.filter(log => {
if (!lastLogTime) return true;
return new Date(log.timestamp) > new Date(lastLogTime);
});
if (newLogs.length > 0) {
lastLogTime = newLogs[0].timestamp;
const container = document.getElementById('logContainer');
if (container) newLogs.forEach(entry => addLogEntry(entry));
}
}
}, 3000);
}
function addLogEntry(entry) {
const container = document.getElementById('logContainer');
if (!container) return;
const colors = { 'INFO': '#4a9eff', 'WARNING': '#fa8c16', 'ERROR': '#f5222d', 'SUCCESS': '#52c41a' };
const color = colors[entry.level] || '#666';
const html = `<div class="log-entry">
<span class="log-time">${entry.timestamp}</span>
<span class="log-level" style="color: ${color}">${entry.level}</span>
<span class="log-source">[${entry.source}]</span>
<span class="log-message">${entry.message}</span>
</div>`;
container.insertAdjacentHTML('afterbegin', html);
if (container.children.length > 500) container.lastChild.remove();
}
function clearLogs() {
const container = document.getElementById('logContainer');
if (container) container.innerHTML = '';
}
function initExternalPage() {
document.getElementById('externalApiUrl').textContent = `${window.location.origin}/api/v1/external/send`;
document.getElementById('webhookReceiveUrl').textContent = `${window.location.origin}/api/v1/external/webhook/{event}`;
}
async function loadPluginTable() {
const result = await api('/plugins/');
if (result.success) renderPluginTable(result.plugins || []);
}
function renderPluginTable(plugins) {
const container = document.getElementById('pluginTable');
if (!container) return;
if (!plugins.length) {
container.innerHTML = '<p class="empty">暂无插件</p>';
return;
}
const typeLabels = {
'message_handler': '消息处理',
'data_source': '数据源',
'action_trigger': '动作触发',
'ai_agent': 'AI智能体',
'custom': '自定义'
};
container.innerHTML = `
<table class="table">
<thead>
<tr><th>名称</th><th>版本</th><th>类型</th><th>描述</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
${plugins.map(p => `
<tr>
<td>${p.name || '-'}</td>
<td>${p.version || '-'}</td>
<td>${typeLabels[p.type] || p.type || '-'}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">${p.description || '-'}</td>
<td><span class="status-${p.enabled ? 'online' : 'offline'}">${p.enabled ? '启用' : '禁用'}</span></td>
<td>
<button class="btn btn-xs" onclick="togglePlugin('${p.name}')">${p.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-xs" onclick="configPlugin('${p.name}')">配置</button>
<button class="btn btn-xs btn-secondary" onclick="showPluginLogs('${p.name}')">日志</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function togglePlugin(name) {
const result = await api(`/plugins/${name}`);
if (result.success && result.plugin) {
const action = result.plugin.enabled ? 'disable' : 'enable';
const res = await api(`/plugins/${name}/${action}`, { method: 'POST' });
if (res.success) {
showToast(res.message);
loadPluginTable();
}
}
}
function configPlugin(name) {
Promise.all([
api(`/plugins/${name}`),
api(`/plugins/${name}/schema`)
]).then(([pluginResult, schemaResult]) => {
if (pluginResult.success) {
if (schemaResult.success && schemaResult.has_form && schemaResult.schema) {
showPluginConfigForm(pluginResult.plugin, schemaResult.schema);
} else {
showPluginConfigModal(pluginResult.plugin);
}
}
});
}
function showPluginConfigForm(plugin, schema) {
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const form = document.getElementById('nodeForm');
title.textContent = `配置插件: ${plugin.name}`;
let formHtml = '';
const currentConfig = plugin.config || {};
if (schema.sections) {
schema.sections.forEach((section, sIdx) => {
const sectionId = `plugin-section-${sIdx}`;
formHtml += `<div class="form-section">
<h4 class="section-header" onclick="togglePluginSection('${sectionId}')">
<span>${section.label}</span>
<span class="section-arrow" id="${sectionId}-arrow">▼</span>
</h4>
<div class="section-content" id="${sectionId}" style="display: block;">`;
section.fields.forEach(field => {
const value = currentConfig[field.name] !== undefined ? currentConfig[field.name] : field.default;
if (field.type === 'string' || field.type === 'password') {
formHtml += `<div class="form-group">
<label>${field.label}</label>
<input type="${field.type === 'password' ? 'password' : 'text'}"
id="plugin_field_${field.name}"
value="${escapeHtml(value || '')}"
placeholder="${field.placeholder || ''}"
${field.required ? 'required' : ''}>
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
</div>`;
} else if (field.type === 'number') {
formHtml += `<div class="form-group">
<label>${field.label}</label>
<input type="number"
id="plugin_field_${field.name}"
value="${value}"
min="${field.min_value || ''}"
max="${field.max_value || ''}"
${field.required ? 'required' : ''}>
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
</div>`;
} else if (field.type === 'boolean') {
formHtml += `<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="plugin_field_${field.name}" ${value ? 'checked' : ''}>
${field.label}
</label>
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
</div>`;
} else if (field.type === 'textarea') {
formHtml += `<div class="form-group">
<label>${field.label}</label>
<textarea id="plugin_field_${field.name}"
rows="3"
placeholder="${field.placeholder || ''}">${escapeHtml(value || '')}</textarea>
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
</div>`;
} else if (field.type === 'select' && field.options) {
let optionsHtml = field.options.map(opt =>
`<option value="${opt.value}" ${value === opt.value ? 'selected' : ''}>${opt.label}</option>`
).join('');
formHtml += `<div class="form-group">
<label>${field.label}</label>
<select id="plugin_field_${field.name}">${optionsHtml}</select>
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
</div>`;
} else if (field.type === 'cron') {
formHtml += `<div class="form-group">
<label>${field.label}</label>
<input type="text"
id="plugin_field_${field.name}"
value="${escapeHtml(value || '')}"
placeholder="*/5 * * * *">
${field.description ? `<small class="field-desc">${field.description}</small>` : ''}
</div>`;
} else {
formHtml += `<div class="form-group">
<label>${field.label}</label>
<input type="text"
id="plugin_field_${field.name}"
value="${escapeHtml(value || '')}"
placeholder="${field.placeholder || ''}">
</div>`;
}
});
formHtml += `</div></div>`;
});
}
formHtml += `<input type="hidden" id="pluginName" value="${plugin.name}">`;
formHtml += `<input type="hidden" id="pluginSchema" value='${JSON.stringify(schema).replace(/'/g, "&#39;")}'>`;
form.innerHTML = formHtml + `
<div class="form-actions">
<button type="button" class="btn" onclick="savePluginConfigFromForm()">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</div>
`;
modal.style.display = 'flex';
}
function togglePluginSection(sectionId) {
const content = document.getElementById(sectionId);
const arrow = document.getElementById(sectionId + '-arrow');
if (content.style.display === 'none') {
content.style.display = 'block';
arrow.textContent = '▼';
} else {
content.style.display = 'none';
arrow.textContent = '▶';
}
}
function escapeHtml(text) {
if (typeof text !== 'string') text = String(text);
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function savePluginConfigFromForm() {
const name = document.getElementById('pluginName').value;
const schema = JSON.parse(document.getElementById('pluginSchema').value);
const config = {};
if (schema.sections) {
schema.sections.forEach(section => {
section.fields.forEach(field => {
const input = document.getElementById(`plugin_field_${field.name}`);
if (!input) return;
if (field.type === 'boolean') {
config[field.name] = input.checked;
} else if (field.type === 'number') {
config[field.name] = parseFloat(input.value) || 0;
} else {
config[field.name] = input.value;
}
});
});
}
const result = await api(`/plugins/${name}/config`, {
method: 'PUT',
body: JSON.stringify({ name, config })
});
if (result.success) {
showToast('配置已保存');
closeModal();
loadPluginTable();
} else {
showToast('保存失败: ' + (result.error || ''), 'error');
}
}
function showPluginConfigModal(plugin) {
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const form = document.getElementById('nodeForm');
title.textContent = `配置插件: ${plugin.name} (JSON)`;
const configJson = JSON.stringify(plugin.config || {}, null, 2);
form.innerHTML = `
<div class="form-group">
<label>配置 (JSON)</label>
<textarea id="pluginConfigJson" class="textarea" rows="6" placeholder='{"key": "value"}'>${configJson}</textarea>
</div>
<input type="hidden" id="pluginName" value="${plugin.name}">
<div class="form-actions">
<button type="button" class="btn" onclick="savePluginConfig()">保存</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">取消</button>
</div>
`;
modal.style.display = 'flex';
}
async function savePluginConfig() {
const name = document.getElementById('pluginName').value;
let config;
try {
config = JSON.parse(document.getElementById('pluginConfigJson').value);
} catch (e) {
showToast('JSON格式错误', 'error');
return;
}
const result = await api(`/plugins/${name}/config`, {
method: 'PUT',
body: JSON.stringify({ name, config })
});
if (result.success) {
showToast('配置已保存');
closeModal();
loadPluginTable();
} else {
showToast('保存失败: ' + (result.error || ''), 'error');
}
}
async function showPluginLogs(pluginName) {
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const form = document.getElementById('nodeForm');
title.textContent = `插件日志: ${pluginName}`;
form.innerHTML = `
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<label>日志内容</label>
<button type="button" class="btn btn-xs" onclick="refreshPluginLogs('${pluginName}')">刷新</button>
</div>
<div id="pluginLogsContent" style="background: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px; max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all;">
加载中...
</div>
</div>
<input type="hidden" id="logPluginName" value="${pluginName}">
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">关闭</button>
</div>
`;
modal.style.display = 'flex';
await refreshPluginLogs(pluginName);
}
async function refreshPluginLogs(pluginName) {
const logsContent = document.getElementById('pluginLogsContent');
if (!logsContent) return;
logsContent.textContent = '加载中...';
const result = await api(`/plugins/${pluginName}/logs?lines=100`);
if (result.success) {
if (result.logs && result.logs.length > 0) {
logsContent.textContent = result.logs.join('\n');
logsContent.scrollTop = logsContent.scrollHeight;
} else {
logsContent.textContent = '暂无日志';
}
} else {
logsContent.textContent = '加载失败: ' + (result.error || '未知错误');
}
}
function showAddNodeModal(node = null) {
document.getElementById('modalTitle').textContent = '添加节点';
document.getElementById('nodeForm').reset();
document.getElementById('nodeIdOld').value = '';
document.getElementById('nodeId').disabled = false;
document.getElementById('modal').style.display = 'flex';
}
async function editNode(nodeId) {
const result = await api(`/nodes/${nodeId}`);
if (result.success) {
const node = result.data;
document.getElementById('modalTitle').textContent = '编辑节点';
document.getElementById('nodeIdOld').value = node.node_id;
document.getElementById('nodeId').value = node.node_id;
document.getElementById('nodeId').disabled = true;
document.getElementById('nodeName').value = node.name;
document.getElementById('nodeApiUrl').value = node.api_url;
document.getElementById('nodeApiKey').value = node.api_key;
document.getElementById('nodeGroup').value = node.group;
document.getElementById('nodeDesc').value = node.description || '';
document.getElementById('modal').style.display = 'flex';
}
}
async function deleteNode(nodeId) {
if (!confirm(`确定删除节点 ${nodeId}?`)) return;
const result = await api(`/nodes/${nodeId}`, { method: 'DELETE' });
if (result.success) {
showToast('节点已删除');
await loadNodesForTable();
}
}
document.getElementById('nodeForm').onsubmit = async (e) => {
e.preventDefault();
const oldId = document.getElementById('nodeIdOld').value;
const nodeData = {
node_id: document.getElementById('nodeId').value,
name: document.getElementById('nodeName').value,
api_url: document.getElementById('nodeApiUrl').value,
api_key: document.getElementById('nodeApiKey').value,
group: document.getElementById('nodeGroup').value,
description: document.getElementById('nodeDesc').value
};
let result;
if (oldId) {
result = await api(`/nodes/${oldId}`, { method: 'PUT', body: JSON.stringify(nodeData) });
} else {
result = await api('/nodes/', { method: 'POST', body: JSON.stringify(nodeData) });
}
if (result.success) {
showToast(oldId ? '节点已更新' : '节点已添加');
closeModal();
await loadNodesForTable();
} else {
showToast('失败: ' + (result.error || result.detail), 'error');
}
};
function closeModal() {
document.getElementById('modal').style.display = 'none';
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show ' + type;
setTimeout(() => toast.classList.remove('show'), 3000);
}
document.addEventListener('DOMContentLoaded', renderApp);
+190
View File
@@ -0,0 +1,190 @@
const APP_TEMPLATE = `
<div class="app">
<aside class="sidebar">
<div class="sidebar-header">
<h1>WXAuto</h1>
<span>中控系统</span>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-page="dashboard">
<span class="nav-icon">📊</span>
<span>控制台</span>
</a>
<a href="#" class="nav-item" data-page="nodes">
<span class="nav-icon">📱</span>
<span>节点管理</span>
</a>
<a href="#" class="nav-item" data-page="monitor">
<span class="nav-icon">🔔</span>
<span>监控告警</span>
</a>
<a href="#" class="nav-item" data-page="send">
<span class="nav-icon">✉️</span>
<span>快捷消息</span>
</a>
<a href="#" class="nav-item" data-page="logs">
<span class="nav-icon">📋</span>
<span>实时日志</span>
</a>
<a href="#" class="nav-item" data-page="external">
<span class="nav-icon">🔗</span>
<span>外部接入</span>
</a>
<a href="#" class="nav-item" data-page="plugins">
<span class="nav-icon">🧩</span>
<span>插件管理</span>
</a>
</nav>
</aside>
<main class="main-content">
<div id="page-content"></div>
</main>
</div>
`;
const PAGES = {
dashboard: `
<div class="page-header">
<h1>控制台</h1>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">在线节点</div>
<div class="stat-value" id="onlineCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">离线节点</div>
<div class="stat-value" id="offlineCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">总消息数</div>
<div class="stat-value" id="totalMessages">-</div>
</div>
<div class="stat-card">
<div class="stat-label">今日消息</div>
<div class="stat-value" id="todayMessages">-</div>
</div>
</div>
`,
nodes: `
<div class="page-header">
<h1>节点管理</h1>
<button class="btn btn-primary" onclick="showAddNodeModal()">
<span class="icon"></span> 添加节点
</button>
</div>
<div class="node-list" id="nodesTable">
<div class="loading">加载中...</div>
</div>
`,
monitor: `
<div class="page-header">
<h1>监控告警</h1>
</div>
<div class="settings-grid">
<div class="settings-card">
<h3>🔔 监控设置</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="monitorEnabled" onchange="updateMonitorConfig()">
启用节点监控
</label>
</div>
<div class="form-group">
<label>检测间隔(秒)</label>
<input type="number" id="checkInterval" class="input input-sm" value="30" min="10" onchange="updateMonitorConfig()">
</div>
</div>
<div class="settings-card">
<h3>📊 回调地址</h3>
<div class="form-group">
<label>Webhook 接收地址</label>
<p style="color: #666; font-size: 12px; margin: 5px 0;">
配置此地址用于接收微信状态回调
</p>
<code>/api/v1/callback/node/reset</code>
</div>
</div>
</div>
<div class="page-header" style="margin-top: 30px;">
<h3>🔗 Webhook 渠道</h3>
<button class="btn btn-primary" onclick="showAddWebhookModal()">
<span class="icon"></span> 添加 Webhook
</button>
</div>
<div class="webhook-list" id="webhookTable">
<div class="loading">加载中...</div>
</div>
`,
send: `
<div class="page-header">
<h1>快捷消息</h1>
</div>
<div class="form-card">
<div class="form-group">
<label>选择节点</label>
<select id="sendNode" class="select">
<option value="">加载中...</option>
</select>
</div>
<div class="form-group">
<label>接收人</label>
<input type="text" id="sendWho" class="input" placeholder="接收人昵称或备注">
</div>
<div class="form-group">
<label>消息内容</label>
<textarea id="quickMsg" class="textarea" rows="3" placeholder="输入消息内容..."></textarea>
</div>
<button class="btn btn-primary" onclick="sendQuickMessage()">
<span class="icon">📤</span> 发送
</button>
</div>
`,
logs: `
<div class="page-header">
<h1>实时日志</h1>
<button class="btn btn-secondary" onclick="loadLogs()">
<span class="icon">🔄</span> 刷新
</button>
</div>
<div class="log-list" id="logContainer">
<div class="loading">加载中...</div>
</div>
`,
external: `
<div class="page-header">
<h1>外部接入</h1>
</div>
<div class="form-card">
<h3>API 接入信息</h3>
<div class="form-group">
<label>API 地址</label>
<code id="externalApiUrl">/api/v1/external/send</code>
</div>
<div class="form-group">
<label>认证密钥</label>
<input type="text" id="externalApiKey" class="input" value="your-external-api-key" readonly>
</div>
<h3 style="margin-top: 20px;">接入示例 (cURL)</h3>
<pre class="code-block">curl -X POST "http://your-server:8080/api/v1/external/send" \\
-H "X-ExternalKey: your-external-api-key" \\
-H "Content-Type: application/json" \\
-d '{"node_id": "wx1", "who": "文件传输助手", "msg": "测试消息"}'</pre>
</div>
`,
plugins: `
<div class="page-header">
<h1>插件管理</h1>
</div>
<div class="plugin-list" id="pluginTable">
<div class="loading">加载中...</div>
</div>
`
};
window.PAGES = PAGES;
window.APP_TEMPLATE = APP_TEMPLATE;
function getPage(name) {
return PAGES[name] || '<p>页面不存在</p>';
}