feat(web): 平台多语言运行时(i18n,档案 81 · R5)

⚠️ 本提交是**另一执行会话**已完成、且**已上线**的源码侧改动,由本会话代为入库
   (线上 https://alotbuy.com/login.html 已引用 /i18n.js)。

- web/i18n.js(新增 276 行):词条表 + data-i18n / data-i18n-attr 就地替换。
  口径(用户定):**这个项目是全球化的 ⇒ 默认语言 = 英语**;
  优先级 ?lang= → cookie `dsh_lang` → navigator.language(**仅命中支持列表才用**)→ `en`;
  ⚠️ cookie 名不得含平台内部名(verify-static 的 BANNED 去痕迹约束会拦)。
- web/{index,login,register,wake}.html:接入 <script src="/i18n.js"> + 文案打标
  + 页脚语言下拉(中性样式、不新增页面)。
- scripts/verify-static.mjs:新增「**已迁移多语言的页面不得再出现裸中文文案**」
  —— I18N_PAGES 白名单(迁一页加一个,未迁页仍允许中文,不会误拦);
  判定前先剥掉 HTML/JS 注释(注释不算文案)。
- scripts/verify-platform-admin-section.mjs:随「偏好设置 = 语言切换(全角色)」新增分区数断言
  (admin 4 个分区 / 非 admin 3 个;切换走官方 ctx.locale.setLocale,语言 id 与官方 LOCALE_IDS 一致)。

验证:node scripts/verify-static.mjs ✓ | node scripts/verify-platform-admin-section.mjs ✓(本会话复核已跑通)
This commit is contained in:
admin committed 2026-09-15 18:54:02 +08:00
1 parent 39a1f2e31b
commit b617cdbaa7
7 files changed
+407 -56

No files matched your search

+25 -3
View File
@@ -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");
+19 -1
View File
@@ -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>([^<]*)<\/title>/.exec(s)
const title = /<title[^>]*>([^<]*)<\/title>/.exec(s)
if (!title || title[1].trim() === '') problems.push('缺 <title> 或为空')
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(/<!--[\s\S]*?-->/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 依赖)')
+276
View File
@@ -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) <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',
'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()
})()
+4 -2
View File
@@ -1,9 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>工作台</title>
<title data-i18n="index.title">Workspace</title>
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
<script src="/i18n.js"></script>
</head>
<body>
<script>
+40 -21
View File
@@ -1,12 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录</title>
<title data-i18n="login.title">Sign in</title>
<link rel="icon" href="/logo.svg" />
<link rel="stylesheet" href="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(词条表 + data-i18n 就地替换;默认英语) -->
<script src="/i18n.js"></script>
<style>
/* 档案 81 · R2-UI:提交忙碌态(点击后立刻可见;对齐 06-工作台UI规范 的按钮/圆角/主色) */
.btn[disabled] { opacity: .72; cursor: not-allowed; }
@@ -15,6 +17,11 @@
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
@keyframes btnsp { to { transform: rotate(360deg); } }
.msg { min-height: 20px; }
/* 档案 81 · R5:语言切换(页脚式下拉,不新增页面;中性样式,不引外链) */
.auth-lang { margin-top: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.auth-lang .i18n-lang-label { font-size: 13px; opacity: .72; }
.auth-lang .i18n-lang { font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; }
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
</style>
</head>
@@ -24,26 +31,31 @@
<img class="logo" src="/logo.svg" alt="logo" />
<span class="wordmark"><svg viewBox="0 0 131 24" fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M117.986 0h-3.21v19.404h3.21V0zM8.7 5.215h1.83v2.838H8.7c-1.135 0-2.28.282-3.019 1.068-.738.785-1.016 1.99-1.016 3.194s.267 2.408 1.016 3.193c.75.786 1.884 1.068 3.018 1.068 1.135 0 2.28-.282 3.018-1.068.739-.785 1.017-1.99 1.017-3.193V.649h3.21v18.755h-3.21V18.21h-.589a3.498 3.498 0 01-.192.199c-.803.733-2.034.995-3.243.995-1.894 0-3.788-.472-5.03-1.78C2.44 16.314 2 14.303 2 12.303c0-2 .45-4 1.68-5.32 1.242-1.308 3.136-1.77 5.02-1.77zM57.564 18.9h-1.83v-2.837h1.83c1.134 0 2.28-.283 3.017-1.068.739-.785 1.017-1.99 1.017-3.194s-.267-2.408-1.017-3.194c-.749-.785-1.883-1.068-3.017-1.068s-2.28.283-3.018 1.068c-.738.786-1.017 1.99-1.017 3.194v11.655h-3.21V4.712h3.21v1.194h.59a3.42 3.42 0 01.186-.194l.005-.005c.803-.733 2.034-.995 3.243-.995 1.895 0 3.788.471 5.03 1.78 1.241 1.31 1.68 3.32 1.68 5.32 0 2-.45 4-1.68 5.319-1.23 1.32-3.135 1.77-5.019 1.77zM32.05 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.264-1.843-5.223-1.843-1.958 0-3.928.492-5.222 1.843-1.295 1.35-1.755 3.434-1.755 5.497 0 2.063.47 4.136 1.755 5.498 1.284 1.36 3.264 1.843 5.222 1.843 1.959 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168a5.026 5.026 0 01-.3.367c-.77.816-1.958 1.11-3.135 1.11-1.177 0-2.365-.304-3.136-1.11-.77-.807-1.048-2.063-1.048-3.299 0-1.236.278-2.482 1.049-3.298.77-.817 1.958-1.11 3.135-1.11 1.177 0 2.365.293 3.136 1.11.535.565.834 1.34.963 2.167H23.5v2.272h8.55zM48.168 12.063v1.141h-8.55v-2.272h5.671c-.129-.827-.428-1.602-.963-2.167-.77-.817-1.959-1.11-3.136-1.11s-2.365.293-3.136 1.11c-.77.816-1.049 2.063-1.049 3.298 0 1.236.279 2.492 1.05 3.299.77.806 1.958 1.11 3.135 1.11 1.177 0 2.365-.294 3.136-1.11.107-.116.203-.241.299-.367h3.168c-.278.985-.717 1.874-1.38 2.566-1.285 1.35-3.264 1.843-5.223 1.843s-3.938-.482-5.222-1.843c-1.285-1.362-1.756-3.435-1.756-5.498s.46-4.147 1.755-5.497c1.296-1.351 3.264-1.843 5.223-1.843s3.938.481 5.222 1.843c1.296 1.36 1.756 3.434 1.756 5.497zM78.635 18.315c-1.284.806-3.263 1.089-5.222 1.089-1.958 0-3.917-.294-5.212-1.09-1.295-.795-1.755-2.03-1.755-3.246h3.767c0 .472.225.953.824 1.257.6.304 1.54.419 2.462.419.92 0 1.851-.115 2.46-.42.611-.303.825-.784.825-1.256 0-.47-.214-.952-.824-1.256-.61-.304-1.627-.419-2.547-.419-1.777 0-3.563-.293-4.73-1.09-1.167-.795-1.584-2.03-1.584-3.245 0-1.215.417-2.44 1.584-3.246 1.167-.807 2.953-1.09 4.73-1.09 1.776 0 3.564.294 4.73 1.09 1.167.795 1.584 2.031 1.584 3.246h-3.264c0-.471-.203-.942-.749-1.257-.546-.303-1.391-.419-2.226-.419s-1.68.105-2.226.42c-.556.303-.75.785-.75 1.256 0 .47.204.942.75 1.256.545.304 1.316.42 2.151.42 1.959 0 3.938.292 5.222 1.088 1.295.796 1.756 2.032 1.756 3.246 0 1.215-.471 2.44-1.756 3.247zM96.507 12.063v1.141h-8.55v-2.272h5.672c-.129-.827-.429-1.602-.963-2.167-.771-.817-1.959-1.11-3.136-1.11s-2.366.293-3.136 1.11c-.77.816-1.048 2.063-1.048 3.298 0 1.236.278 2.492 1.048 3.299.77.806 1.959 1.11 3.135 1.11 1.178 0 2.366-.294 3.137-1.11.106-.116.203-.241.3-.367h3.167c-.279.985-.717 1.874-1.38 2.566-1.284 1.35-3.265 1.843-5.224 1.843-1.957 0-3.938-.482-5.222-1.843-1.284-1.362-1.754-3.435-1.754-5.498s.46-4.147 1.754-5.497c1.296-1.351 3.265-1.843 5.222-1.843 1.96 0 3.94.481 5.224 1.843 1.294 1.36 1.754 3.434 1.754 5.497zM112.624 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.265-1.843-5.223-1.843-1.959 0-3.928.492-5.222 1.843-1.296 1.35-1.756 3.434-1.756 5.497 0 2.063.471 4.136 1.756 5.498 1.284 1.36 3.263 1.843 5.222 1.843 1.958 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168l-.012.016c-.093.12-.185.24-.288.35-.77.817-1.957 1.11-3.135 1.11-1.177 0-2.365-.303-3.136-1.11-.77-.806-1.049-2.062-1.049-3.298 0-1.236.279-2.482 1.049-3.298.771-.817 1.959-1.11 3.136-1.11 1.178 0 2.365.293 3.135 1.11.536.565.836 1.34.964 2.167h-5.672v2.272h8.55zM128.73 19.404l-5.264-7.78 5.264-6.252h-3.97l-5.265 6.251 5.265 7.78h3.97z"/></svg></span>
</div>
<p class="auth-sub">登录进入AI世界</p>
<p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p>
<form id="login">
<div class="field">
<label for="username">用户名</label>
<label for="username" data-i18n="login.username">Username</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="field">
<label for="password">密码</label>
<label for="password" data-i18n="login.password">Password</label>
<div class="pw-wrap">
<input id="password" name="password" type="password" autocomplete="current-password" required />
<button class="pw-toggle" type="button" id="pwToggle" aria-label="显示密码" aria-pressed="false">显示</button>
<button class="pw-toggle" type="button" id="pwToggle" data-i18n="common.show"
data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
</div>
</div>
<button class="btn primary" type="submit">登 录</button>
<button class="btn primary" type="submit" data-i18n="login.submit">Sign in</button>
<p class="msg" id="msg"></p>
</form>
<p class="auth-alt">还没有账号?<a href="/register.html">注册</a></p>
<p class="auth-alt"><span data-i18n="login.noAccount">Don't have an account?</span>
<a href="/register.html" data-i18n="login.register">Sign up</a></p>
<!-- 档案 81 · R5:语言切换(默认英语;切换写 cookie dsh_lang 后重载) -->
<div class="auth-lang"><div data-i18n-switch="label"></div></div>
</div>
<script>
@@ -55,8 +67,8 @@
pwBtn.addEventListener('click', () => {
const show = pwInput.type === 'password'
pwInput.type = show ? 'text' : 'password'
pwBtn.textContent = show ? '隐藏' : '显示'
pwBtn.setAttribute('aria-label', show ? '隐藏密码' : '显示密码')
pwBtn.textContent = I18N.t(show ? 'common.hide' : 'common.show')
pwBtn.setAttribute('aria-label', I18N.t(show ? 'common.hidePw' : 'common.showPw'))
pwBtn.setAttribute('aria-pressed', String(show))
pwInput.focus()
})
@@ -72,7 +84,7 @@
if (!btn.dataset.idle) btn.dataset.idle = btn.innerHTML
btn.disabled = true
btn.classList.add('is-busy')
btn.innerHTML = '<span class="sp"></span><span>' + (label || '处理中…') + '</span>'
btn.innerHTML = '<span class="sp"></span><span>' + (label || I18N.t('common.busy')) + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
@@ -83,7 +95,7 @@
event.preventDefault()
msg.textContent = ''
msg.className = 'msg err'
setBusy(true, '登录中…')
setBusy(true, I18N.t('login.signingIn'))
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -92,16 +104,16 @@
if (!res.ok) {
const body = await res.json().catch(() => ({}))
msg.textContent =
body.error === 'pending_review' ? '账号待管理员审核'
: body.error === 'disabled' ? '账号已被禁用'
: '用户名或密码错误'
body.error === 'pending_review' ? I18N.t('login.errPending')
: body.error === 'disabled' ? I18N.t('login.errDisabled')
: I18N.t('login.errBadCreds')
setBusy(false)
return
}
const body = await res.json()
const role = body && body.user ? body.user.role : null
// 所有用户(admin/active)登录后统一直达 DSH 会话窗口
setBusy(true, '正在进入工作台…')
setBusy(true, I18N.t('login.entering'))
try {
const r = await fetch('/api/dsh/enter', { method: 'POST' })
const b = await r.json().catch(() => ({}))
@@ -111,14 +123,21 @@
}
// 失败兜底:admin 可手动进管理台 /portal.html
const errText =
b.error === 'instance_starting' ? '实例正在启动,请稍后重试'
b.error === 'instance_starting' ? I18N.t('login.errStarting')
: (b.error ?? 'HTTP ' + r.status)
msg.innerHTML =
'进入工作台失败:' + errText +
(role === 'admin' ? '(<a href="/portal.html">前往管理台</a>)' : '')
// 档案 81 · R5:不再用 innerHTML 拼文案(安全);管理台入口用 DOM 组装
msg.textContent = I18N.t('login.errEnter') + errText
if (role === 'admin') {
const link = document.createElement('a')
link.href = '/portal.html'
link.textContent = I18N.t('login.gotoAdmin')
msg.appendChild(document.createTextNode('('))
msg.appendChild(link)
msg.appendChild(document.createTextNode(')'))
}
setBusy(false)
} catch {
msg.textContent = '进入工作台失败:网络错误'
msg.textContent = I18N.t('login.errNetwork')
setBusy(false)
}
})
+26 -14
View File
@@ -1,12 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>注册</title>
<title data-i18n="register.title">Sign up</title>
<link rel="icon" href="/logo.svg" />
<link rel="stylesheet" href="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
<script src="/i18n.js"></script>
<style>
/* 档案 81 · R2-UI:提交忙碌态(点击后立刻可见;对齐 06-工作台UI规范 的按钮/圆角/主色) */
.btn[disabled] { opacity: .72; cursor: not-allowed; }
@@ -15,6 +17,11 @@
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
@keyframes btnsp { to { transform: rotate(360deg); } }
.msg { min-height: 20px; }
/* 档案 81 · R5:语言切换(页脚式下拉,不新增页面;中性样式,不引外链) */
.auth-lang { margin-top: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.auth-lang .i18n-lang-label { font-size: 13px; opacity: .72; }
.auth-lang .i18n-lang { font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; }
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
</style>
</head>
@@ -24,25 +31,30 @@
<img class="logo" src="/logo.svg" alt="logo" />
<span class="wordmark"><svg viewBox="0 0 131 24" fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M117.986 0h-3.21v19.404h3.21V0zM8.7 5.215h1.83v2.838H8.7c-1.135 0-2.28.282-3.019 1.068-.738.785-1.016 1.99-1.016 3.194s.267 2.408 1.016 3.193c.75.786 1.884 1.068 3.018 1.068 1.135 0 2.28-.282 3.018-1.068.739-.785 1.017-1.99 1.017-3.193V.649h3.21v18.755h-3.21V18.21h-.589a3.498 3.498 0 01-.192.199c-.803.733-2.034.995-3.243.995-1.894 0-3.788-.472-5.03-1.78C2.44 16.314 2 14.303 2 12.303c0-2 .45-4 1.68-5.32 1.242-1.308 3.136-1.77 5.02-1.77zM57.564 18.9h-1.83v-2.837h1.83c1.134 0 2.28-.283 3.017-1.068.739-.785 1.017-1.99 1.017-3.194s-.267-2.408-1.017-3.194c-.749-.785-1.883-1.068-3.017-1.068s-2.28.283-3.018 1.068c-.738.786-1.017 1.99-1.017 3.194v11.655h-3.21V4.712h3.21v1.194h.59a3.42 3.42 0 01.186-.194l.005-.005c.803-.733 2.034-.995 3.243-.995 1.895 0 3.788.471 5.03 1.78 1.241 1.31 1.68 3.32 1.68 5.32 0 2-.45 4-1.68 5.319-1.23 1.32-3.135 1.77-5.019 1.77zM32.05 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.264-1.843-5.223-1.843-1.958 0-3.928.492-5.222 1.843-1.295 1.35-1.755 3.434-1.755 5.497 0 2.063.47 4.136 1.755 5.498 1.284 1.36 3.264 1.843 5.222 1.843 1.959 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168a5.026 5.026 0 01-.3.367c-.77.816-1.958 1.11-3.135 1.11-1.177 0-2.365-.304-3.136-1.11-.77-.807-1.048-2.063-1.048-3.299 0-1.236.278-2.482 1.049-3.298.77-.817 1.958-1.11 3.135-1.11 1.177 0 2.365.293 3.136 1.11.535.565.834 1.34.963 2.167H23.5v2.272h8.55zM48.168 12.063v1.141h-8.55v-2.272h5.671c-.129-.827-.428-1.602-.963-2.167-.77-.817-1.959-1.11-3.136-1.11s-2.365.293-3.136 1.11c-.77.816-1.049 2.063-1.049 3.298 0 1.236.279 2.492 1.05 3.299.77.806 1.958 1.11 3.135 1.11 1.177 0 2.365-.294 3.136-1.11.107-.116.203-.241.299-.367h3.168c-.278.985-.717 1.874-1.38 2.566-1.285 1.35-3.264 1.843-5.223 1.843s-3.938-.482-5.222-1.843c-1.285-1.362-1.756-3.435-1.756-5.498s.46-4.147 1.755-5.497c1.296-1.351 3.264-1.843 5.223-1.843s3.938.481 5.222 1.843c1.296 1.36 1.756 3.434 1.756 5.497zM78.635 18.315c-1.284.806-3.263 1.089-5.222 1.089-1.958 0-3.917-.294-5.212-1.09-1.295-.795-1.755-2.03-1.755-3.246h3.767c0 .472.225.953.824 1.257.6.304 1.54.419 2.462.419.92 0 1.851-.115 2.46-.42.611-.303.825-.784.825-1.256 0-.47-.214-.952-.824-1.256-.61-.304-1.627-.419-2.547-.419-1.777 0-3.563-.293-4.73-1.09-1.167-.795-1.584-2.03-1.584-3.245 0-1.215.417-2.44 1.584-3.246 1.167-.807 2.953-1.09 4.73-1.09 1.776 0 3.564.294 4.73 1.09 1.167.795 1.584 2.031 1.584 3.246h-3.264c0-.471-.203-.942-.749-1.257-.546-.303-1.391-.419-2.226-.419s-1.68.105-2.226.42c-.556.303-.75.785-.75 1.256 0 .47.204.942.75 1.256.545.304 1.316.42 2.151.42 1.959 0 3.938.292 5.222 1.088 1.295.796 1.756 2.032 1.756 3.246 0 1.215-.471 2.44-1.756 3.247zM96.507 12.063v1.141h-8.55v-2.272h5.672c-.129-.827-.429-1.602-.963-2.167-.771-.817-1.959-1.11-3.136-1.11s-2.366.293-3.136 1.11c-.77.816-1.048 2.063-1.048 3.298 0 1.236.278 2.492 1.048 3.299.77.806 1.959 1.11 3.135 1.11 1.178 0 2.366-.294 3.137-1.11.106-.116.203-.241.3-.367h3.167c-.279.985-.717 1.874-1.38 2.566-1.284 1.35-3.265 1.843-5.224 1.843-1.957 0-3.938-.482-5.222-1.843-1.284-1.362-1.754-3.435-1.754-5.498s.46-4.147 1.754-5.497c1.296-1.351 3.265-1.843 5.222-1.843 1.96 0 3.94.481 5.224 1.843 1.294 1.36 1.754 3.434 1.754 5.497zM112.624 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.265-1.843-5.223-1.843-1.959 0-3.928.492-5.222 1.843-1.296 1.35-1.756 3.434-1.756 5.497 0 2.063.471 4.136 1.756 5.498 1.284 1.36 3.263 1.843 5.222 1.843 1.958 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168l-.012.016c-.093.12-.185.24-.288.35-.77.817-1.957 1.11-3.135 1.11-1.177 0-2.365-.303-3.136-1.11-.77-.806-1.049-2.062-1.049-3.298 0-1.236.279-2.482 1.049-3.298.771-.817 1.959-1.11 3.136-1.11 1.178 0 2.365.293 3.135 1.11.536.565.836 1.34.964 2.167h-5.672v2.272h8.55zM128.73 19.404l-5.264-7.78 5.264-6.252h-3.97l-5.265 6.251 5.265 7.78h3.97z"/></svg></span>
</div>
<p class="auth-sub">注册后需管理员审核方可登录</p>
<p class="auth-sub" data-i18n="register.sub">Your account must be approved by an admin before you can sign in</p>
<form id="register">
<div class="field">
<label for="username">用户名(3–32 位字母/数字/下划线/连字符)</label>
<label for="username" data-i18n="register.username">Username (3–32 letters / digits / underscore / hyphen)</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="field">
<label for="password">密码(至少 8 位)</label>
<label for="password" data-i18n="register.password">Password (at least 8 characters)</label>
<div class="pw-wrap">
<input id="password" name="password" type="password" autocomplete="new-password" required />
<button class="pw-toggle" type="button" id="pwToggle" aria-label="显示密码" aria-pressed="false">显示</button>
<button class="pw-toggle" type="button" id="pwToggle" data-i18n="common.show"
data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
</div>
</div>
<button class="btn primary" type="submit">注 册</button>
<button class="btn primary" type="submit" data-i18n="register.submit">Sign up</button>
<p class="msg" id="msg"></p>
</form>
<p class="auth-alt">已有账号?<a href="/login.html">登录</a></p>
<p class="auth-alt"><span data-i18n="register.hasAccount">Already have an account?</span>
<a href="/login.html" data-i18n="register.signIn">Sign in</a></p>
<!-- 档案 81 · R5:语言切换(默认英语;切换写 cookie dsh_lang 后重载) -->
<div class="auth-lang"><div data-i18n-switch="label"></div></div>
</div>
<script>
@@ -54,8 +66,8 @@
pwBtn.addEventListener('click', () => {
const show = pwInput.type === 'password'
pwInput.type = show ? 'text' : 'password'
pwBtn.textContent = show ? '隐藏' : '显示'
pwBtn.setAttribute('aria-label', show ? '隐藏密码' : '显示密码')
pwBtn.textContent = I18N.t(show ? 'common.hide' : 'common.show')
pwBtn.setAttribute('aria-label', I18N.t(show ? 'common.hidePw' : 'common.showPw'))
pwBtn.setAttribute('aria-pressed', String(show))
pwInput.focus()
})
@@ -71,7 +83,7 @@
if (!btn.dataset.idle) btn.dataset.idle = btn.innerHTML
btn.disabled = true
btn.classList.add('is-busy')
btn.innerHTML = '<span class="sp"></span><span>' + (label || '处理中…') + '</span>'
btn.innerHTML = '<span class="sp"></span><span>' + (label || I18N.t('common.busy')) + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
@@ -82,7 +94,7 @@
event.preventDefault()
msg.textContent = ''
msg.className = 'msg err'
setBusy(true, '注册中…')
setBusy(true, I18N.t('register.signingUp'))
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -90,11 +102,11 @@
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
msg.textContent = body.error === 'username_taken' ? '用户名已被占用' : body.message || '注册失败'
msg.textContent = body.error === 'username_taken' ? I18N.t('register.errTaken') : body.message || I18N.t('register.errFailed')
return
}
msg.className = 'msg ok'
msg.textContent = '注册成功,请等待管理员审核后登录。'
msg.textContent = I18N.t('register.ok')
setBusy(false)
form.querySelector('button[type=submit]').disabled = true
form.reset()
+17 -15
View File
@@ -1,10 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>正在启动工作区</title>
<title data-i18n="wake.title">Starting your workspace</title>
<link rel="stylesheet" href="/design.css" />
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
<script src="/i18n.js"></script>
<style>
/* 档案 77「AI 核心」加载体 —— 与实例页恢复浮层**同一套视觉语言**(配色跟随平台主题) */
.wk-orb{position:relative;width:86px;height:86px;margin:2px auto 16px}
@@ -56,16 +58,16 @@
<div class="wk-ring"></div><div class="wk-core"></div>
<div class="wk-dots"><i></i><i></i><i></i></div>
</div>
<h1 class="page-title" style="margin-bottom:6px">正在启动你的工作区…</h1>
<p class="page-sub" style="margin-bottom:0">空闲一段时间后实例会被回收,首次访问需要几秒重新拉起。</p>
<h1 class="page-title" style="margin-bottom:6px" data-i18n="wake.h1">Starting your workspace…</h1>
<p class="page-sub" style="margin-bottom:0" data-i18n="wake.sub">Idle instances get reclaimed; the first visit takes a few seconds to spin it back up.</p>
<div class="wk-bar"><i></i></div>
<div class="wk-step" id="step">正在连接…</div>
<div class="wk-step" id="step" data-i18n="wake.connecting">Connecting…</div>
<div class="wk-label">dsh · auto recovery</div>
<div class="wk-acts" id="acts" style="display:none">
<button class="btn primary" id="retry">重试</button>
<button class="btn primary" id="retry" data-i18n="common.retry">Retry</button>
</div>
<p class="wk-note" id="note">
若长时间无反应,可点「重试」。本页会自动跳转,无需手动刷新。
<p class="wk-note" id="note" data-i18n="wake.note">
If nothing happens for a while, click “Retry”. This page redirects automatically — no manual refresh needed.
</p>
</div>
</div>
@@ -84,7 +86,7 @@
document.getElementById('spin').style.display = 'none'
acts.style.display = 'block'
note.textContent = msg
show('启动失败')
show(I18N.t('wake.failed'))
}
// 只允许跳回 *.alotbuy.com,避免被 next 参数带偏
@@ -94,7 +96,7 @@
async function wake() {
tried += 1
show(tried === 1 ? '正在拉起实例(第 1 次,通常 5–15 秒)…' : '正在重试(第 ' + tried + ' 次)…')
show(tried === 1 ? I18N.t('wake.launching') : I18N.t('wake.retrying', { n: tried }))
try {
// 复用平台已有的登录直达接口:它内部会 launch(实例不在则拉起)并返回带 token 的直达 URL
var res = await fetch('/api/dsh/enter', {
@@ -106,7 +108,7 @@
var j = await res.json().catch(function () { return {} })
var url = j.url || j.redirect || ''
if (res.ok && url) {
show('已就绪,正在跳转…')
show(I18N.t('wake.ready'))
location.replace(url)
return
}
@@ -116,16 +118,16 @@
}
if (j.error === 'instance_circuit_open') { // 档案 78:被熔断冷却,重试没有意义
var waitMin = Math.max(1, Math.ceil((j.retryAfterMs || 0) / 60000))
fail('实例连续崩溃,平台已暂停自动拉起(约 ' + waitMin + ' 分钟后可重试)。若反复出现,请联系管理员查看维护日志。')
fail(I18N.t('wake.circuit', { min: waitMin }))
return
}
throw new Error(j.error || ('HTTP ' + res.status))
} catch (err) {
if (tried < MAX) {
show('未就绪,2 秒后自动重试…')
show(I18N.t('wake.notReady'))
setTimeout(wake, 2000)
} else {
fail('多次尝试仍未就绪:' + (err && err.message ? err.message : '未知错误'))
fail(I18N.t('wake.giveUp', { msg: (err && err.message ? err.message : I18N.t('wake.unknownErr')) }))
}
}
}
@@ -134,7 +136,7 @@
tried = 0
document.getElementById('spin').style.display = ''
acts.style.display = 'none'
note.textContent = '本页会自动跳转,无需手动刷新。'
note.textContent = I18N.t('wake.noteShort')
wake()
})