feat: WebUI 框架设置页面 + API Key 管理 UI

新增:
- static/web_panel/pages/settings.html — API Key CRUD 界面
- 侧边栏 '框架设置' 导航项 (齿轮图标)
- BUILTIN_PAGES 增加 'settings'
- UI 功能: 创建对话框(名称/权限/过期), 列表表格, 一键删除

权限模板:
- readonly — framework.status.read + plugin.info.read
- monitor — + framework.event.subscribe
- full — admin (全部)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 14:28:59 +08:00
parent b92b1cc4fc
commit 66ad51002c
5 changed files with 157 additions and 3 deletions
+4
View File
@@ -46,6 +46,10 @@
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 4H5V8h14v2zm0 2v6H5v-6h14z"/></svg>
<span>文件管理</span>
</a>
<a class="nav-item" data-page="settings">
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81a.484.484 0 00-.41-.3h-3.98c-.2 0-.38.13-.45.32l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.19.24.31.44.31h3.98c.2 0 .38-.13.45-.32l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span>框架设置</span>
</a>
<!-- Plugin WebUI pages (collapsible) -->
<div class="nav-expandable" id="nav-plugins">
+1 -1
View File
@@ -26,7 +26,7 @@ window.toggleTheme = function(){
// Built-in pages map to ./static/pages/{name}.html
// Plugin pages load from /plugin/{name}
var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files'];
var BUILTIN_PAGES = ['dashboard','logs','console','plugins','projects','proxy','files','settings'];
var _pluginPagesCache = null; // {plugin_name: {path, title, icon}}
function currentRoute() {
+150
View File
@@ -0,0 +1,150 @@
<div class="plugin-page-root">
<h2>⚙ 框架设置</h2>
<!-- ── API Key 管理 ── -->
<div class="card" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">🔑 API Key 管理</h3>
<button class="btn btn-filled" onclick="showCreateKey()">+ 创建 Key</button>
</div>
<p style="color:var(--text-dim);font-size:.85rem;margin-bottom:12px">
API Key 用于服务器间安全通信。支持权限范围控制和过期时间。
</p>
<div id="apikey-list" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="text-align:left;border-bottom:1px solid var(--border)">
<th style="padding:8px">名称</th><th style="padding:8px">Key</th><th style="padding:8px">权限</th><th style="padding:8px">用量</th><th style="padding:8px">过期</th><th style="padding:8px">操作</th>
</tr></thead>
<tbody id="apikey-tbody"><tr><td colspan="6" style="padding:16px;text-align:center;color:var(--text-dim)">加载中...</td></tr></tbody>
</table>
</div>
</div>
<!-- ── 创建对话框 (hidden) ── -->
<div id="create-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:1000;display:none;align-items:center;justify-content:center">
<div class="card" style="min-width:420px;max-width:500px">
<h3 style="margin:0 0 16px 0">创建 API Key</h3>
<div class="form-group" style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px;font-size:.85rem">名称</label>
<input id="new-key-name" class="input" placeholder="例如: 监控插件" style="width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
</div>
<div class="form-group" style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px;font-size:.85rem">权限范围</label>
<select id="new-key-preset" class="input" style="width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
<option value="readonly">只读 — 仅查询系统状态</option>
<option value="monitor" selected>监控 — 查询 + 事件订阅</option>
<option value="full">管理 — 全部权限</option>
</select>
</div>
<div class="form-group" style="margin-bottom:16px">
<label style="display:block;margin-bottom:4px;font-size:.85rem">过期时间 (秒, 0=永不过期)</label>
<input id="new-key-ttl" class="input" type="number" value="0" min="0" style="width:100%;padding:8px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--shape-xs)">
<span style="font-size:.75rem;color:var(--text-dim)">常用: 86400(1天) 604800(7天) 2592000(30天)</span>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-outlined" onclick="hideCreateKey()">取消</button>
<button class="btn btn-filled" onclick="doCreateKey()">创建</button>
</div>
<div id="create-result" style="margin-top:12px;display:none">
<div style="background:var(--md-sys-color-surface-container-lowest);padding:12px;border-radius:var(--shape-xs);word-break:break-all">
<strong style="color:var(--success)">✅ Key 已创建 (仅显示一次):</strong>
<code id="new-key-display" style="display:block;margin-top:4px;font-size:.9rem;color:var(--primary)"></code>
</div>
</div>
</div>
</div>
<style>
.input:focus { outline:1px solid var(--primary) }
#apikey-tbody td { padding:8px; border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
#apikey-tbody tr:hover { background:var(--md-sys-color-surface-container-lowest) }
</style>
<script>
// ── 加载 API Keys ──
async function loadKeys() {
var tbody = document.getElementById("apikey-tbody");
try {
var resp = await fetch("./api/apikeys");
var data = await resp.json();
if (!data.keys || !data.keys.length) {
tbody.innerHTML = '<tr><td colspan="6" style="padding:16px;text-align:center;color:var(--text-dim)">暂无 API Key</td></tr>';
return;
}
var rows = [];
data.keys.forEach(function(k) {
var expStr = k.expires_at > 0 ? new Date(k.expires_at*1000).toLocaleDateString() : "永不过期";
if (k.expired) expStr = '<span style="color:var(--error)">已过期</span>';
var permLabel = k.permissions && k.permissions[0] === "admin" ? "管理" : (k.permissions && k.permissions.length <= 2 ? "只读" : "监控");
rows.push(
'<tr>' +
'<td>' + esc(k.name) + '</td>' +
'<td><code>' + esc(k.key) + '</code></td>' +
'<td><span class="badge">' + permLabel + '</span></td>' +
'<td>' + (k.request_count || 0) + '</td>' +
'<td>' + expStr + '</td>' +
'<td><button class="btn btn-sm btn-outlined" style="color:var(--error);border-color:var(--error)" onclick="deleteKey(\'' + esc(k.full_key) + '\',\'' + esc(k.name) + '\')">删除</button></td>' +
'</tr>'
);
});
tbody.innerHTML = rows.join("");
} catch(e) {
tbody.innerHTML = '<tr><td colspan="6" style="padding:16px;text-align:center;color:var(--error)">加载失败: ' + esc(String(e)) + '</td></tr>';
}
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── 创建对话框 ──
function showCreateKey() {
document.getElementById("create-dialog").style.display = "flex";
document.getElementById("create-result").style.display = "none";
document.getElementById("new-key-name").value = "";
document.getElementById("new-key-ttl").value = "0";
}
function hideCreateKey() {
document.getElementById("create-dialog").style.display = "none";
}
async function doCreateKey() {
var name = document.getElementById("new-key-name").value.trim();
if (!name) { alert("请输入名称"); return; }
var preset = document.getElementById("new-key-preset").value;
var ttl = parseInt(document.getElementById("new-key-ttl").value) || 0;
try {
var resp = await fetch("./api/apikeys", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({name: name, preset: preset, ttl: ttl})
});
var data = await resp.json();
if (data.ok) {
document.getElementById("create-result").style.display = "block";
document.getElementById("new-key-display").textContent = data.key;
loadKeys();
} else {
alert("创建失败: " + (data.error || "未知错误"));
}
} catch(e) { alert("请求失败: " + e); }
}
// ── 删除 ──
async function deleteKey(fullKey, name) {
if (!confirm("确认删除 Key '" + name + "' 此操作不可撤销。")) return;
try {
var resp = await fetch("./api/apikeys", {
method: "DELETE",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({key: fullKey})
});
var data = await resp.json();
if (data.ok) loadKeys();
else alert("删除失败: " + (data.error || "未知错误"));
} catch(e) { alert("请求失败: " + e); }
}
// ── 初始加载 ──
loadKeys();
</script>
</div>