初始化提交
This commit is contained in:
@@ -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;
|
||||
@@ -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, "'")}'>`;
|
||||
|
||||
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);
|
||||
@@ -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>';
|
||||
}
|
||||
Reference in New Issue
Block a user