Files
dsh_shenxian/web/portal.html
T
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

1009 lines
72 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理门户</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon-180.png" />
<link rel="stylesheet" href="/design.css" />
<style>
/* 06-工作台UI规范 Token:亮色工具后台 + 蓝主色 + 橙强调 */
:root {
--bp-primary: #2f6fed; --bp-primary-soft: #eaf1fe; --bp-primary-hover: #2a5fd0;
--bp-accent: #e8590c; --bp-danger: #d93026;
--bp-bg: #f5f6f8; --bp-panel: #ffffff; --bp-border: #e3e6ea;
--bp-text: #24292f; --bp-dim: #6b7280; --bp-head: #f6f8fa;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; background: var(--bp-bg); color: var(--bp-text); }
/* ── 顶栏 ── */
.topbar { display: flex; align-items: center; gap: 20px; height: 55px; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--bp-border); position: sticky; top: 0; z-index: 50; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
.brand .logo { width: 28px; height: 28px; border-radius: 8px; }
.crumbs { display: flex; align-items: center; gap: 6px; flex: 1; font-size: 14px; }
.crumb-home { color: var(--bp-primary); text-decoration: none; cursor: pointer; }
.crumb-home:hover { text-decoration: underline; }
.crumb-sep { color: var(--bp-dim); }
.crumb-current { color: var(--bp-text); font-weight: 600; }
.top-actions { display: flex; align-items: center; gap: 10px; }
.who { color: var(--bp-dim); font-size: 13px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; font-size: 14px; cursor: pointer; border: 1px solid var(--bp-border); background: #fff; color: var(--bp-text); transition: all .15s; }
.btn:hover { border-color: var(--bp-primary); color: var(--bp-primary); }
.btn-primary { background: var(--bp-primary); border-color: var(--bp-primary); color: #fff; }
.btn-primary:hover { background: var(--bp-primary-hover); color: #fff; }
.btn:disabled { opacity: .5; cursor: default; }
/* ── 内容区 ── */
#view { max-width: 960px; margin: 0 auto; padding: 24px 20px 48px; }
.page-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.page-head .page-title { margin: 0; }
.page-head .page-sub { margin: 4px 0 0; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.page-sub { font-size: 13px; color: var(--bp-dim); margin: 0 0 20px; }
.home-section { margin-bottom: 24px; }
.home-section-title { font-size: 13px; color: var(--bp-dim); font-weight: 600; margin: 0 0 10px; }
.card { background: #fff; border: 1px solid var(--bp-border); border-radius: 10px; padding: 18px; }
/* ── 首页导航卡片 ── */
.nav-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
.nav-card { display: flex; flex-direction: column; gap: 6px; background: #fff; border: 1px solid var(--bp-border); border-radius: 10px; padding: 18px 16px; cursor: pointer; transition: all .15s; }
.nav-card:hover { border-color: var(--bp-primary); box-shadow: 0 4px 12px rgba(47,111,237,.12); transform: translateY(-2px); }
.nav-card .ic { color: var(--bp-primary); }
.nav-card .t { font-size: 15px; font-weight: 600; }
.nav-card .d { font-size: 12px; color: var(--bp-dim); }
/* ── 表格 ── */
.table-wrap { overflow: auto; border: 1px solid var(--bp-border); border-radius: 8px; background: #fff; }
table.tbl { width: 100%; border-collapse: collapse; font-size: 15px; }
table.tbl th { position: sticky; top: 0; background: var(--bp-head); padding: 8px 10px; text-align: left; font-weight: 600; white-space: nowrap; border-bottom: 1px solid var(--bp-border); z-index: 1; }
table.tbl td { padding: 7px 10px; border-bottom: 1px solid var(--bp-border); white-space: nowrap; }
table.tbl tbody tr:last-child td { border-bottom: none; }
table.tbl tbody tr:hover { background: var(--bp-primary-soft); }
td.num { text-align: right; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--bp-dim); }
td.desc { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bp-dim); }
td.act { text-align: center; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 13px; }
.badge.admin { background: var(--bp-primary-soft); color: var(--bp-primary); }
.badge.active { background: #e6f7ef; color: #1a7f37; }
.badge.pending { background: #fff4e5; color: var(--bp-accent); }
.badge.disabled { background: #f0f1f3; color: var(--bp-dim); }
/* 数据面「声明非法 / 执行失败」用 danger 色(与 P0 安全命中同一视觉语言) */
.badge.danger { background: #fdecea; color: var(--bp-danger); }
.btn-sm { display: inline-flex; align-items: center; padding: 3px 10px; font-size: 14px; border-radius: 6px; cursor: pointer; border: 1px solid var(--bp-border); background: #fff; color: var(--bp-text); }
.btn-sm:hover { border-color: var(--bp-primary); color: var(--bp-primary); }
.btn-sm.danger { color: var(--bp-danger); }
.btn-sm.danger:hover { border-color: var(--bp-danger); }
/* ── 上传行 / 提示 ── */
.upload-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.upload-row input[type='file'] { flex: 1; min-width: 220px; padding: 8px; border: 1px solid var(--bp-border); border-radius: 8px; font-size: 14px; background: #fff; }
.hint { font-size: 12px; color: var(--bp-dim); margin: 0 0 14px; line-height: 1.6; }
/* ── 页内 Tab(06 §4.4 下划线式;类名避开全局 .tab 以免与顶栏胶囊式冲突) ── */
.pg-tabs { display: flex; gap: 22px; border-bottom: 1px solid var(--bp-border); margin: 0 0 16px; }
.pg-tab { padding: 8px 2px 10px; font-size: 15px; color: var(--bp-dim); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color .15s, border-color .15s; user-select: none; white-space: nowrap; }
.pg-tab:hover { color: var(--bp-primary); }
.pg-tab.active { color: var(--bp-primary); font-weight: 600; border-bottom-color: var(--bp-primary); }
.pg-tab .pg-cnt { display: inline-block; min-width: 18px; padding: 1px 6px; margin-left: 6px; border-radius: 9px; background: var(--bp-primary-soft); color: var(--bp-primary); font-size: 12px; font-weight: 600; line-height: 16px; text-align: center; }
.pg-tab:not(.active) .pg-cnt { background: #f0f1f3; color: var(--bp-dim); }
.card-h { display: flex; align-items: baseline; gap: 10px; font-size: 15px; font-weight: 600; margin: 0 0 12px; }
.card-h .sub { font-size: 13px; font-weight: 400; color: var(--bp-dim); }
/* ── 分段容器(页内多组信息视觉分段 · 06 §4.3 层级 ≤3) ── */
.sec-head { display: flex; align-items: baseline; gap: 8px; margin: 26px 0 10px; }
.sec-head:first-child { margin-top: 0; }
.sec-title { font-size: 15px; font-weight: 600; color: var(--bp-text); }
.sec-sub { font-size: 13px; color: var(--bp-dim); }
.sec-right { margin-left: auto; display: inline-flex; gap: 8px; align-items: center; }
.sec-head .pg-cnt { display: inline-block; min-width: 18px; padding: 1px 6px; border-radius: 9px; background: var(--bp-primary-soft); color: var(--bp-primary); font-size: 12px; font-weight: 600; line-height: 16px; text-align: center; }
.sub-head { display: flex; align-items: baseline; gap: 8px; margin: 16px 0 8px; }
.sub-title { font-size: 14px; font-weight: 600; color: var(--bp-text); }
.sub-head .pg-cnt { display: inline-block; min-width: 18px; padding: 1px 6px; border-radius: 9px; background: #f0f1f3; color: var(--bp-dim); font-size: 12px; font-weight: 600; line-height: 16px; text-align: center; }
.sec-note { font-size: 12px; color: var(--bp-dim); line-height: 1.6; margin: 0 0 10px; }
/* ── 白名单表格:以「说明」为主视觉(一眼看懂插件用途) ── */
.wl-cell { white-space: normal; }
.wl-desc { font-size: 14px; line-height: 1.5; color: var(--bp-text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 620px; }
.wl-desc.fallback { font-weight: 600; }
.wl-meta { font-size: 12px; color: var(--bp-dim); margin-top: 3px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wl-cat { font-size: 12px; color: var(--bp-dim); } /* ── 文件树 ── */
.wl-link { text-decoration: none; white-space: nowrap; }
/* 插件管理页:表格 6 列信息量大,单独放宽容器(其他页仍 960)。 */
#view.page-plugins { max-width: 1200px; }
/* 2026-09-12:插件管理页底部留白已移除(见档案 61 §六修正)。
原因:列表上方约 362px + 列表高度 + 按钮行,若再加底部留白会让整页必然超出视口。
现列表高度纯自适应(calc(100vh - 400px)),小屏自动变矮、不超屏;大屏自然更高。 */
.wl-cat { white-space: nowrap; } .pathbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pathbar .crumbs { gap: 4px; }
.pathbar .crumb { color: var(--bp-primary); cursor: pointer; font-size: 14px; }
.pathbar .crumb:hover { text-decoration: underline; }
.dsh-bar { display: flex; align-items: center; gap: 12px; background: #fff; border: 1px solid var(--bp-border); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.dsh-bar .stat { font-size: 14px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot.on { background: #1fb56a; } .dot.off { background: #c4c9d0; }
/* ── 密钥 ── */
.key-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--bp-border); }
.key-row:last-child { border-bottom: none; }
/* ── 空状态 / Toast ── */
.empty { padding: 48px 20px; text-align: center; color: var(--bp-dim); font-size: 14px; }
.toast { position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%); background: #24292f; color: #fff; padding: 10px 18px; border-radius: 8px; font-size: 14px; z-index: 200; display: none; white-space: pre-wrap; max-width: 90vw; }
.toast.show { display: block; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><img class="logo" src="/favicon-192.png" alt="" /><span>管理门户</span></div>
<nav class="crumbs" id="crumbs"></nav>
<div class="top-actions">
<span class="who" id="who"></span>
<button class="btn" id="btnHome">首页</button>
<button class="btn" id="logoutBtn">退出</button>
</div>
</header>
<main id="view"><div class="empty">加载中…</div></main>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id)
let toastTimer = null
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])) }
function fmtSize(n) { if (!n) return '—'; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB' }
function toast(text) { const el = $('toast'); el.textContent = text; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 1800) }
function when(ms) { return ms ? new Date(ms).toLocaleString() : '—' }
async function me() { const r = await fetch('/api/auth/me'); return r.ok ? (await r.json()).user : null }
function postJson(url, body) { return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) }
function readAsBase64(file) { return String(new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file) })).split(',')[1] ?? '' }
function emptyRow(cols, text) { return `<tr><td colspan="${cols}" style="text-align:center;color:var(--bp-dim);padding:40px 10px">${esc(text)}</td></tr>` }
/**
* 分段容器:一个 `<section class="sec">` + 标题行(名称 + 计数 + 副说明 + 可选右侧动作)。
* 用来把同一页里的多组信息**视觉上分段**,⛔ 不再靠一长串卡片堆叠。
*/
function sec(title, count, sub, right) {
return `<div class="sec-head"><span class="sec-title">${esc(title)}</span>` +
(count === undefined || count === null ? '' : `<span class="pg-cnt">${esc(String(count))}</span>`) +
(sub ? `<span class="sec-sub">${esc(sub)}</span>` : '') +
(right ? `<span class="sec-right">${right}</span>` : '') + '</div>'
}
/** 分段内的小标题(比 `sec()` 低一级,用于「已启用 / 已停用」这类子分组)。 */
function subHead(title, count, sub) {
return `<div class="sub-head"><span class="sub-title">${esc(title)}</span>` +
(count === undefined || count === null ? '' : `<span class="pg-cnt">${esc(String(count))}</span>`) +
(sub ? `<span class="sec-sub">${esc(sub)}</span>` : '') + '</div>'
}
/* ================= 路由 ================= */
const CRUMB_TITLES = { files: '实例管理', keys: '模型管理', users: '用户管理', skills: '技能管理', plugins: '插件管理' }
function parseHash() { const h = (location.hash || '#/').replace(/^#/, ''); const parts = h.split('/').filter(Boolean); return parts[0] || '' }
function updateCrumbs(name) {
const c = $('crumbs')
if (!name) { c.innerHTML = '<span class="crumb-current">管理门户</span>'; return }
c.innerHTML = `<a class="crumb-home" href="#/">管理门户</a><span class="crumb-sep">/</span><span class="crumb-current">${esc(CRUMB_TITLES[name] || name)}</span>`
}
function pageHead(title, sub) {
return `<div class="page-head"><button class="btn" data-back>← 返回</button><div><h1 class="page-title">${title}</h1><p class="page-sub">${sub}</p></div></div>`
}
function renderHome() {
const sec1 = [
{ ic: '🖥️', t: '实例管理', d: '文件树 + DSH 启动', hash: '#/files' },
{ ic: '🔑', t: '模型管理', d: '平台共享密钥', hash: '#/keys' },
]
const sec2 = [
{ ic: '👥', t: '用户管理', d: '审批 / 禁用 / 删除', hash: '#/users', admin: true },
{ ic: '🧩', t: '技能管理', d: '共享技能上传', hash: '#/skills', admin: true },
{ ic: '🔌', t: '插件管理', d: '功能插件投放', hash: '#/plugins', admin: true },
{ ic: '📦', t: '运行环境', d: '共享运行时与工具', hash: '#/runtime', admin: true },
]
const grid = (cards) => cards.filter((c) => !c.admin || isAdmin).map((c) => `
<div class="nav-card" data-hash="${c.hash}">
<div class="ic">${c.ic}</div>
<div class="t">${c.t}</div>
<div class="d">${c.d}</div>
</div>`).join('')
return `<h1 class="page-title">管理门户</h1><p class="page-sub">平台管理</p>
<div class="home-section"><div class="home-section-title">服务</div><div class="nav-grid">${grid(sec1)}</div></div>
<div class="home-section"><div class="home-section-title">管理</div><div class="nav-grid">${grid(sec2)}</div></div>`
}
/* ================= 服务管理(#/files) ================= */
let currentPath = []
function pathString() { return currentPath.join('/') }
async function renderFiles() {
return `${pageHead('服务管理', '浏览文件、在此文件夹启动 DSH')}
<div class="dsh-bar">
<span class="stat" id="dshState"><span class="dot off"></span>未运行</span>
<button class="btn btn-primary" id="launchBtn">启动 DSH</button>
<button class="btn" id="stopBtn" style="display:none">停止</button>
<button class="btn" id="openDshBtn" style="display:none">打开 DSH ↗</button>
<span style="flex:1"></span>
<span style="font-size:13px;color:var(--bp-dim)">启动目录:<b id="dshFolder">根目录</b></span>
</div>
<div class="card">
<div class="pathbar">
<button class="btn btn-sm" id="newFolderBtn">新建文件夹</button>
<button class="btn btn-sm" id="uploadBtn">上传文件</button>
<span class="crumbs" id="breadcrumb"></span>
</div>
<div class="table-wrap"><table class="tbl">
<thead><tr><th>名称</th><th>类型</th><th class="num" style="text-align:right">大小</th><th>修改时间</th></tr></thead>
<tbody id="rows"></tbody>
</table></div>
</div>
<input id="fileInput" type="file" style="display:none" />`
}
async function initFiles() {
async function load() {
const p = pathString()
const res = await fetch('/api/desktop/tree' + (p ? '?path=' + encodeURIComponent(p) : ''))
const body = await res.json()
const rows = $('rows')
rows.innerHTML = ''
if (p) rows.insertAdjacentHTML('beforeend', '<tr><td class="dir" data-up="1">..</td><td></td><td></td><td></td></tr>')
for (const e of body.entries || []) {
const href = e.type === 'dir' ? ' class="dir" data-path="' + encodeURIComponent(p ? p + '/' + e.name : e.name) + '"' : ''
rows.insertAdjacentHTML('beforeend', `<tr><td${href}>${esc(e.name)}</td><td>${e.type === 'dir' ? '文件夹' : '文件'}</td><td class="num">${fmtSize(e.size)}</td><td>${new Date(e.mtimeMs).toLocaleString()}</td></tr>`)
}
// 面包屑
const parts = ['<span class="crumb" data-path="">根</span>']
let acc = []
for (const seg of currentPath) { acc.push(seg); parts.push('<span class="crumb-sep">/</span><span class="crumb" data-path="' + esc(acc.join('/')) + '">' + esc(seg) + '</span>') }
$('breadcrumb').innerHTML = parts.join('')
$('breadcrumb').querySelectorAll('.crumb').forEach((c) => c.addEventListener('click', () => { currentPath = c.dataset.path ? c.dataset.path.split('/').filter(Boolean) : []; initFiles() }))
rows.querySelectorAll('.dir[data-path]').forEach((el) => el.addEventListener('click', () => { currentPath = decodeURIComponent(el.dataset.path).split('/').filter(Boolean); initFiles() }))
const up = rows.querySelector('.dir[data-up]')
if (up) up.addEventListener('click', () => { currentPath = currentPath.slice(0, -1); initFiles() })
}
async function refreshDsh() {
const res = await fetch('/api/dsh/status'); if (!res.ok) return
const body = await res.json()
const running = body.running
$('dshState').innerHTML = `<span class="dot ${running ? 'on' : 'off'}"></span>${running ? '运行中' : '未运行'}`
$('stopBtn').style.display = running ? '' : 'none'
$('openDshBtn').style.display = running ? '' : 'none'
if (running) $('openDshBtn').onclick = () => window.open(body.url, '_blank')
}
$('dshFolder').textContent = pathString() || '根目录'
$('launchBtn').onclick = async () => {
const res = await postJson('/api/dsh/launch', { folder: pathString() })
if (!res.ok) { const b = await res.json().catch(() => ({})); toast(b.error === 'already_running' ? '已有运行中的 DSH' : '启动失败'); return }
toast('DSH 已启动'); await refreshDsh()
}
$('stopBtn').onclick = async () => { await fetch('/api/dsh/stop', { method: 'POST' }); await refreshDsh() }
$('newFolderBtn').onclick = () => { const name = prompt('文件夹名称:'); if (name) doCreate(name, 'dir') }
$('uploadBtn').onclick = () => $('fileInput').click()
$('fileInput').onchange = async (ev) => {
const file = ev.target.files[0]; if (!file) return
const data = await readAsBase64(file)
const res = await postJson('/api/fs/upload', { path: pathString(), name: file.name, data })
if (!res.ok) { const e = (await res.json().catch(() => ({}))).error; toast('上传失败:' + (e || res.status)); return }
ev.target.value = ''; await load()
}
async function doCreate(name, type) {
const res = await postJson('/api/fs/create', { path: pathString(), name, type })
if (!res.ok) { const e = (await res.json().catch(() => ({}))).error; toast('创建失败:' + (e || res.status)); return }
await load()
}
await load(); await refreshDsh()
}
/* ================= 密钥管理(#/keys) ================= */
async function renderKeys() {
return `${pageHead('密钥管理', '平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)')}
<div class="card">
<div id="keyList"></div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<input id="keyNameInput" placeholder="名称(如 家用 / 服务器)" style="flex:1;min-width:120px;padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px" />
<input id="keyValueInput" type="password" placeholder="sk-..." style="flex:1.4;min-width:160px;padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px" />
<button class="btn btn-primary" id="addKeyBtn">添加</button>
</div>
</div>`
}
async function initKeys() {
async function load() {
const res = await fetch('/api/me/keys'); if (!res.ok) return
const { keys } = await res.json()
const list = $('keyList')
if (keys.length === 0) { list.innerHTML = '<div class="empty">暂无平台共享密钥。未自配密钥的用户将无法调用模型 —— 用户可在自己实例的「设置 → 我的密钥」里配置自己的密钥。</div>'; return }
list.innerHTML = keys.map((k) => `<div class="key-row">
<span class="dot ${k.enabled ? 'on' : 'off'}"></span><strong>${esc(k.name)}</strong><span style="flex:1"></span>
${k.enabled ? '<span style="font-size:12px;color:#1a7f37">启用中</span>' : `<button class="btn-sm" data-sel="${k.id}">启用</button>`}
<button class="btn-sm danger" data-del="${k.id}">删除</button></div>`).join('')
list.querySelectorAll('[data-sel]').forEach((b) => b.onclick = async () => { await fetch(`/api/me/keys/${b.dataset.sel}/select`, { method: 'POST' }); await load() })
list.querySelectorAll('[data-del]').forEach((b) => b.onclick = async () => { if (confirm('删除这个密钥?')) { await fetch(`/api/me/keys/${b.dataset.del}`, { method: 'DELETE' }); await load() } })
}
$('addKeyBtn').onclick = async () => {
const name = $('keyNameInput').value.trim(), apiKey = $('keyValueInput').value.trim()
if (!name || !apiKey) { toast('请填名称和密钥'); return }
const res = await postJson('/api/me/keys', { name, apiKey })
if (!res.ok) { toast('名称或密钥格式无效'); return }
$('keyNameInput').value = ''; $('keyValueInput').value = ''; await load()
}
await load()
}
/* ================= 用户管理(#/users) ================= */
async function renderUsers() {
return `${pageHead('用户管理', '审批 / 禁用 / 删除用户')}
<div class="table-wrap"><table class="tbl">
<thead><tr><th>用户名</th><th>角色</th><th>注册时间</th><th class="act">操作</th></tr></thead>
<tbody id="usersTbody"></tbody></table></div>`
}
async function initUsers() {
async function load() {
const res = await fetch('/api/admin/users'); const { users } = await res.json()
const tbody = $('usersTbody'); tbody.innerHTML = ''
const badge = { admin: '管理员', pending: '待审核', active: '正常', disabled: '已禁用' }
for (const u of users) {
const actions = u.role === 'pending' ? `<button class="btn-sm" data-act="approve" data-id="${u.id}">通过</button>`
: u.role === 'active' ? `<button class="btn-sm danger" data-act="disable" data-id="${u.id}">禁用</button>`
: u.role === 'disabled' ? `<button class="btn-sm" data-act="enable" data-id="${u.id}">恢复</button>` : ''
const del = u.role !== 'admin' ? `<button class="btn-sm danger" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">删除</button>` : ''
tbody.insertAdjacentHTML('beforeend', `<tr><td><strong>${esc(u.username)}</strong></td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${new Date(u.createdAt).toLocaleString()}</td><td class="act">${actions}${actions && del ? ' ' : ''}${del}</td></tr>`)
}
tbody.onclick = async (event) => {
const btn = event.target.closest('button[data-act]'); if (!btn) return
const { act, id } = btn.dataset
if (act === 'del') {
const name = prompt(`删除后不可恢复。输入用户名「${btn.dataset.name}」以确认:`)
if (name !== btn.dataset.name) { toast('用户名不匹配,已取消'); return }
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
if (res.ok) await load(); else toast('删除失败(admin 不可删除)')
return
}
const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' })
if (res.ok) await load(); else toast('操作失败')
}
}
await load()
}
/* ================= 技能管理(#/skills) ================= */
async function renderSkills() {
return `${pageHead('技能管理', '共享技能(所有用户可用 · 只读)')}
<div class="card">
<div class="upload-row"><input type="file" id="skillFile" accept=".zip" /><button class="btn btn-primary" id="skillUploadBtn">上传 / 替换</button></div>
<p class="hint">仅支持 .zip:包内单个顶层目录且含 SKILL.md(name 小写连字符 + description)。上传先做安全检测;同名确认后整体替换。</p>
<div class="table-wrap"><table class="tbl">
<thead><tr><th>技能名</th><th>说明</th><th class="num" style="text-align:right">文件</th><th class="num" style="text-align:right">大小</th><th>更新时间</th><th class="act">操作</th></tr></thead>
<tbody id="skillTbody"></tbody></table></div>
</div>`
}
async function initSkills() {
async function load() {
const tbody = $('skillTbody'); tbody.innerHTML = emptyRow(6, '加载中…')
try {
const { skills } = await (await fetch('/api/skills/shared')).json()
if (skills.length === 0) { tbody.innerHTML = emptyRow(6, '暂无共享技能 —— 上传 .zip 投放第一个'); return }
tbody.innerHTML = skills.map((s) => `<tr><td><strong>${esc(s.name)}</strong></td><td class="desc" title="${esc(s.description)}">${esc(s.description) || '—'}</td><td class="num">${s.files ?? '—'}</td><td class="num">${fmtSize(s.size)}</td><td>${when(s.mtimeMs)}</td><td class="act"><button class="btn-sm danger" data-del="${esc(s.name)}">删除</button></td></tr>`).join('')
} catch { tbody.innerHTML = emptyRow(6, '加载失败') }
}
$('skillUploadBtn').onclick = async () => {
const input = $('skillFile'); const file = input.files[0]
if (!file) { toast('请先选择 .zip 文件'); return }
if (!file.name.toLowerCase().endsWith('.zip')) { toast('仅支持 .zip 文件'); return }
if (file.size > 150 * 1024 * 1024) { toast('文件过大(>150MB)'); return }
const btn = $('skillUploadBtn'); btn.disabled = true; btn.textContent = '检测中…'
const base64 = await readAsBase64(file)
try {
const res = await postJson('/api/skills/shared', { file: base64, filename: file.name })
const data = await res.json().catch(() => ({}))
if (!res.ok) { toast('上传失败:' + (data.error ?? res.status)); return }
if (!data.conflict) { toast('✓ 已安装技能「' + data.skill.name + '」'); input.value = ''; await load(); return }
const ok = confirm('检测到同名技能「' + data.skill.name + '」已存在。确认整体替换?\n\n⚠️ 替换将删除旧技能全部文件,不可恢复。')
if (!ok) { toast('已取消'); return }
const r2 = await postJson('/api/skills/shared/apply', { stagedId: data.stagedId }); const d2 = await r2.json().catch(() => ({}))
if (!r2.ok) { toast('替换失败:' + (d2.error ?? r2.status)); return }
toast('✓ 已整体替换技能「' + d2.skill.name + '」'); input.value = ''; await load()
} catch { toast('上传失败:网络错误') } finally { btn.disabled = false; btn.textContent = '上传 / 替换' }
}
$('skillTbody').onclick = async (event) => {
const btn = event.target.closest('button[data-del]'); if (!btn) return
if (!confirm(`删除共享技能「${btn.dataset.del}」?所有用户将立即不可用。`)) return
const res = await fetch('/api/skills/shared/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' })
if (res.ok) { toast('✓ 已删除'); await load() } else toast('删除失败')
}
await load()
}
/* ================= 插件管理(#/plugins) ================= */
async function renderPlugins() {
return `${pageHead('插件管理', '功能插件(系统外插件)的投放与管理')}
<div class="pg-tabs" id="pluginTabs">
<div class="pg-tab active" data-tab="official">官方推荐插件</div>
<div class="pg-tab" data-tab="manual">手动添加 / 管理<span class="pg-cnt" id="poolCnt">0</span></div>
<div class="pg-tab" data-tab="visibility">用户可见性(三段式)</div>
</div>
<div id="pane-official">
<div class="card">
<div class="upload-row">
<input id="wlQ" placeholder="搜索插件名 / 描述 / npm 包名" style="flex:1;min-width:200px;padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px" />
<select id="wlCat" style="padding:8px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:14px;background:#fff"><option value="">全部分类</option></select>
<label style="display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--bp-dim);cursor:pointer"><input type="checkbox" id="wlOnlyImportable" checked />只看可导入</label>
<button class="btn" id="wlSearchBtn">搜索</button>
<button class="btn" id="wlRefreshBtn" title="绕过本地缓存,从官方站重新下载插件目录(约数秒;平时打开本页不会下载,直接读本地缓存)">重新拉取目录</button>
</div>
<p class="hint">来源:<b>awesome-dsh-plugin</b> 官方插件目录(dsh 官方社区精选,3400+ 插件,按下载量排序,取前 300 条)。<br />
导入只接受<b>预构建包</b>:官方 npm 包(registry tarball)或作者发布的 release 资产 —— 平台不执行任何第三方构建脚本。标记「需源码构建」的插件暂不支持一键导入(官方安装方式为 <code>github:owner/repo</code>),可自行构建后到「手动添加 / 管理」上传。</p>
<div style="margin-bottom:8px"><span style="color:var(--bp-dim);font-size:13px" id="wlInfo">加载中…</span></div>
<div class="table-wrap" style="max-height:calc(100vh - 520px)"><table class="tbl">
<thead><tr><th style="width:34px"></th><th>插件说明</th><th>分类</th><th>导入方式</th><th class="num" style="text-align:right">下载量</th><th class="act">详情</th></tr></thead>
<tbody id="wlTbody"></tbody></table></div>
<div style="margin-top:10px;display:flex;align-items:center;gap:12px">
<button class="btn btn-primary" id="wlImportBtn">导入到平台</button>
<span style="color:var(--bp-dim);font-size:13px" id="wlSelInfo">已选 0</span>
</div>
<div id="wlResult" style="margin-top:12px"></div>
</div>
</div>
<div id="pane-manual" hidden>
<div class="card" style="margin-bottom:16px">
<div class="card-h">投放插件<span class="sub">上传 .tgz 收录到平台</span></div>
<div class="upload-row"><input type="file" id="pluginFile" accept=".tgz" /><button class="btn btn-primary" id="pluginUploadBtn">上传 / 替换</button></div>
<p class="hint" style="margin:0">仅支持 .tgz:dsh 插件 bundle(含 package.json,name 为 npm 包名)。上传先做安全检测;同名按替换策略(旧包先删再落新包)。命中 P0 规则时默认拒绝,但会<b>逐条列出命中内容</b> —— 确认可信可显式声明信任后投放(会记入审计)。</p>
<div id="pluginScanBox"></div>
</div>
<div class="card">
<div class="card-h">已投放插件<span class="sub" id="poolHint">共 0 个 · 全部默认禁用</span></div>
<p class="hint"><b>投放 ≠ 生效。</b>这里只是把插件<b>收录到平台</b>,对任何用户都不生效;用户要真正用上,需在自己实例的「设置 → 功能插件」里勾选启用 —— 启用时平台才会把插件装进他的环境,重启实例后生效。</p>
<p class="hint"><b>「数据面」列:</b>插件若在包内声明了数据表(<code>package.json</code> 的 <code>dsh.data</code>),平台会为它单独建一个库。状态为 <b>就绪</b>之前,插件<b>不能发布、用户也不能启用</b>(这是硬门禁,不是只置灰按钮)。建库与迁移都要先把改动清单给 admin 看过、点「确认执行」才会跑。</p>
<div class="table-wrap"><table class="tbl">
<thead><tr><th>插件名</th><th>版本</th><th>数据面</th><th>说明</th><th class="num" style="text-align:right">大小</th><th>投放时间</th><th class="act">操作</th></tr></thead>
<tbody id="pluginTbody"></tbody></table></div>
</div>
<!-- 预演清单面板(两段式「预演 → 确认执行」的第一段;同族交互在建库 / 迁移 / 彻底卸载三处复用) -->
<div id="dataPlanePanel"></div>
</div>
<div id="pane-visibility" hidden>
<div class="card">
<div class="card-h">用户可见性(三段式)<span class="sub">平台共享只读 · 已启用 · 已停用</span></div>
<p class="hint" style="margin-bottom:10px">按 <b>D1</b> 口径:候选池里的插件对<b>所有登录用户默认可见</b>,<b>开通由用户自己点</b>(平台不替用户默认安装)。本页把用户实际会看到的形态摊开给 admin 看 —— 三段之间互斥且合起来 = 共享层全集。</p>
<p class="hint" style="margin-bottom:12px"><b>端侧边界:</b>端侧设备(桌面壳 / 浏览器)访问的是<b>同一个平台 API</b>,平台只保证「<b>能看见 + 能开通</b>」—— <b>不要求端侧本地装包</b>,<b>不下发平台级凭据到客户端</b>。因此下面三段在任何终端的口径完全一致。</p>
<div id="visWrap"><div class="empty">加载中…</div></div>
</div>
</div>`
}
async function initPlugins() {
const wlSelected = new Set()
const WL_KIND = {
npm: { label: 'npm 预构建', cls: 'active' },
tarball: { label: 'release 资产', cls: 'admin' },
source: { label: '需源码构建', cls: 'disabled' },
}
/** 切 tab:同时写 hash 后缀(#/plugins/<key>)以便深链;replaceState 不触发 hashchange。 */
function showTab(key) {
document.querySelectorAll('#pluginTabs .pg-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === key))
$('pane-official').hidden = key !== 'official'
$('pane-manual').hidden = key !== 'manual'
$('pane-visibility').hidden = key !== 'visibility'
if (key === 'visibility') void loadVisibility()
try { history.replaceState(null, '', '#/plugins/' + key) } catch { /* ignore */ }
}
/**
* 三段式可见性(S6-2 / S6-1)。
*
* 数据来源**分两路**、语义不混:
* · 第 1 段「平台共享只读」← `GET /api/plugins/shared/catalog`(**共享层的实况**:
* 只有真铺到共享层、用户才装得到的才算数;池里有但没发布的⛔ 不在这段);
* · 第 2/3 段「已启用 / 已停用」← `GET /api/plugins/mine`(**admin 自己实例**的
* profile 实际启用集,与用户面同一份判据)。
* ⚠️ `/api/plugins/mine` 的形状**未动**(S6-1 明令不许顺手加过滤);本页只在展示层做三段划分。
*/
async function loadVisibility() {
const wrap = $('visWrap'); if (!wrap) return
wrap.innerHTML = '<div class="empty">加载中…</div>'
let catalog = null, mine = []
try {
const r = await fetch('/api/plugins/shared/catalog')
if (r.ok) { catalog = await r.json() }
else if (r.status === 404) catalog = null // 开关未开
else catalog = null
} catch { catalog = null }
try {
const r = await fetch('/api/plugins/mine')
if (r.ok) { const d = await r.json(); mine = d.plugins || [] }
} catch { /* 留空 */ }
const shared = (catalog && catalog.plugins) || []
const sharedIds = new Set(shared.map((p) => p.id))
const enabled = mine.filter((p) => p.enabled)
const disabled = mine.filter((p) => !p.enabled && sharedIds.has(p.id))
// 「已停用」只列**能装但没开**的:池里有、共享层里没有的插件根本装不了,
// 放进"已停用"会让 admin 以为用户只是没点。那部分单独在末尾点名。
const unavailable = mine.filter((p) => !p.enabled && !sharedIds.has(p.id))
if (catalog === null) {
wrap.innerHTML = `<div class="sec-note" style="border-left:3px solid var(--bp-accent);padding:10px 12px;background:#fffaf2;border-radius:8px">
<b>用户侧「平台共享只读」清单未开启</b>(开关 <code>DSHS_SHARED_CATALOG_PUBLIC</code> 默认关)。<br>
关着时登录用户拿不到这份清单,端侧也就无从"看见"共享层。这是<b>可见面开关</b>,需 admin 显式开启。<br>
<span style="color:var(--bp-dim)">下面两段来自 admin 自己实例的启用集,与开关无关,照常展示。</span></div>` +
renderEnabledDisabled(enabled, disabled, mine.length, shared.length, unavailable)
return
}
wrap.innerHTML = renderEnabledDisabled(enabled, disabled, mine.length, shared.length, unavailable) +
sec('① 平台共享只读', shared.length, '所有用户默认可见 · 只读 · 一份实体全员共享') +
(shared.length === 0
? `<div class="empty">共享层暂无插件 —— 到「手动添加 / 管理」上传后点「发布」</div>`
: tableWrap(['插件名', '版本', '说明'], shared.map((p) => `<tr>
<td><strong>${esc(p.name)}</strong></td>
<td>${esc(p.version) || '—'}</td>
<td class="desc" title="${esc(p.description)}">${esc(p.description) || '—'}</td></tr>`)))
}
/** 「已启用 / 已停用」两段(含"不可用"的单独点名)。 */
function renderEnabledDisabled(enabled, disabled, total, sharedCount, unavailable) {
const nameOf = (p) => `<td><strong>${esc(p.name || p.id)}</strong></td><td>${esc(p.version) || '—'}</td>`
return sec('② 已启用', enabled.length, 'admin 这个实例里实际开着') +
(enabled.length === 0
? `<div class="empty">本实例当前未启用任何功能插件</div>`
: tableWrap(['插件名', '版本'], enabled.map((p) => `<tr>${nameOf(p)}</tr>`))) +
sec('③ 已停用', disabled.length, '共享层里有、用户能装但当前没开') +
(disabled.length === 0
? `<div class="empty">没有停用中的插件</div>`
: tableWrap(['插件名', '版本'], disabled.map((p) => `<tr>${nameOf(p)}</tr>`))) +
(unavailable.length
? subHead('⋯ 暂不可用', unavailable.length, '在池中但未发布到共享层 —— 用户装不到,不是"用户没开"') +
tableWrap(['插件名', '版本'], unavailable.map((p) => `<tr>${nameOf(p)}</tr>`))
: '')
}
/** 三段共用的表格外壳(同一页里三处复用,⛔ 不各写一份)。 */
function tableWrap(heads, rows) {
return `<div class="table-wrap"><table class="tbl">
<thead><tr>${heads.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead>
<tbody>${rows.join('')}</tbody></table></div>`
}
async function load() {
const tbody = $('pluginTbody'); tbody.innerHTML = emptyRow(7, '加载中…')
try {
const { plugins } = await (await fetch('/api/plugins/business')).json()
if ($('poolCnt')) $('poolCnt').textContent = String(plugins.length)
if ($('poolHint')) $('poolHint').textContent = `共 ${plugins.length} 个 · 全部默认禁用`
if (plugins.length === 0) { tbody.innerHTML = emptyRow(7, '暂无已投放插件 —— 从「官方推荐插件」导入,或在上方上传 .tgz'); return }
tbody.innerHTML = plugins.map((p) => {
const dp = p.dataPlane || { state: 'none' }
const badge = DP_BADGE[dp.state] || DP_BADGE.none
const drift = dp.versionDrift
? `<div style="font-size:11px;color:var(--bp-danger);margin-top:2px" title="池内 tgz 的内容与平台记录不一致(多半在平台之外被替换过);判「有没有更新」只认内容指纹">⚠ 已被外部替换(磁盘 ${esc(dp.diskVersion || '?')})</div>`
: ''
return `<tr>
<td><strong>${esc(p.name)}</strong>${drift}</td>
<td>${esc(p.version) || '—'}</td>
<td><span class="badge ${badge.cls}" title="${esc(badge.tip)}">${badge.label}</span>${dp.dbName ? `<div style="font-size:11px;color:var(--bp-dim);margin-top:2px;font-family:ui-monospace,monospace">${esc(dp.dbName)}</div>` : ''}</td>
<td class="desc" title="${esc(p.description)}">${esc(p.description) || '—'}</td>
<td class="num">${fmtSize(p.fileSize)}</td>
<td>${when(p.updatedAt)}</td>
<td class="act">${dpActions(p, dp)}</td></tr>`
}).join('')
} catch { tbody.innerHTML = emptyRow(7, '加载失败') }
}
/**
* 数据面 7 态 → 徽章(`设计件 §三`)。**文案与后端状态机一一对应**,⛔ 不新造第六种。
* `cls` 取自本页既有的 badge 样式(active / admin / disabled)。
*/
const DP_BADGE = {
none: { label: '无数据面', cls: 'disabled', tip: '包内未声明 dsh.data —— 不建库,不受数据面门禁约束' },
pending: { label: '待建库', cls: 'admin', tip: '已有声明、库尚未建立 ⇒ 不能发布、用户不能启用' },
created: { label: '已建库', cls: 'admin', tip: '库已建立但结构未对齐 ⇒ 尚不能启用' },
ready: { label: '✅ 就绪', cls: 'active', tip: '库存在且结构与声明一致 ⇒ 可发布、可启用' },
drift: { label: '待更新', cls: 'admin', tip: '声明与库现状不一致 ⇒ 先看改动清单,确认后执行迁移' },
blocked: { label: '⛔ 声明非法', cls: 'danger', tip: '声明违反「只增不减」(删列 / 改类型 / 重命名 / 无默认值非空列)⇒ 必须改声明重传' },
error: { label: '❌ 失败', cls: 'danger', tip: '上次建库 / 迁移失败,见错误详情' },
}
/**
* 状态驱动按钮(`设计件 §三`)—— **按钮显隐完全由状态决定**,⛔ 不做"都显示、点了再报错"。
* ⚠️ 后端的门禁是**独立强制**的:这里置灰只是提前告知,绕过前端也拦得住。
*/
function dpActions(p, dp) {
const btns = []
if (dp.hasDeclaration) btns.push(`<button class="btn-sm" data-plan="${esc(p.id)}" title="只读预演:把将要执行的 DDL 逐条列出来,不动任何库">${dp.state === 'drift' ? '查看改动' : '预演'}</button>`)
if (dp.state === 'pending' || dp.state === 'error') btns.push(`<button class="btn-sm" data-create="${esc(p.id)}" title="建库:按包内 dsh.data 声明创建 dshs_pl_* 独立库(单语句、不走事务);建完还要「确认执行」迁移表结构">建库</button>`)
if (dp.state === 'pending' || dp.state === 'created') btns.push(`<button class="btn-sm" disabled title="数据面未就绪 ⇒ 不能发布。请先完成建库">发布</button>`)
if (dp.state === 'ready' || dp.state === 'none') btns.push(`<button class="btn-sm" data-share="${esc(p.id)}" title="铺到共享只读包库,用户面才可见">发布</button>`)
btns.push(`<button class="btn-sm" data-update="${esc(p.id)}" title="用新 .tgz 同名替换(替换策略:旧包先删再落新包;替换后需重新检测)">更新</button>`)
if (dp.state === 'blocked') btns.push(`<button class="btn-sm" disabled title="声明违反「只增不减」⇒ 没有可执行的动作,必须改声明重传">确认执行</button>`)
btns.push(`<button class="btn-sm danger" data-del="${esc(p.id)}">删除</button>`)
return btns.join(' ')
}
/**
* 预演:只读拉计划,渲染**逐条 DDL** 与影响(N 表 / M 列 / K 索引)+ 禁令清单。
* 「告知数据库改动」= 用户原话要求的那一步;`planHash` 一并展示,供「确认执行」比对。
*/
async function showPlan(id) {
const panel = $('dataPlanePanel')
panel.innerHTML = `<div class="card" style="margin-top:16px"><div class="empty">正在预演…</div></div>`
try {
const res = await postJson('/api/plugins/business/datastore/plan', { id })
const d = await res.json().catch(() => ({}))
if (!res.ok) {
panel.innerHTML = `<div class="card" style="margin-top:16px">
<div class="card-h">数据面预演<span class="sub">${esc(id)}</span></div>
<div style="border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">无法预演:${esc(d.error || res.status)}</div>
<div style="font-size:13px;color:var(--bp-dim);line-height:1.7">${esc(d.message || '')}</div>
${(d.findings || []).length ? `<ul style="margin:6px 0 0 18px;padding:0;font-size:13px">${d.findings.map((f) => `<li style="margin:3px 0"><code style="font-size:12px">${esc(f.target)}</code> — ${esc(f.detail)}</li>`).join('')}</ul>` : ''}
</div></div>`
return
}
const badge = DP_BADGE[d.state] || DP_BADGE.none
const s = d.plan.summary
const forbidden = d.plan.forbidden || []
const items = d.plan.items || []
const rows = items.map((it) => `<tr>
<td style="white-space:nowrap"><span class="badge active">${esc(it.kind)}</span></td>
<td style="font-family:ui-monospace,monospace;font-size:12px">${esc(it.target)}</td>
<td style="font-family:ui-monospace,monospace;font-size:12px;word-break:break-all">${esc(it.sql)}</td>
<td style="font-size:12px;color:var(--bp-dim)">${esc(it.note || '')}</td></tr>`).join('')
panel.innerHTML = `<div class="card" style="margin-top:16px">
<div class="card-h">数据面预演<span class="sub">${esc(id)} · 库 <code>${esc(d.dbName)}</code> · 声明版本 v${esc(String(d.schemaVersion))}</span></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span class="badge ${badge.cls}">${badge.label}</span>
<span style="font-size:13px;color:var(--bp-dim)">将执行 <b style="color:var(--bp-text)">${items.length}</b> 条改动 | 新建表 <b style="color:var(--bp-text)">${s.tables}</b> | 加列 <b style="color:var(--bp-text)">${s.columns}</b> | 加索引 <b style="color:var(--bp-text)">${s.indexes}</b></span>
<span style="font-size:12px;color:var(--bp-dim);font-family:ui-monospace,monospace" title="确认执行时必须带回这个指纹;服务端会重新预演比对(防「看完又换了包」)">planHash ${esc(String(d.plan.planHash).slice(0, 16))}…</span>
</div>
${forbidden.length ? `<div style="border-left:3px solid var(--bp-danger);border-radius:8px;padding:10px 12px;background:#fff8f6;margin-bottom:10px">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:4px">⛔ 违反「只增不减」,不可执行(${forbidden.length} 项)</div>
<ul style="margin:0 0 0 18px;padding:0;font-size:13px">${forbidden.map((f) => `<li style="margin:3px 0"><code style="font-size:12px">${esc(f.target)}</code> — ${esc(f.kind)}:${esc(f.detail)}</li>`).join('')}</ul>
<div style="font-size:12px;color:var(--bp-dim);margin-top:6px">插件只能「只增」:加表 / 加列(带默认值)/ 加索引。删列、改类型、重命名、加无默认值的非空列一律拒绝,且没有覆盖入口 —— 请修正声明后重传。</div>
</div>` : ''}
${items.length === 0 ? `<div class="empty">库结构已与声明一致 —— 没有要执行的改动。</div>` : `<div class="table-wrap" style="max-height:340px"><table class="tbl">
<thead><tr><th style="width:110px">动作</th><th style="width:180px">对象</th><th>SQL</th><th style="width:180px">说明</th></tr></thead>
<tbody>${rows}</tbody></table></div>`}
<div style="display:flex;gap:10px;align-items:center;margin-top:12px;flex-wrap:wrap">
${d.executable
? `<button class="btn btn-primary" id="dpMigrateBtn" data-id="${esc(id)}" data-expect="${esc(d.plan.planHash)}">确认执行</button>`
: `<button class="btn btn-primary" disabled title="当前状态不允许执行:${esc(d.executabilityNote || '')}">确认执行</button>`}
<span style="font-size:12px;color:var(--bp-dim)">${esc(d.executabilityNote || '')}</span>
<button class="btn" id="dpCloseBtn">关闭</button>
</div>
</div>`
const close = $('dpCloseBtn'); if (close) close.onclick = () => { panel.innerHTML = '' }
const mig = $('dpMigrateBtn')
if (mig) mig.onclick = async () => {
mig.disabled = true; mig.textContent = '执行中…'
try {
const r = await postJson('/api/plugins/business/datastore/migrate', { id: mig.dataset.id, confirm: true, expect: mig.dataset.expect })
const rd = await r.json().catch(() => ({}))
if (r.status === 409 && rd.error === 'plan_stale') { toast('计划已过期(包或库发生了变化),已重新预演'); await showPlan(id); return }
if (!r.ok) { toast('未执行:' + (rd.error || r.status)); mig.disabled = false; mig.textContent = '确认执行'; return }
toast('✓ 已执行'); await showPlan(id); await load()
} catch { toast('执行失败:网络错误'); mig.disabled = false; mig.textContent = '确认执行' }
}
} catch { panel.innerHTML = `<div class="card" style="margin-top:16px"><div class="empty">预演失败(网络错误)</div></div>` }
}
/**
* 建库:调 `POST /api/plugins/business/datastore`(真发 `CREATE DATABASE`,单语句不走事务)。
*
* ⚠️ 与「确认执行」是**两个动作**(设计件 §一 的流程:建库成功 ⇒ 再确认执行迁移):
* 建库只创建空库并落台账,表结构由随后的「确认执行」在**同一事务**里建。
* 建完**自动打开预演面板**,让 admin 直接看到下一步要执行什么(少一次点击)。
*/
async function createDatastore(id) {
const panel = $('dataPlanePanel')
panel.innerHTML = `<div class="card" style="margin-top:16px"><div class="empty">正在建库…</div></div>`
try {
const r = await postJson('/api/plugins/business/datastore', { id })
const d = await r.json().catch(() => ({}))
if (!r.ok) {
panel.innerHTML = `<div class="card" style="margin-top:16px">
<div class="card-h">建库<span class="sub">${esc(id)}</span></div>
<div style="border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">建库失败:${esc(d.error || r.status)}</div>
<div style="font-size:13px;color:var(--bp-dim);line-height:1.7">${esc(d.message || '')}</div>
</div>
<div style="margin-top:10px"><button class="btn" id="dpCloseBtn2">关闭</button></div>
</div>`
const c2 = $('dpCloseBtn2'); if (c2) c2.onclick = () => { panel.innerHTML = '' }
toast('建库失败:' + (d.error || r.status))
return
}
toast(d.outcome === 'exists' ? '✓ 库已存在(幂等)' : '✓ 库已创建')
await load()
await showPlan(id)
} catch {
panel.innerHTML = `<div class="card" style="margin-top:16px"><div class="empty">建库失败(网络错误)</div></div>`
toast('建库失败:网络错误')
}
}
/** 更新 = 行内选新 .tgz,走既有「同名替换」语义(替换后需重新检测)。 */
function pickUpdate(id) {
const input = document.createElement('input')
input.type = 'file'; input.accept = '.tgz'
input.onchange = () => { const f = input.files[0]; if (f) doUpload(f, false).then(() => showPlan(id)) }
input.click()
}
/** 相对时间(目录缓存的上次更新时间展示用)。 */
function relTime(ts) {
if (!ts || typeof ts !== 'number') return '未知'
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000))
if (s < 60) return '刚刚'
if (s < 3600) return Math.floor(s / 60) + ' 分钟前'
if (s < 86400) return Math.floor(s / 3600) + ' 小时前'
return Math.floor(s / 86400) + ' 天前'
}
async function loadWhitelist() {
const tbody = $('wlTbody'); tbody.innerHTML = emptyRow(7, '加载目录中…(首次会从官方站下载一次,之后读本地缓存)')
const q = encodeURIComponent($('wlQ').value.trim())
const cat = encodeURIComponent($('wlCat').value)
const only = $('wlOnlyImportable').checked ? '1' : ''
try {
const r = await fetch(`/api/plugins/whitelist?q=${q}&category=${cat}&onlyImportable=${only}`)
const d = await r.json().catch(() => ({}))
if (!r.ok) { tbody.innerHTML = emptyRow(7, '拉取失败:' + (d.error ?? r.status)); return }
const sel = $('wlCat')
if (sel.dataset.filled !== '1') { for (const c of (d.categories || [])) sel.insertAdjacentHTML('beforeend', `<option value="${esc(c.id)}">${esc(c.label)}(${c.count})</option>`); sel.dataset.filled = '1' }
$('wlInfo').textContent = `清单 ${d.total} 条 | 可导入 ${d.importable} 条 | 匹配 ${d.count} 条 | 显示 ${(d.plugins || []).length} 条 | 目录缓存更新于 ${relTime(d.fetchedAt)}(6 小时内直接复用,不联网)` + (d.stale ? ' | ⚠ 官方站暂不可达,正在使用本地缓存' : '')
if (!d.plugins || d.plugins.length === 0) { tbody.innerHTML = emptyRow(7, '无匹配'); return }
tbody.innerHTML = d.plugins.map((p) => {
const k = WL_KIND[p.importKind] || WL_KIND.source
const dis = p.importKind === 'source'
// 主视觉 = 中文说明(一眼知道这插件干什么);插件名 / npm 包名 / 版本退为副行小字。
const desc = (p.description || '').trim()
const primary = desc !== '' ? desc : p.name
const meta = [
p.name,
p.npm ? p.npm + (p.version ? ' @ ' + p.version : '') : '',
(p.stars || 0) > 0 ? '★ ' + p.stars : '',
].filter(Boolean).join(' · ')
return `<tr${dis ? ' style="opacity:.55"' : ''}>
<td><input type="checkbox" data-wl="${esc(p.id)}" ${dis ? 'disabled' : ''} ${wlSelected.has(p.id) ? 'checked' : ''} /></td>
<td class="wl-cell">
<div class="wl-desc${desc === '' ? ' fallback' : ''}" title="${esc(primary)}">${esc(primary)}</div>
<div class="wl-meta" title="${esc(meta)}">${esc(meta)}</div>
</td>
<td><span class="wl-cat">${esc(p.categoryLabel || p.category) || '—'}</span></td>
<td><span class="badge ${k.cls}">${k.label}</span></td>
<td class="num">${p.downloads || 0}</td>
<td class="act">${p.page ? `<a class="btn-sm wl-link" href="${esc(p.page)}" target="_blank" rel="noreferrer noopener" title="打开官方详情页(完整介绍 / 截图 / 评论)">详情 ↗</a>` : '—'}</td></tr>`
}).join('')
tbody.querySelectorAll('input[data-wl]').forEach((cb) => { cb.onchange = () => { if (cb.checked) wlSelected.add(cb.dataset.wl); else wlSelected.delete(cb.dataset.wl); $('wlSelInfo').textContent = `已选 ${wlSelected.size}` } })
} catch { tbody.innerHTML = emptyRow(7, '拉取失败') }
}
document.querySelectorAll('#pluginTabs .pg-tab').forEach((t) => { t.onclick = () => showTab(t.dataset.tab) })
$('wlSearchBtn').onclick = () => loadWhitelist()
$('wlQ').onkeydown = (e) => { if (e.key === 'Enter') loadWhitelist() }
$('wlCat').onchange = () => loadWhitelist()
$('wlOnlyImportable').onchange = () => loadWhitelist()
$('wlRefreshBtn').onclick = async () => { const b = $('wlRefreshBtn'); b.disabled = true; b.textContent = '正在重新拉取…'; try { await postJson('/api/plugins/whitelist/refresh', {}); wlSelected.clear(); $('wlSelInfo').textContent = '已选 0'; await loadWhitelist(); toast('✓ 目录已重新拉取(缓存已更新)') } catch { toast('重新拉取失败(网络或官方站不可达)') } finally { b.disabled = false; b.textContent = '重新拉取目录' } }
$('wlImportBtn').onclick = async () => {
const names = [...wlSelected]
if (names.length === 0) { toast('请先勾选插件'); return }
if (names.length > 20) { toast('单次最多导入 20 个'); return }
if (!confirm(`将 ${names.length} 个插件投放(收录)到平台?\n\n投放后默认禁用,对用户不生效;用户需在实例「设置 → 功能插件」里自行启用。`)) return
const btn = $('wlImportBtn'); btn.disabled = true; btn.textContent = '导入中…'
$('wlResult').innerHTML = ''
try {
const r = await postJson('/api/plugins/whitelist/import', { names })
const d = await r.json().catch(() => ({}))
const results = d.results || []
const ok = results.filter((x) => x.ok)
const fail = results.filter((x) => !x.ok)
toast(`投放完成:成功 ${ok.length} / ${names.length}` + (fail.length ? `,失败 ${fail.length}` : ''))
if (ok.length > 0 && fail.length === 0) {
$('wlResult').innerHTML = `<div style="border:1px solid var(--bp-border);border-left:3px solid #1a7f37;border-radius:8px;padding:12px 14px;background:#f4fbf7">
<div style="font-size:13px;color:var(--bp-dim);line-height:1.6">✓ 已投放 <b style="color:var(--bp-text)">${ok.length}</b> 个插件到平台(默认禁用,用户未启用前不生效)。切到 <b style="color:var(--bp-text)">「手动添加 / 管理」</b> 可查看或删除。</div></div>`
}
if (fail.length) {
$('wlResult').innerHTML = `<div style="border:1px solid var(--bp-border);border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">失败 ${fail.length} 个</div>
${fail.map((x) => `<div style="font-size:13px;color:var(--bp-dim);margin:4px 0;line-height:1.6"><b style="color:var(--bp-text)">${esc(x.id)}</b>:${esc(x.error)}</div>`).join('')}
</div>`
}
for (const x of results) if (x.ok) wlSelected.delete(x.id)
$('wlSelInfo').textContent = `已选 ${wlSelected.size}`
await loadWhitelist(); await load()
} catch { toast('投放失败:网络错误') } finally { btn.disabled = false; btn.textContent = '导入到平台' }
}
/**
* 上传投放。`trust=false` 走默认路径(命中 P0 即被拒绝并回显详情);
* `trust=true` 表示 admin 已逐条确认并显式声明信任 —— 平台放行并写审计。
*/
async function doUpload(file, trust) {
const input = $('pluginFile'); const btn = $('pluginUploadBtn')
const base64 = await readAsBase64(file)
btn.disabled = true; btn.textContent = trust ? '投放中…' : '检测中…'
$('pluginScanBox').innerHTML = ''
try {
const payload = { file: base64, filename: file.name }
if (trust) payload.trust = { confirmed: true, reason: ($('trustReason') && $('trustReason').value.trim()) || '' }
const res = await postJson('/api/plugins/business', payload)
const data = await res.json().catch(() => ({}))
if (res.status === 409 && (data.error === 'scan_blocked' || data.error === 'compat_incompatible')) { renderScanBlocked(file, data); return }
if (!res.ok) { toast('上传失败:' + (data.error ?? res.status)); return }
toast((data.replaced ? '✓ 已替换' : '✓ 已投放') + '功能插件「' + data.plugin.name + '」' + (data.trustedOverride ? '(已记录信任声明)' : ''))
if (data.versionMismatch) toast('⚠ 版本回读不一致:平台记录与包内版本不同,已记审计(判「有没有更新」只认内容指纹)')
input.value = ''; await load()
// 有数据面 ⇒ 顺手把改动清单摊开(用户原话:「检测通过后告知数据库改动」)。
const dp = data.dataPlane || {}
if (dp.declared) {
if (dp.state === 'blocked' && (dp.findings || []).length) {
$('pluginScanBox').innerHTML = `<div style="border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6;margin-top:12px">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">数据面声明不合法(检测第 3 项未通过,共 ${dp.findings.length} 处)</div>
<ul style="margin:0;padding-left:18px;font-size:13px;line-height:1.7">${dp.findings.map((f) => `<li style="margin:3px 0"><code style="font-size:12px">${esc(f.target)}</code> — ${esc(f.detail)}</li>`).join('')}</ul>
<div style="font-size:12px;color:var(--bp-dim);margin-top:6px;line-height:1.6">插件包已投放,但平台<b>不会</b>为它建库、也<b>不能</b>发布或启用 —— 请修正声明后重新上传。声明写错属作者要改的事,平台<b>没有</b>「信任并放行」入口。</div></div>`
} else {
await showPlan(data.plugin.id)
}
}
} catch { toast('上传失败:网络错误') } finally { btn.disabled = false; btn.textContent = '上传 / 替换' }
}
/**
* 渲染拒绝详情 + 显式信任入口。
* 两类拒绝共用(信息给足,让 admin 真的看到自己信任了什么):
* · `scan_blocked` —— 安全检测命中 P0(档案 66)
* · `compat_incompatible` —— 与平台 dsh 版本不兼容(档案 71)
*/
function renderScanBlocked(file, data) {
const isCompat = data.error === 'compat_incompatible'
const list = isCompat ? ((data.compat && data.compat.findings) || []) : (data.blocked || [])
const warn = (data.warnings || []).length
const rows = list.map((b) => `<li style="margin:3px 0"><code style="font-size:12px">${esc(isCompat ? (b.pkg + (b.file ? ' ← ' + b.file : '')) : b.file)}</code> — ${esc(isCompat ? b.detail : b.why)}</li>`).join('')
const title = isCompat
? `与当前平台 dsh 版本不兼容,已拒绝投放(共 ${list.length} 条依据)`
: `安全检测命中 P0 规则,已拒绝投放(共 ${list.length} 处)`
$('pluginScanBox').innerHTML = `<div style="border:1px solid var(--bp-border);border-left:3px solid var(--bp-danger);border-radius:8px;padding:12px 14px;background:#fff8f6;margin-top:12px">
<div style="font-size:13px;font-weight:600;color:var(--bp-danger);margin-bottom:6px">${title}</div>
<div style="font-size:13px;color:var(--bp-dim);line-height:1.7;margin-bottom:8px">${esc(data.message || '')}</div>
<ul style="margin:0 0 10px 18px;padding:0;font-size:13px;color:var(--bp-text)">${rows}</ul>
${warn ? `<div style="font-size:12px;color:var(--bp-dim);margin-bottom:8px">另有 ${warn} 条 P1 告警(不阻断)</div>` : ''}
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input id="trustReason" placeholder="信任理由(可选,会记入审计)" style="flex:1;min-width:200px;padding:7px 10px;border:1px solid var(--bp-border);border-radius:8px;font-size:13px" />
<button class="btn btn-primary" id="trustConfirmBtn">我已逐条确认,信任并投放</button>
</div>
<div style="font-size:12px;color:var(--bp-dim);margin-top:6px;line-height:1.6">⚠ 只在确认命中确属误报或风险可控时继续。平台会记录「谁 / 何时 / 命中什么 / 理由」。</div>
</div>`
$('trustConfirmBtn').onclick = () => doUpload(file, true)
}
$('pluginUploadBtn').onclick = async () => {
const input = $('pluginFile'); const file = input.files[0]
if (!file) { toast('请先选择 .tgz 文件'); return }
if (!file.name.toLowerCase().endsWith('.tgz')) { toast('仅支持 .tgz 文件'); return }
if (file.size > 150 * 1024 * 1024) { toast('文件过大(>150MB)'); return }
await doUpload(file, false)
}
$('pluginTbody').onclick = async (event) => {
const planBtn = event.target.closest('button[data-plan]')
if (planBtn) { await showPlan(planBtn.dataset.plan); return }
const crBtn = event.target.closest('button[data-create]')
if (crBtn) { await createDatastore(crBtn.dataset.create); return }
const upBtn = event.target.closest('button[data-update]')
if (upBtn) { pickUpdate(upBtn.dataset.update); return }
const shBtn = event.target.closest('button[data-share]')
if (shBtn) {
const id = shBtn.dataset.share
// 发布两段式(既有语义):先请求一次看有没有冲突,再带 confirm 落盘。
const r1 = await postJson('/api/plugins/business/share', { id })
const d1 = await r1.json().catch(() => ({}))
if (!r1.ok) { toast('发布被拒绝:' + (d1.error || r1.status) + (d1.message ? ' —— ' + d1.message : '')); return }
if (d1.conflict && !confirm(`共享层已有该插件(版本 ${d1.existing && d1.existing.version}),内容与本次不同。\n\n确认后将直接替换该包目录,所有已指向它的用户下一次生效即为新版本(无灰度)。`)) return
const r2 = await postJson('/api/plugins/business/share', { id, confirm: true })
const d2 = await r2.json().catch(() => ({}))
if (!r2.ok) { toast('发布失败:' + (d2.error || r2.status)); return }
toast(d2.idempotent ? '✓ 已在共享层(内容一致,未改动)' : '✓ 已发布到共享层'); await load(); return
}
const btn = event.target.closest('button[data-del]'); if (!btn) return
if (!confirm(`删除已投放插件「${btn.dataset.del}」?\n\n将从平台移除;已启用它的用户实例会失去该插件。`)) return
const res = await fetch('/api/plugins/business/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' })
if (res.ok) { toast('✓ 已删除'); await load() } else toast('删除失败')
}
// 初始 tab:读 hash 后缀(#/plugins/manual / #/plugins/visibility),默认「官方推荐插件」
const seg = (location.hash.replace(/^#/, '').split('/').filter(Boolean))[1]
showTab(seg === 'manual' || seg === 'visibility' ? seg : 'official')
await load(); await loadWhitelist()
}
/* ================= 运行环境(#/runtime) ================= */
async function renderRuntime() {
return `${pageHead('运行环境', '实例共享的运行时与工具(服务器装一次,全部用户共享)')}
<div class="card" id="rtWrap"><div class="empty">加载中…</div></div>`
}
async function initRuntime() {
const wrap = document.getElementById('rtWrap')
try {
const r = await (await fetch('/api/admin/runtime')).json()
const mb = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
const cell = 'padding:8px 10px;border-bottom:1px solid var(--color-border-tertiary,#eee);vertical-align:top'
const mon = 'font-family:ui-monospace,SFMono-Regular,Menlo,monospace'
const rows = r.items.map((i) => `<tr>
<td style="${cell}"><b>${esc(i.name)}</b><div style="font-size:12px;color:var(--bp-dim)">${esc(i.group)} · ${esc(i.kind)}</div></td>
<td style="${cell};${mon};font-size:12px">${esc(String(i.version).slice(0, 60))}</td>
<td style="${cell};font-size:12px">${esc(i.source)}</td>
<td style="${cell};${mon};font-size:12px">${esc(i.script)}</td>
<td style="${cell};font-size:12px">${i.removable ? '可卸载' : '平台必备'}</td>
</tr>`).join('')
const drift = r.drift && r.drift.length
? `<div style="margin:10px 0;padding:10px 12px;border-radius:8px;background:#FCEBEB;color:#501313;font-size:13px">
⚠️ 检测到版本漂移(与基线不一致):${r.drift.map(esc).join(';')}<br>
若是有意升级,请跑 <code>${esc(r.baselineScript)}</code> 刷新基线。</div>`
: `<div style="margin:10px 0;padding:10px 12px;border-radius:8px;background:#E1F5EE;color:#04342C;font-size:13px">
✅ 版本与基线一致${r.baseline && r.baseline.acceptedAt ? '(基线时间 ' + esc(String(r.baseline.acceptedAt).slice(0, 19)) + ')' : ''}</div>`
wrap.innerHTML = `
<div style="font-size:13px;color:var(--bp-dim);margin-bottom:12px">${esc(r.note)}</div>
${drift}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">名称</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">版本</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">来源</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">安装/升级脚本</th>
<th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--color-border-secondary,#ddd)">可卸载</th>
</tr></thead><tbody>${rows}</tbody>
</table>
<div style="margin-top:16px;font-size:13px;line-height:1.9">
<b>目录</b>:<code>${esc(r.runtimeDir.path)}</code> 
<b>体积</b>:${mb(r.runtimeDir.bytes)} 
<b>最近变更</b>:${esc(String(r.runtimeDir.installedAt).slice(0, 19))}
</div>
<details style="margin-top:12px;font-size:13px">
<summary style="cursor:pointer">升级 / 卸载 / 迁移怎么做?</summary>
<div style="margin-top:8px;line-height:1.9">
· <b>升级</b>:改脚本里的固定版本号 → 重跑对应安装脚本(幂等 + 官方 sha256 校验)→
再跑 <code>${esc(r.baselineScript)}</code> 刷新版本基线,否则页面会一直提示漂移。<br>
· <b>卸载</b>:删 <code>/usr/local/bin/&lt;名字&gt;</code> 软链即可("平台必备"项请勿删);<br>
· <b>迁移</b>:整个 <code>${esc(r.runtimeDir.path)}</code> 就是一个打包单元 ——
<code>tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime</code>,
目标机解压回原位后重跑两个安装脚本(只重建软链)。<br>
· 实例内 <code>/usr</code> 只读 → 用户<b>无法</b>自行安装或修改,这里的变更对全部用户立即生效。
</div>
</details>
<details style="margin-top:12px;font-size:13px">
<summary style="cursor:pointer">安装清单原文(SHARED-TOOLS.md)</summary>
<pre style="margin-top:8px;white-space:pre-wrap;font-size:12px">${esc(r.manifest)}</pre>
</details>`
} catch (err) {
wrap.innerHTML = '<div class="empty">加载失败:' + esc(err.message) + '</div>'
}
}
/* ================= 路由分发 ================= */
const PAGES = { runtime: [renderRuntime, initRuntime], files: [renderFiles, initFiles], keys: [renderKeys, initKeys], users: [renderUsers, initUsers], skills: [renderSkills, initSkills], plugins: [renderPlugins, initPlugins] }
let isAdmin = false
async function router() {
const name = parseHash(); const view = $('view')
if (!name || !PAGES[name]) {
updateCrumbs('')
view.className = ''
view.innerHTML = renderHome()
view.querySelectorAll('.nav-card').forEach((c) => c.addEventListener('click', () => { location.hash = c.dataset.hash }))
return
}
updateCrumbs(name)
view.className = 'page-' + name
const [render, init] = PAGES[name]
view.innerHTML = await render()
const back = view.querySelector('[data-back]')
if (back) back.addEventListener('click', () => { location.hash = '#/' })
try { await init() } catch (err) { console.error('[portal]', name, err); view.innerHTML = '<div class="empty">渲染失败:' + esc(err.message) + '</div>' }
}
window.addEventListener('hashchange', router)
$('btnHome').addEventListener('click', () => { location.hash = '#/' })
$('logoutBtn').addEventListener('click', async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.href = '/login.html' })
/* ================= init ================= */
;(async function init() {
const user = await me()
if (!user) { location.href = '/login.html'; return }
if (user.role !== 'admin') {
// 普通用户不进门户,直接进入实例会话
try {
const res = await fetch('/api/dsh/enter', { method: 'POST' })
const body = await res.json().catch(() => ({}))
if (res.ok && body.kind === 'session' && body.url) { location.href = body.url; return }
} catch {}
}
isAdmin = user.role === 'admin'
$('who').textContent = user.username + (isAdmin ? '(管理员)' : '')
await router()
})()
</script>
</body>
</html>