diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs
index 2cc7f96..4010060 100644
--- a/scripts/verify-platform-admin-section.mjs
+++ b/scripts/verify-platform-admin-section.mjs
@@ -184,7 +184,8 @@ function clsAll(tree) {
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
// 档案 87 起 = 3 个:模型设置(全角色)+ 功能管理(全角色)+ 系统管理(仅 admin)
-ok("admin 视角下注册了三个 settings.section", regs.length === 3, "-> " + regs.map(r => r.meta.id).join(", "));
+// 档案 81 · R5(2026-09-15)新增「偏好设置」= 语言切换(全角色,order 99)⇒ 共 **4** 个
+ok("admin 视角下注册了四个 settings.section", regs.length === 4, "-> " + regs.map(r => r.meta.id).join(", "));
const pa = regs.find(r => r.meta.id === "platform-admin");
ok("存在 platform-admin 分区", !!pa);
if (pa) ok("其 order = 102", pa.meta.order === 102);
@@ -212,14 +213,35 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
const ids = regs.map(r => r.meta.id).sort();
- ok("非 admin:注册 2 个分区(功能管理 + 模型设置;不含系统管理)",
- ids.length === 2 && ids[0] === "business-plugins" && ids[1] === "model-settings",
+ ok("非 admin:注册 3 个分区(偏好设置 + 功能管理 + 模型设置;不含系统管理)",
+ ids.length === 3 && ids[0] === "business-plugins" && ids[1] === "model-settings" && ids[2] === "preferences",
"-> " + ids.join(", "));
regs = []; ROLE = "admin";
mod.apply(ctx);
await new Promise((r) => setTimeout(r, 80));
}
+// ── 档案 81 · R5(2026-09-15):「偏好设置」= 语言切换(全角色,走官方 locale)──────
+// 口径:默认英语(官方 FALLBACK_LOCALE="en");切换走 ctx.locale.setLocale(id),
+// 语言 id 必须与官方 LOCALE_IDS 一致(en / zh)。本断言同时充当「该栏真能渲染」的冒烟。
+{
+ regs = []; ROLE = "active";
+ mod.apply(ctx);
+ await new Promise((r) => setTimeout(r, 80));
+ const pref = regs.find(r => r.meta.id === "preferences");
+ ok("存在 preferences 分区(语言切换)", !!pref);
+ if (pref) {
+ ok("其 order = 99(排在模型设置之前)", pref.meta.order === 99, "-> " + pref.meta.order);
+ const tPref = text(await render(pref.Comp)).join(" | ");
+ ok("偏好设置:含「界面语言」标签 + 英文/中文两个语言项",
+ tPref.includes("界面语言") && tPref.includes("English") && tPref.includes("中文"),
+ "-> " + tPref.slice(0, 140));
+ }
+ regs = []; ROLE = "admin";
+ mod.apply(ctx);
+ await new Promise((r) => setTimeout(r, 80));
+}
+
// ── 档案 87「模型设置」分区:三条口径必须体现在界面上 ─────────────────────────
{
const ms = regs.find(r => r.meta.id === "model-settings");
diff --git a/scripts/verify-static.mjs b/scripts/verify-static.mjs
index 640a410..3068a2c 100644
--- a/scripts/verify-static.mjs
+++ b/scripts/verify-static.mjs
@@ -16,6 +16,8 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const WEB = join(ROOT, 'web')
const BANNED = 'dshs' // 用户可见面不得出现的平台内部名
const WAKE_IDS = ['spin', 'step', 'acts', 'retry', 'note'] // wake.html 内联 JS 依赖的 id
+// 档案 81 · R5:已完成多语言迁移的页面(**迁移一页加一个**)。这些页不得再出现裸中文文案。
+const I18N_PAGES = ['login.html', 'register.html', 'wake.html', 'index.html']
let bad = 0
const pages = readdirSync(WEB).filter((f) => f.endsWith('.html'))
@@ -24,11 +26,27 @@ console.log('=== 静态页不变量(' + pages.length + ' 页)===')
for (const f of pages) {
const s = readFileSync(join(WEB, f), 'utf8')
const problems = []
- const title = /
([^<]*)<\/title>/.exec(s)
+ const title = /]*>([^<]*)<\/title>/.exec(s)
if (!title || title[1].trim() === '') problems.push('缺 或为空')
else if (title[1].includes(BANNED)) problems.push('title 含内部平台名: ' + title[1])
if (s.includes(BANNED)) problems.push('页面正文含内部平台名(去痕迹约束)')
+ // 档案 81 · R5:**已迁移多语言的页面不得再出现裸中文文案** —— 用户可见文案必须走词条
+ // (`data-i18n` / `I18N.t`)。注释不算文案,故先剥掉 HTML 与 JS 注释再判。
+ // 只对白名单页生效 ⇒ 未迁移页仍允许中文,不会误拦(迁移一页就往 I18N_PAGES 加一个)。
+ if (I18N_PAGES.includes(f)) {
+ const stripped = s
+ .replace(//g, '')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^[ \t]*\/\/.*$/gm, '')
+ .replace(/[ \t]\/\/[^\n]*/g, '') // 行尾注释(`code // 说明`)也要剥(`https://` 不会被误伤:左邻是 `:` 不是空格)
+ const cjk = stripped.split('\n').map((l, i) => [i + 1, l]).filter(([, l]) => /[\u4e00-\u9fa5]/.test(l))
+ if (cjk.length) {
+ problems.push('已迁移多语言但仍有裸中文文案(行 ' + cjk.slice(0, 3).map(([n]) => n).join('/') + '…)')
+ }
+ if (!s.includes('/i18n.js')) problems.push('已迁移多语言但未引入 /i18n.js')
+ }
+
if (f === 'wake.html') {
for (const id of WAKE_IDS) {
if (!new RegExp('id="' + id + '"').test(s)) problems.push('缺 id="' + id + '"(内联 JS 依赖)')
diff --git a/web/i18n.js b/web/i18n.js
new file mode 100644
index 0000000..7ba65a4
--- /dev/null
+++ b/web/i18n.js
@@ -0,0 +1,276 @@
+/*!
+ * 平台多语言运行时(档案 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) ← 放 ,阻塞加载(体积小,避免闪文案)
+ * 2) 文案打标:登录
+ *
+ * (多属性用逗号分隔:attr:key,attr2:key2)
+ * 3) 语言切换控件:页面上放一个容器 ⇒ 运行时自动渲染下拉
+ * 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',
+
+ '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.',
+
+ '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': '重试',
+
+ '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': '注册成功,请等待管理员审核后登录。',
+
+ '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()
+})()
diff --git a/web/index.html b/web/index.html
index f24777e..067c62b 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,9 +1,11 @@
-
+
- 工作台
+ Workspace
+
+
@@ -24,26 +31,31 @@
- 登录进入AI世界
+ Welcome to the AI world
- 还没有账号?注册
+ Don't have an account?
+ Sign up
+
+
+
@@ -24,25 +31,30 @@
- 注册后需管理员审核方可登录
+ Your account must be approved by an admin before you can sign in
- 已有账号?登录
+ Already have an account?
+ Sign in
+
+
+