三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 138 · 平台共享模型:管理员逐用户授权(默认关闭)
用户口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
用户才能在会话中使用(以及在设置的模型设置页面展示)」。
· DB 迁移 v11:新增 users.shared_model_granted(DEFAULT 0 = 默认关闭)。
⚠️ 刻意**不**复用 v6 的 shared_model_enabled —— 那是用户侧偏好(用户能自己关,默认 1),
而本需求要的是**管理员门禁**;共用一列则用户点一下就给自己授权,门禁形同不存在。
生效 = granted ∧ enabled(server.ts#sharedLandingRows)。
· 新路由 POST /api/admin/users/:id/models/shared(requireAdmin)+ 审计 shared_model_grant
+ **尽力而为**重启该用户实例(租约被占/实例未运行都不算失败)。
· admin 用户列表新增「共享模型」列;用户侧 /api/me/keys 增 shared.granted / sharedModelGranted;
插件 0.3.24:未授权时「平台共享模型」整块不渲染。
二、顺带修掉一个既有真缺陷:平台侧写用户 home 必须走 UserFs(用户卷在 worker 上)
landModels 原先 join(owner.home_dir, …) + 本机 fs ⇒ 对「实例不在控制面本机」的用户
读到空串(**不报错**)⇒ 模型落地一直是**静默空操作**(托管清单还被清空)
——即档案 87 的模型设置页对 guest 这类用户**从未生效**。
· UserFs 新增 readHomeFile/writeHomeFile + 文件名白名单(settings.yaml / .credentials.yaml)
· worker agent 新增 /fs/home-read /fs/home-write(只认白名单裸文件名)
· landModels 改走 userFs(与"文件面"同一份按归属路由 ⇒ 落地与实例必然同机)
· 同轮把 /api/me/locale(档案 102 语言偏好)也改成同一套(原先同样失效)
· home-files.ts 抽出 backupHomeFile(备份留平台侧,命名规则逐字不变)
三、档案 139 · 品牌中文名:能力枢纽 → 能力网络(其他语言仍 CapabilityNet)
落点四处:i18n 中文词条 / admin.html 顶栏 / favicon.svg 的 title+aria-label / design.css 注释;
test/i18n-brand.test.mjs 期望值同步。档案 137 顶部加"后续"指针,不改历史。
验证(全部真机实测):
· 红腿:未授权 → 106 上 guest 的 .credentials.yaml refs 变空(共享 key 被撤)
· 绿腿:授权 → key 回来 + 托管清单恢复 ["DEEPSEEK_API_KEY"]
· admin 列表带出 sharedModelGranted;用户侧 granted 随授权翻面(true/shared ↔ false/none)
· 开关两次均 200(不再假失败);插件实装 0.3.24 且含 gating 字符串
· 语言偏好:106 上 settings.yaml 出现 locale.preference=en(属主=实例属主,既有段逐字保留)
· 本机 npm test 226 tests / 225 pass / 0 fail / 1 skipped;四个 verify 脚本全绿
部署:47 推 51 个 lib 产物、106 推 12 个(lib/ 是 gitignore ⇒ 回滚点物化在
/opt/dsh/backups/seq138b-20260919-125345/,逐文件对账 0 不一致;先 106 后 47);
插件 business-plugins 0.3.24(两机 artifacts 与本机 pack md5 一致)。
166 lines
9.1 KiB
HTML
166 lines
9.1 KiB
HTML
<!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.svg" />
|
||
<link rel="stylesheet" href="/design.css" />
|
||
</head>
|
||
<body class="auth-bg">
|
||
<script>
|
||
/* 会话失效统一跳登录页:避免页面直接吐 {"error":"unauthorized"} 却停在原地。
|
||
登录页/注册页自身不装,否则密码错误时会被弹走。 */
|
||
;(function () {
|
||
var p = location.pathname
|
||
if (p === '/' || p === '/index.html' || p === '/login.html' || p === '/register.html') return
|
||
var orig = window.fetch
|
||
window.fetch = function () {
|
||
return orig.apply(this, arguments).then(function (res) {
|
||
if (res && res.status === 401) location.href = '/login.html'
|
||
return res
|
||
})
|
||
}
|
||
})()
|
||
</script>
|
||
|
||
<div class="auth-card" style="max-width: 720px">
|
||
<div class="auth-brand">
|
||
<span class="wordmark">能力网络</span>
|
||
</div>
|
||
<p class="auth-sub" id="sub">仅限管理员</p>
|
||
|
||
<div id="login" class="hidden">
|
||
<div class="field"><label>用户名</label><input id="username" autocomplete="username" /></div>
|
||
<div class="field"><label>密码</label><input id="password" type="password" autocomplete="current-password" /></div>
|
||
<button class="btn primary" id="loginBtn">登录</button>
|
||
<p class="msg err" id="loginMsg"></p>
|
||
</div>
|
||
|
||
<div id="console" class="hidden">
|
||
<div class="row-actions" style="justify-content:space-between">
|
||
<span>登录为 <strong id="who"></strong> <a href="/portal.html#/skills" style="margin-left:10px">技能管理</a></span>
|
||
<button class="btn ghost small" id="logoutBtn">退出</button>
|
||
</div>
|
||
<table class="admin">
|
||
<thead><tr><th>用户名</th><th>角色</th><th>共享模型</th><th>注册时间</th><th>操作</th></tr></thead>
|
||
<tbody id="users"></tbody>
|
||
</table>
|
||
|
||
<p class="auth-sub" style="margin:6px 0 0">「共享模型」= 你在「密钥管理」里配的平台共享模型,<strong>逐个用户开启后</strong>该用户才用得上(也才会在实例的「设置 → 模型设置」里出现);默认关闭。开启即时生效(会重启该用户实例)。</p>
|
||
|
||
<h3 style="margin:22px 0 8px">存储用量 <span class="auth-sub" id="storageAt" style="font-weight:400"></span></h3>
|
||
<table class="admin">
|
||
<thead><tr><th>用户</th><th>工作区</th><th>会话</th><th>回收站</th><th>可清理</th></tr></thead>
|
||
<tbody id="storage"></tbody>
|
||
</table>
|
||
<p class="auth-sub" style="margin:6px 0 0">工作区达 <span id="thWs"></span> / 会话达 <span id="thSess"></span> 时由每日维护任务自动清理(工作区 90 天、会话 365 天前的记录;均先入回收站保留 30 天)。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const badge = { admin: '管理员', pending: '待审核', active: '正常', disabled: '已禁用' }
|
||
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) }
|
||
async function me() {
|
||
const res = await fetch('/api/auth/me')
|
||
return res.ok ? (await res.json()).user : null
|
||
}
|
||
async function loadUsers() {
|
||
const { users } = await (await fetch('/api/admin/users')).json()
|
||
const tbody = document.getElementById('users')
|
||
tbody.innerHTML = ''
|
||
for (const u of users) {
|
||
const actions = u.role === 'pending'
|
||
? `<button class="btn small ghost" data-act="approve" data-id="${u.id}">通过</button>`
|
||
: u.role === 'active' ? `<button class="btn small danger" data-act="disable" data-id="${u.id}">禁用</button>`
|
||
: u.role === 'disabled' ? `<button class="btn small ghost" data-act="enable" data-id="${u.id}">恢复</button>` : ''
|
||
const del = u.role !== 'admin'
|
||
? `<button class="btn small ghost" style="color:var(--danger)" data-act="del" data-id="${u.id}" data-name="${esc(u.username)}" title="删除用户">🗑</button>`
|
||
: ''
|
||
// 共享模型:admin 本人不需要(他自己配的就是"共享"的源头)⇒ 显示「—」;
|
||
// 待审核的人还没实例,开了也没意义 ⇒ 也显示「—」。其余用户可以开/关。
|
||
const grant = u.role === 'admin' || u.role === 'pending'
|
||
? '<span class="badge">—</span>'
|
||
: u.sharedModelGranted
|
||
? `<button class="btn small danger" data-act="grant-off" data-id="${u.id}" title="关闭该用户的平台共享模型">已开启</button>`
|
||
: `<button class="btn small ghost" data-act="grant-on" data-id="${u.id}" title="允许该用户使用平台共享模型">已关闭</button>`
|
||
tbody.insertAdjacentHTML('beforeend', `<tr><td>${esc(u.username)}</td><td><span class="badge ${u.role}">${badge[u.role] || u.role}</span></td><td>${grant}</td><td>${new Date(u.createdAt).toLocaleString()}</td><td>${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) { alert('用户名不匹配,已取消'); return }
|
||
const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' })
|
||
if (res.ok) await loadUsers()
|
||
else alert('删除失败(admin 账号不可删除)')
|
||
return
|
||
}
|
||
if (act === 'grant-on' || act === 'grant-off') {
|
||
const enabled = act === 'grant-on'
|
||
const res = await fetch(`/api/admin/users/${id}/models/shared`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ enabled }),
|
||
})
|
||
if (res.ok) await loadUsers()
|
||
else alert('操作失败')
|
||
return
|
||
}
|
||
const res = await fetch(`/api/admin/users/${id}/${act}`, { method: 'POST' })
|
||
if (res.ok) await loadUsers()
|
||
else alert('操作失败')
|
||
}
|
||
}
|
||
async function init() {
|
||
const u = await me()
|
||
if (u && u.role === 'admin') {
|
||
document.getElementById('login').classList.add('hidden')
|
||
document.getElementById('console').classList.remove('hidden')
|
||
document.getElementById('who').textContent = u.username
|
||
await loadUsers()
|
||
await loadStorage()
|
||
} else if (u) {
|
||
document.getElementById('sub').textContent = '该账号不是管理员'
|
||
} else {
|
||
document.getElementById('login').classList.remove('hidden')
|
||
}
|
||
}
|
||
document.getElementById('loginBtn').addEventListener('click', async () => {
|
||
const res = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ username: document.getElementById('username').value, password: document.getElementById('password').value }),
|
||
})
|
||
if (!res.ok) { document.getElementById('loginMsg').textContent = '用户名或密码错误'; return }
|
||
const { user } = await res.json()
|
||
if (user.role !== 'admin') { document.getElementById('loginMsg').textContent = '该账号不是管理员'; return }
|
||
await init()
|
||
})
|
||
function fmtB(b) { return b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : (b / 1048576).toFixed(1) + ' MB' }
|
||
async function loadStorage() {
|
||
const tbody = document.getElementById('storage')
|
||
try {
|
||
const r = await (await fetch('/api/admin/storage')).json()
|
||
document.getElementById('storageAt').textContent = r.generatedAt ? `(更新于 ${new Date(r.generatedAt).toLocaleString()})` : ''
|
||
if (r.thresholds) {
|
||
document.getElementById('thWs').textContent = r.thresholds.wsMB + ' MB'
|
||
document.getElementById('thSess').textContent = r.thresholds.sessionsMB + ' MB'
|
||
}
|
||
tbody.innerHTML = (r.users ?? []).map((u) => `<tr>
|
||
<td>${esc(u.username)}</td>
|
||
<td style="${u.wsOver ? 'color:var(--danger)' : ''}">${fmtB(u.ws)}</td>
|
||
<td style="${u.sessionsOver ? 'color:var(--danger)' : ''}">${fmtB(u.sessions)}</td>
|
||
<td>${fmtB(u.trash)}</td>
|
||
<td>${u.cleanableT1 + u.cleanableT2} 项 / ${fmtB(u.cleanableBytes)}</td>
|
||
</tr>`).join('') || `<tr><td colspan="5" class="auth-sub">${esc(r.note ?? '暂无数据')}</td></tr>`
|
||
} catch { tbody.innerHTML = '<tr><td colspan="5" class="auth-sub">读取失败</td></tr>' }
|
||
}
|
||
document.getElementById('logoutBtn').addEventListener('click', async () => { await fetch('/api/auth/logout', { method: 'POST' }); location.reload() })
|
||
init()
|
||
</script>
|
||
</body>
|
||
</html>
|