chore(仓库对齐): 文档库结构治理 + IM/插件线落地
build / build-and-scan (push) Waiting to run

文档库:目录改为编号制(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/;交接单不入库(政策)。
This commit is contained in:
admin committed 2026-09-24 07:25:16 +08:00
1 parent 3d8f50e366
commit e6207aa691
239 files changed
+34477 -14633

No files matched your search

+304 -7
View File
@@ -70,6 +70,8 @@
.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); }
@@ -91,6 +93,18 @@
.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; }
@@ -151,6 +165,22 @@ async function me() { const r = await fetch('/api/auth/me'); return r.ok ? (awai
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: '插件管理' }
@@ -392,6 +422,7 @@ async function renderPlugins() {
<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">
@@ -427,10 +458,22 @@ async function renderPlugins() {
<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 class="num" style="text-align:right">大小</th><th>投放时间</th><th class="act">操作</th></tr></thead>
<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() {
@@ -445,17 +488,239 @@ async function initPlugins() {
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(6, '加载中…')
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(6, '暂无已投放插件 —— 从「官方推荐插件」导入,或在上方上传 .tgz'); return }
tbody.innerHTML = plugins.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><td class="num">${fmtSize(p.fileSize)}</td><td>${when(p.updatedAt)}</td><td class="act"><button class="btn-sm danger" data-del="${esc(p.id)}">删除</button></td></tr>`).join('')
} catch { tbody.innerHTML = emptyRow(6, '加载失败') }
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) {
@@ -556,7 +821,20 @@ async function initPlugins() {
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 = '上传 / 替换' }
}
/**
@@ -594,14 +872,33 @@ async function initPlugins() {
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),默认「官方推荐插件」
// 初始 tab:读 hash 后缀(#/plugins/manual / #/plugins/visibility),默认「官方推荐插件」
const seg = (location.hash.replace(/^#/, '').split('/').filter(Boolean))[1]
showTab(seg === 'manual' ? 'manual' : 'official')
showTab(seg === 'manual' || seg === 'visibility' ? seg : 'official')
await load(); await loadWhitelist()
}
/* ================= 运行环境(#/runtime) ================= */