初始化提交
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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()">×</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>
|
||||
@@ -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