三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 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 一致)。
189 lines
8.6 KiB
JavaScript
189 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-models-render.cjs —— 「模型设置」分区(`poc/business-plugins/lib/client.js`)
|
||
* 的**真渲染冒烟**:把 `ModelSettingsSection` 抠出来,用桩 React / jsxRuntime 跑一遍,
|
||
* 覆盖「卡片行 / 添加提供方卡片 / 自定义提供方卡片 / 默认双按钮 / 删除确认弹窗」五个分支。
|
||
*
|
||
* 为什么需要(语法检查抓不到的东西):
|
||
* `node --check` 只保证能解析;`verify-models-dict.mjs` 只保证词典齐全与版式锚点。
|
||
* 但组件里还有一类**只在渲染时才炸**的错:未定义变量、jsxRuntime 用法错、
|
||
* 访问了 `undefined.trim()` 之类。这类错在浏览器里表现为「整个设置页白屏」,
|
||
* 而本仓库没有前端测试运行器。本脚本用最小桩把渲染跑通,把这类错拦在构建期。
|
||
*
|
||
* ⚠️ **维护约束**:桩 `useState` 按**调用序号**喂值(见 `STATE_MAP`)。若给组件新增/调换
|
||
* `useState`,请同步 `STATE_MAP`;脚本会先断言调用数量,数量不符时**明确报错**而不是静默测错。
|
||
*
|
||
* 用法:node scripts/verify-models-render.cjs 退出码 0=全绿 / 1=有失败
|
||
*/
|
||
const { readFileSync } = require('node:fs')
|
||
const { join, dirname } = require('node:path')
|
||
|
||
const ROOT = join(__dirname, '..')
|
||
const FILE = join(ROOT, 'poc', 'business-plugins', 'lib', 'client.js')
|
||
const src = readFileSync(FILE, 'utf8')
|
||
|
||
let failed = 0
|
||
const ok = (name, cond, extra = '') => {
|
||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||
if (!cond) failed++
|
||
}
|
||
|
||
/** 从 `function <name>() {` 起按大括号配平截取函数源码(跳过字符串与注释)。 */
|
||
function extractFn(text, name) {
|
||
const at = text.indexOf('function ' + name + '()')
|
||
if (at < 0) return null
|
||
const start = text.indexOf('{', at)
|
||
let depth = 0
|
||
let i = start
|
||
while (i < text.length) {
|
||
const c = text[i]
|
||
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue }
|
||
if (c === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i) + 2; continue }
|
||
if (c === '"' || c === "'" || c === '`') {
|
||
const q = c
|
||
i++
|
||
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++ }
|
||
i++
|
||
continue
|
||
}
|
||
if (c === '{') depth++
|
||
else if (c === '}') { depth--; if (depth === 0) return text.slice(at, i + 1) }
|
||
i++
|
||
}
|
||
return null
|
||
}
|
||
|
||
const fnSrc = extractFn(src, 'ModelSettingsSection')
|
||
ok('取到 ModelSettingsSection 源码', fnSrc !== null)
|
||
if (fnSrc === null) process.exit(1)
|
||
|
||
/** 真词典(顺便验证键真的存在)。 */
|
||
function grabObj(text, name) {
|
||
const at = text.indexOf('var ' + name + ' = {')
|
||
const start = text.indexOf('{', at)
|
||
let depth = 0
|
||
for (let i = start; i < text.length; i++) {
|
||
if (text[i] === '{') depth++
|
||
else if (text[i] === '}') { depth--; if (depth === 0) return text.slice(start, i + 1) }
|
||
}
|
||
return null
|
||
}
|
||
const zh = eval('(' + grabObj(src, 'zh') + ')')
|
||
|
||
/**
|
||
* 桩 `useState` 的**位置→语义**映射。顺序 = 组件里 `var x = useState(...)` 的先后。
|
||
* 键是调用序号,值是「该序号在真实组件里是什么」——仅用于可读性,实际取值看 `over`。
|
||
*/
|
||
const STATE_MAP = [
|
||
'data', 'providers', 'loading', 'busy', 'msg', 'mode',
|
||
'fPick', 'fKey', 'fName', 'fRoute', 'fUrl', 'fApi', 'fModels', 'fShow', 'delTarget', 'catalog',
|
||
]
|
||
|
||
const mockedKeys = []
|
||
const FAKE_DATA = {
|
||
keys: [
|
||
{ id: 1, name: '内置 DeepSeek', route: null, enabled: true, baseUrl: null, api: null },
|
||
{ id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null },
|
||
{ id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' },
|
||
],
|
||
// 档案 138:`granted` = 管理员授权(前端据它决定是否渲染「平台共享模型」区块)
|
||
shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, granted: true, count: 1 },
|
||
sharedModelEnabled: true,
|
||
sharedModelGranted: true,
|
||
protocols: ['openai-completions', 'anthropic-messages'],
|
||
}
|
||
const FAKE_PROVIDERS = [
|
||
{ id: 'moonshot', label: '月之暗面', cn: true, modelCount: 3, models: ['kimi-k2', 'kimi-lite', 'kimi-pro'] },
|
||
{ id: 'openai', label: 'OpenAI', cn: false, modelCount: 2, models: ['gpt-5', 'gpt-5-mini'] },
|
||
]
|
||
|
||
function makeUseState(pass, counter) {
|
||
return function useState(init) {
|
||
const k = counter.i++
|
||
const over = pass.over || {}
|
||
// 未提供的字段回落到 init(而不是 undefined)—— 免得桩的疏漏被当成组件缺陷
|
||
const v = Object.prototype.hasOwnProperty.call(over, k) && over[k] !== undefined ? over[k] : init
|
||
return [v, function () {}]
|
||
}
|
||
}
|
||
|
||
const React = { useState: null, useEffect: function () {} }
|
||
const jsxRuntime = {
|
||
jsx: (type, props) => ({ type, props: props || {} }),
|
||
jsxs: (type, props) => ({ type, props: props || {} }),
|
||
Fragment: 'Fragment',
|
||
}
|
||
const t = (k) => {
|
||
if (!(k in zh)) { mockedKeys.push(k); return '<' + k + '>' }
|
||
return zh[k]
|
||
}
|
||
const noopReq = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
|
||
const MS_ERR = {}
|
||
const portalHost = () => ''
|
||
|
||
function render(pass) {
|
||
const counter = { i: 0 }
|
||
React.useState = makeUseState(pass, counter)
|
||
const factory = new Function(
|
||
'React', 'jsxRuntime', 't', 'paReq', 'papostJson', 'portalHost', 'MS_ERR',
|
||
fnSrc + '\nreturn ModelSettingsSection;',
|
||
)
|
||
const tree = factory(React, jsxRuntime, t, noopReq, noopReq, portalHost, MS_ERR)()
|
||
return { tree, calls: counter.i }
|
||
}
|
||
|
||
function walk(node, out) {
|
||
if (node == null || typeof node !== 'object') return
|
||
if (Array.isArray(node)) { for (const n of node) walk(n, out); return }
|
||
const p = node.props || {}
|
||
if (p.className) out.classes.push(String(p.className))
|
||
if (typeof p.children === 'string') out.text.push(p.children)
|
||
if (Array.isArray(p.children)) {
|
||
for (const c of p.children) { if (typeof c === 'string') out.text.push(c); else walk(c, out) }
|
||
} else if (p.children && typeof p.children === 'object') walk(p.children, out)
|
||
}
|
||
|
||
const BASE = { key: 'sk-abc', msg: '', over: {} }
|
||
const CASES = [
|
||
['默认态(两个虚线按钮)', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
|
||
['ms-rows', 'ms-rowCard', 'ms-rowHead', 'ms-rowIdentity', 'ms-rowActions', 'ms-addActions', 'ms-addBtn']],
|
||
['添加提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'add', 6: 'moonshot', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
|
||
['ms-card', 'ms-field', 'ms-fieldLabel', 'ms-details', 'ms-summary', 'ms-editorActions']],
|
||
['自定义提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'custom', 6: '', 7: BASE.key, 8: '我的网关', 9: 'my-gw', 10: 'https://gw.example/v1', 11: 'openai-completions', 12: ['m1', ''], 13: false, 14: null, 15: null } },
|
||
['ms-modelList', 'ms-modelEntry', 'ms-modelRow', 'ms-iconBtn']],
|
||
['删除确认弹窗', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: FAKE_DATA.keys[1], 15: null } },
|
||
['pa-overlay', 'pa-modal', 'ms-editorActions']],
|
||
]
|
||
|
||
let sawCalls = 0
|
||
for (const [title, pass, expect] of CASES) {
|
||
let tree, calls
|
||
try {
|
||
const r = render(pass)
|
||
tree = r.tree
|
||
calls = r.calls
|
||
} catch (e) {
|
||
console.log(' ✗ ' + title + ' → 抛异常: ' + e.message)
|
||
failed++
|
||
continue
|
||
}
|
||
sawCalls = calls
|
||
const out = { classes: [], text: [] }
|
||
walk(tree, out)
|
||
const joined = out.classes.join(' | ')
|
||
const miss = expect.filter((c) => joined.indexOf(c) < 0)
|
||
const problems = []
|
||
if (miss.length) problems.push('缺 class: ' + miss.join(', '))
|
||
const unrendered = out.text.filter((s) => /^<ms\./.test(s))
|
||
if (unrendered.length) problems.push('未渲染词典键: ' + unrendered.join(', '))
|
||
if (problems.length) { console.log(' ✗ ' + title + ' → ' + problems.join(' ; ')); failed++ }
|
||
else console.log(' ✓ ' + title + ' (class ' + out.classes.length + ' 个, 文本 ' + out.text.length + ' 段)')
|
||
}
|
||
|
||
ok('useState 调用数量与 STATE_MAP 对齐', sawCalls === STATE_MAP.length,
|
||
sawCalls === STATE_MAP.length ? '-> ' + sawCalls : '-> 实测 ' + sawCalls + ' / 映射 ' + STATE_MAP.length + '(组件加了 state?请同步本脚本 STATE_MAP)')
|
||
ok('渲染期未出现缺词典键', mockedKeys.length === 0, mockedKeys.length ? '-> ' + [...new Set(mockedKeys)].join(', ') : '')
|
||
|
||
console.log(failed === 0 ? '\n渲染冒烟全部通过' : '\n' + failed + ' 项失败')
|
||
process.exit(failed === 0 ? 0 : 1)
|