Files
admin c2b7c5ef71 平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效
三条线合并入库(同一次部署批次,源码与生产此前已一致):

一、档案 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 一致)。
2026-09-19 13:51:01 +08:00

336 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
/*!
* 平台多语言运行时(档案 81 · R5 §10.2 / §10.9)
*
* ⚠️ 口径(用户 2026-09-15 定):**这个项目是全球化的 ⇒ 默认语言 = 英语**。
* 语言选择优先级:`?lang=` → cookie `dsh_lang` → `navigator.language`(**仅命中支持列表才用**)→ **`en`**。
* ⚠️ cookie 名**不得含平台内部名**(`scripts/verify-static.mjs` 的 `BANNED` 去痕迹约束会拦)⇒ 用 `dsh_lang`。
* (保留 navigator 一步是全球化产品的常规做法;若要「忽略浏览器、一律英文」,删掉 resolve() 里那一档即可。)
*
* 用法(静态页):
* 1) <script src="/i18n.js"></script> ← 放 <head>,阻塞加载(体积小,避免闪文案)
* 2) 文案打标:<h1 data-i18n="login.title">登录</h1>
* <input data-i18n-attr="placeholder:login.username" />
* (多属性用逗号分隔:attr:key,attr2:key2)
* 3) 语言切换控件:页面上放一个容器 <div data-i18n-switch></div> ⇒ 运行时自动渲染下拉
* 4) 脚本里的动态文案:I18N.t('login.busy')
*
* 纪律(防回归):用户可见文案**必须**走词条;缺 key 时回退英文并 warn —— **不出现空文案、不出现裸 key**。
* ⛔ 本文件只做「渲染期替换」,不碰业务逻辑;不引任何外部依赖。
*/
;(function () {
'use strict'
// ⚠️ 语言 id 与**官方 dsh 保持一致**(`en` / `zh`,见官方 `dsh-client-locale` 的 `LOCALE_IDS`),
// 便于日后把平台语言透传给实例(`settings.yaml` 的 `locale.preference`)而无需再映射。
var SUPPORTED = ['en', 'zh']
var DEFAULT = 'en'
var COOKIE = 'dsh_lang'
// ── 词条表(扁平 key)────────────────────────────────────────────
var DICT = {
en: {
'common.language': 'Language',
'common.busy': 'Working…',
'common.show': 'Show',
'common.hide': 'Hide',
'common.showPw': 'Show password',
'common.hidePw': 'Hide password',
'common.retry': 'Retry',
// 品牌名(2026-09-19):登录 / 注册页的品牌标识由 DeepSeek 图形改为**本平台文字标识**。
// ⚠️ 走词条而不是写死在 HTML 里:英文/其他语言 = CapabilityNet(en 也是**其他语言的回退**,
// `lookup()` 缺 key 时正是回退到 en),中文 = 能力网络(2026-09-19 定名)。
'brand.name': 'CapabilityNet',
'index.title': 'Workspace',
'login.title': 'Sign in',
'login.sub': 'Welcome to the AI world',
'login.username': 'Username',
'login.password': 'Password',
'login.submit': 'Sign in',
'login.noAccount': "Don't have an account?",
'login.register': 'Sign up',
'login.signingIn': 'Signing in…',
'login.entering': 'Entering the workspace…',
'login.errPending': 'Your account is awaiting admin review',
'login.errDisabled': 'Your account has been disabled',
'login.errBadCreds': 'Incorrect username or password',
'login.errStarting': 'The instance is starting, please retry shortly',
'login.errEnter': 'Failed to enter the workspace: ',
'login.errNetwork': 'Failed to enter the workspace: network error',
'login.gotoAdmin': 'go to the admin portal',
'register.title': 'Sign up',
'register.sub': 'Your account must be approved by an admin before you can sign in',
'register.username': 'Username (3–32 letters / digits / underscore / hyphen)',
'register.password': 'Password (at least 8 characters)',
'register.submit': 'Sign up',
'register.hasAccount': 'Already have an account?',
'register.signIn': 'Sign in',
'register.signingUp': 'Signing up…',
'register.errTaken': 'That username is already taken',
'register.errFailed': 'Sign-up failed',
'register.ok': 'Signed up. Please wait for admin approval before signing in.',
// 档案 134:邮箱验证码 + 人机验证
'register.email': 'E-mail',
'register.emailPh': '[email protected]',
'register.code': 'E-mail verification code',
'register.codePh': '6-digit code',
'register.sendCode': 'Send code',
'register.sendingCode': 'Sending…',
'register.resendIn': 'Resend in {n}s',
'register.codeSent': 'Code sent — check your inbox (and the spam folder).',
'register.captcha': 'Human verification',
'register.captchaLoading': 'Loading…',
'register.errEmail': 'Enter a valid e-mail address',
'register.errEmailTaken': 'That e-mail is already registered',
'register.errEmailRequired': 'E-mail and verification code are required',
'register.errCode': 'Invalid or expired code',
'register.errCodeExhausted': 'Too many attempts — please request a new code',
'register.errCooldown': 'Too many requests — please wait {n}s',
'register.errMail': 'Could not send the e-mail — please try again later',
'register.errCaptcha': 'Human verification failed — please retry',
'register.errCaptchaLoad': 'Human verification could not load. Disable ad-blockers or switch network, then reload.',
'register.errCaptchaToken': 'Finish the human verification first',
'register.errUnavailable': 'E-mail verification is unavailable right now',
'register.errUsername': 'Username must be 3–32 letters / digits / _ / -',
'register.errPassword': 'Password must be at least 8 characters',
'register.errBusy': 'Please wait a moment and try again',
'wake.title': 'Starting your workspace',
'wake.h1': 'Starting your workspace…',
'wake.sub': 'Idle instances get reclaimed; the first visit takes a few seconds to spin it back up.',
'wake.connecting': 'Connecting…',
'wake.launching': 'Launching the instance (attempt 1, usually 5–15 seconds)…',
'wake.retrying': 'Retrying (attempt {n})…',
'wake.ready': 'Ready — redirecting…',
'wake.circuit': 'The instance kept crashing, so automatic launching is paused (you can retry in about {min} min). If this keeps happening, ask an admin to check the maintenance log.',
'wake.notReady': 'Not ready yet — retrying in 2 seconds…',
'wake.giveUp': 'Still not ready after several attempts: {msg}',
'wake.unknownErr': 'unknown error',
'wake.failed': 'Startup failed',
'wake.noteShort': 'This page redirects automatically; no need to refresh manually.',
'wake.note': 'If nothing happens for a while, click “Retry”. This page redirects automatically — no manual refresh needed.',
},
zh: {
'common.language': '语言',
'common.busy': '处理中…',
'common.show': '显示',
'common.hide': '隐藏',
'common.showPw': '显示密码',
'common.hidePw': '隐藏密码',
'common.retry': '重试',
// 品牌名(2026-09-19):中文界面显示中文名;其余语言由 en 词条(CapabilityNet)兜底。
// ⚠️ 中文名 2026-09-19 定为「能力网络」(用户口径;此前短暂用过「能力枢纽」/「能力领域」)。
'brand.name': '能力网络',
'index.title': '工作台',
'login.title': '登录',
'login.sub': '欢迎进入AI世界',
'login.username': '用户名',
'login.password': '密码',
'login.submit': '登 录',
'login.noAccount': '还没有账号?',
'login.register': '注册',
'login.signingIn': '登录中…',
'login.entering': '正在进入工作台…',
'login.errPending': '账号待管理员审核',
'login.errDisabled': '账号已被禁用',
'login.errBadCreds': '用户名或密码错误',
'login.errStarting': '实例正在启动,请稍后重试',
'login.errEnter': '进入工作台失败:',
'login.errNetwork': '进入工作台失败:网络错误',
'login.gotoAdmin': '前往管理台',
'register.title': '注册',
'register.sub': '注册后需管理员审核方可登录',
'register.username': '用户名(3–32 位字母/数字/下划线/连字符)',
'register.password': '密码(至少 8 位)',
'register.submit': '注 册',
'register.hasAccount': '已有账号?',
'register.signIn': '登录',
'register.signingUp': '注册中…',
'register.errTaken': '用户名已被占用',
'register.errFailed': '注册失败',
'register.ok': '注册成功,请等待管理员审核后登录。',
// 档案 134:邮箱验证码 + 人机验证
'register.email': '邮箱',
'register.emailPh': '[email protected]',
'register.code': '邮箱验证码',
'register.codePh': '6 位数字验证码',
'register.sendCode': '获取验证码',
'register.sendingCode': '发送中…',
'register.resendIn': '{n} 秒后可重发',
'register.codeSent': '验证码已发送,请查收邮件(含垃圾箱)。',
'register.captcha': '人机验证',
'register.captchaLoading': '加载中…',
'register.errEmail': '请输入有效的邮箱地址',
'register.errEmailTaken': '该邮箱已被注册',
'register.errEmailRequired': '请填写邮箱与验证码',
'register.errCode': '验证码错误或已过期',
'register.errCodeExhausted': '尝试次数过多,请重新获取验证码',
'register.errCooldown': '请求过于频繁,请等 {n} 秒后再试',
'register.errMail': '邮件发送失败,请稍后再试',
'register.errCaptcha': '人机验证未通过,请重试',
'register.errCaptchaLoad': '人机验证加载失败:请关闭广告拦截插件或更换网络后刷新页面。',
'register.errCaptchaToken': '请先完成人机验证',
'register.errUnavailable': '邮箱验证当前不可用',
'register.errUsername': '用户名需为 3–32 位字母 / 数字 / _ / -',
'register.errPassword': '密码至少 8 位',
'register.errBusy': '请稍候再试',
'wake.title': '正在启动工作区',
'wake.h1': '正在启动你的工作区…',
'wake.sub': '空闲一段时间后实例会被回收,首次访问需要几秒重新拉起。',
'wake.connecting': '正在连接…',
'wake.launching': '正在拉起实例(第 1 次,通常 5–15 秒)…',
'wake.retrying': '正在重试(第 {n} 次)…',
'wake.ready': '已就绪,正在跳转…',
'wake.circuit': '实例连续崩溃,平台已暂停自动拉起(约 {min} 分钟后可重试)。若反复出现,请联系管理员查看维护日志。',
'wake.notReady': '未就绪,2 秒后自动重试…',
'wake.giveUp': '多次尝试仍未就绪:{msg}',
'wake.unknownErr': '未知错误',
'wake.failed': '启动失败',
'wake.noteShort': '本页会自动跳转,无需手动刷新。',
'wake.note': '若长时间无反应,可点「重试」。本页会自动跳转,无需手动刷新。',
},
}
function readCookie(name) {
var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
return m ? decodeURIComponent(m[1]) : null
}
function writeCookie(name, value) {
document.cookie =
name + '=' + encodeURIComponent(value) + '; path=/; max-age=31536000; SameSite=Lax'
}
/** 把浏览器语言(如 `zh-CN` / `zh` / `en-US` / `ja`)映射到受支持语言;映射不到返回 null。 */
function matchSupported(tag) {
if (!tag) return null
var lower = String(tag).toLowerCase()
for (var i = 0; i < SUPPORTED.length; i++) {
if (SUPPORTED[i].toLowerCase() === lower) return SUPPORTED[i]
}
var base = lower.split('-')[0]
for (var j = 0; j < SUPPORTED.length; j++) {
if (SUPPORTED[j].toLowerCase().split('-')[0] === base) return SUPPORTED[j]
}
return null
}
/** 解析语言:?lang= → cookie → navigator(仅命中支持列表)→ DEFAULT(en)。 */
function resolveLang() {
var fromQuery = null
try {
fromQuery = matchSupported(new URLSearchParams(location.search).get('lang'))
} catch (_) {
fromQuery = null
}
if (fromQuery) {
writeCookie(COOKIE, fromQuery) // 显式指定 ⇒ 记住
return fromQuery
}
var fromCookie = matchSupported(readCookie(COOKIE))
if (fromCookie) return fromCookie
var nav = matchSupported(navigator.language) || matchSupported((navigator.languages || [])[0])
if (nav) return nav
return DEFAULT
}
var lang = resolveLang()
/** 取词条(缺 key:回退英文并 warn,**绝不**返回空串或裸 key)。 */
function lookup(key) {
var table = DICT[lang] || DICT[DEFAULT]
if (Object.prototype.hasOwnProperty.call(table, key)) return table[key]
if (Object.prototype.hasOwnProperty.call(DICT[DEFAULT], key)) {
console.warn('[i18n] missing key for "' + lang + '": ' + key + ' (fell back to en)')
return DICT[DEFAULT][key]
}
console.warn('[i18n] unknown key: ' + key)
return key
}
/**
* 翻译:`t('key')`;带占位符时 `t('key', { n: 3, min: 5 })`(词条里写 `{n}` / `{min}`)。
* ⚠️ 占位符缺参时**原样保留** `{n}`(便于一眼看出漏传,而不是静默变空)。
*/
function t(key, params) {
var s = lookup(key)
if (params) {
s = String(s).replace(/\{(\w+)\}/g, function (m, k) {
return Object.prototype.hasOwnProperty.call(params, k) ? String(params[k]) : m
})
}
return s
}
/** 就地替换:data-i18n(文本)/ data-i18n-attr="attr:key,attr2:key2"(属性)。 */
function apply(root) {
var scope = root || document
var nodes = scope.querySelectorAll('[data-i18n]')
for (var i = 0; i < nodes.length; i++) nodes[i].textContent = t(nodes[i].getAttribute('data-i18n'))
var attrNodes = scope.querySelectorAll('[data-i18n-attr]')
for (var j = 0; j < attrNodes.length; j++) {
var pairs = attrNodes[j].getAttribute('data-i18n-attr').split(',')
for (var k = 0; k < pairs.length; k++) {
var bits = pairs[k].split(':')
if (bits.length === 2) attrNodes[j].setAttribute(bits[0].trim(), t(bits[1].trim()))
}
}
document.documentElement.lang = lang
}
/** 切换语言:写 cookie → 重新加载(静态页无状态,重载最稳)。 */
function setLang(next) {
if (SUPPORTED.indexOf(next) === -1) return
writeCookie(COOKIE, next)
location.reload()
}
/** 渲染语言下拉(容器标 data-i18n-switch)。 */
function mountSwitchers() {
var hosts = document.querySelectorAll('[data-i18n-switch]')
for (var i = 0; i < hosts.length; i++) {
var host = hosts[i]
host.innerHTML = ''
var label = host.getAttribute('data-i18n-switch') || ''
if (label === 'label') {
var span = document.createElement('span')
span.className = 'i18n-lang-label'
span.textContent = t('common.language')
host.appendChild(span)
}
var sel = document.createElement('select')
sel.className = 'i18n-lang'
sel.setAttribute('aria-label', t('common.language'))
var names = { en: 'English', zh: '中文' }
for (var s = 0; s < SUPPORTED.length; s++) {
var opt = document.createElement('option')
opt.value = SUPPORTED[s]
opt.textContent = names[SUPPORTED[s]] || SUPPORTED[s]
if (SUPPORTED[s] === lang) opt.selected = true
sel.appendChild(opt)
}
sel.addEventListener('change', function (event) {
setLang(event.target.value)
})
host.appendChild(sel)
}
}
window.I18N = { t: t, lang: lang, supported: SUPPORTED, set: setLang, apply: apply }
function boot() {
mountSwitchers()
apply(document)
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot)
else boot()
})()