chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
<!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.ico" sizes="any" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon-180.png" />
<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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
+73
View File
@@ -0,0 +1,73 @@
/* auth.css —— 登录 / 注册页的规范对齐层(2026-09-13)
*
* 背景:本页原来直接用 design.css,而它是一套**深色 OS-desktop 体系**(`--accent #4d7cfe`、
* 卡片 r20、重投影、输入框 r10/14px),与 `06-工作台UI规范.md` **冲突**。
* 规范第 4 行:「**冲突时以本文档为准**」,第 5 行:**门户页面在适用范围内**。
* 因此这里按规范取值**就近覆盖**(§2 Token / §4.1 按钮 / §4.2 输入框 / 圆角 8px / 正文 15px)。
*
* ⚠️ 刻意**不改 design.css** —— 它被 5 个页面共用,整站换肤超出本次范围且风险大;
* 「静态页全站对齐规范」应作为**单独一期**做(见档案 83)。
*/
:root {
--a-bg: #f5f6f8; /* 规范 --bg */
--a-panel: #ffffff; /* 规范 --panel */
--a-border: #e3e6ea; /* 规范 --border */
--a-text: #24292f; /* 规范 --text */
--a-dim: #6b7280; /* 规范 --dim */
--a-primary: #2f6fed; /* 规范 --primary */
--a-primary-hover: #2a5fd0;
}
/* 页面底色:规范是亮色工具类后台(原为深蓝渐变) */
.auth-bg {
background: var(--a-bg) !important;
}
/* 卡片:规范 §3 —— r12 + `0 1px 3px rgba(0,0,0,.08)`,不用重投影 */
.auth-card {
border: 1px solid var(--a-border);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .08);
color: var(--a-text);
}
.auth-sub { color: var(--a-dim); font-size: 14px; }
/* 表单:§4.2 —— 输入框 `6px 10px` / r8 / 15px / 白底 / 1px 描边 */
.field label { font-size: 14px; color: var(--a-text); }
.field input {
padding: 6px 10px;
font-size: 15px;
border: 1px solid var(--a-border);
border-radius: 8px;
background: var(--a-panel);
color: var(--a-text);
}
.field input:focus {
border-color: var(--a-primary);
box-shadow: 0 0 0 3px rgba(47, 111, 237, .15);
background: var(--a-panel);
}
/* 密码框:右侧「显示密码」按钮(§4.1 .btn-icon 量级 `5px 9px`) */
.pw-wrap { position: relative; }
.pw-wrap input { padding-right: 60px; }
.pw-toggle {
position: absolute; top: 50%; right: 4px; transform: translateY(-50%);
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
padding: 5px 9px; border: 0; border-radius: 6px; cursor: pointer;
background: transparent; color: var(--a-dim); font-size: 13px; line-height: 1;
transition: color .15s, background .15s;
}
.pw-toggle:hover { color: var(--a-primary); background: #eaf1fe; }
.pw-toggle:focus-visible { outline: 2px solid var(--a-primary); outline-offset: 1px; }
/* 按钮:§4.1 —— `.btn` `6px 14px` / r8 / 15px;primary `#2f6fed`;:disabled opacity .5 */
.btn { padding: 6px 14px; border-radius: 8px; font-size: 15px; }
.btn[disabled] { opacity: .5; }
.btn.primary { background: var(--a-primary); }
.btn.primary:hover { background: var(--a-primary-hover); filter: none; }
/* 链接 / 次级文字 */
.auth-alt { color: var(--a-dim); font-size: 14px; }
.auth-alt a { color: var(--a-primary); }
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 131 24" 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"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+272
View File
@@ -0,0 +1,272 @@
/* design system — OS-desktop style */
:root {
--accent: #4d7cfe;
--accent-2: #38d6d0;
--ink: #0f1c33;
--ink-soft: #5a6b85;
--line: #e6ebf4;
--surface: #ffffff;
--surface-2: #f6f8fc;
--danger: #e5484d;
--ok: #1fb56a;
--warn: #f5a623;
--radius: 14px;
--shadow: 0 18px 50px -18px rgba(9, 24, 58, 0.35);
--shadow-sm: 0 6px 18px -8px rgba(9, 24, 58, 0.28);
--mono: "SFMono-Regular", "Cascadia Code", Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: var(--ink);
background: #0b1a33;
-webkit-font-smoothing: antialiased;
}
/* ---------- auth pages ---------- */
.auth-bg {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(60rem 40rem at 80% -10%, rgba(56, 214, 208, 0.18), transparent 60%),
radial-gradient(50rem 36rem at 10% 110%, rgba(77, 124, 254, 0.28), transparent 60%),
linear-gradient(160deg, #0d1f3c, #0a1530);
}
.auth-card {
width: 100%;
max-width: 380px;
background: var(--surface);
border-radius: 20px;
padding: 32px 30px 26px;
box-shadow: var(--shadow);
}
.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.auth-brand .logo { width: 34px; height: 34px; border-radius: 9px; }
.auth-brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.2px; }
.wordmark { display: inline-flex; align-items: center; }
.wordmark svg { height: 100%; width: auto; display: block; }
/* 2026-09-19:登录 / 注册页的品牌标识由 **DeepSeek 图形**改为**本平台文字**
(中文「能力网络」(2026-09-19 定名)/ 英文及其他语言 CapabilityNet,走 i18n 词条 `brand.name`)。
上面那套定高 + svg 的规则对文字不适用 ⇒ 这里补字号 / 字重 / 行高(`height:auto` 覆盖旧定高)。
⚠️ `.topbar .wordmark`(admin / portal 顶栏)**未动** —— 那两处仍用 svg,用户本轮只要求登录 / 注册页。 */
.auth-brand .wordmark { height: auto; font-size: 19px; font-weight: 600; line-height: 1.2; letter-spacing: 0.2px; color: var(--ink); }
.topbar .wordmark { height: 20px; color: #dbe5ff; }
.auth-sub { color: var(--ink-soft); font-size: 13px; margin: 6px 0 22px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 13px; color: var(--ink-soft); margin-bottom: 6px; }
.field input {
width: 100%;
padding: 11px 13px;
font-size: 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
color: var(--ink);
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(77, 124, 254, 0.15); background: #fff; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border: 0; cursor: pointer; font-size: 14px; font-weight: 600;
padding: 11px 16px; border-radius: 10px; transition: transform .06s, filter .15s, background .15s;
}
.btn:active { transform: translateY(1px); }
.btn.primary { width: 100%; background: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.06); }
.btn.ghost { background: var(--surface-2); color: var(--ink); border: 1px solid var(--line); }
.btn.ghost:hover { background: #eef1f8; }
.btn.danger { background: var(--danger); color: #fff; }
.btn.small { padding: 6px 10px; font-size: 13px; border-radius: 8px; }
.msg { min-height: 20px; font-size: 13px; margin-top: 10px; }
.msg.err { color: var(--danger); }
.msg.ok { color: var(--ok); }
.auth-alt { margin-top: 16px; text-align: center; font-size: 13px; color: var(--ink-soft); }
.auth-alt a { color: var(--accent); text-decoration: none; font-weight: 600; }
/* ---------- desktop ---------- */
.desktop { position: fixed; inset: 0; display: flex; flex-direction: column; }
.wallpaper {
position: absolute; inset: 0; z-index: 0;
background:
radial-gradient(70rem 40rem at 75% -20%, rgba(56, 214, 208, 0.20), transparent 55%),
radial-gradient(60rem 40rem at 10% 110%, rgba(77, 124, 254, 0.30), transparent 55%),
linear-gradient(160deg, #0e2246 0%, #0a1630 60%, #071021 100%);
}
.wallpaper::after {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 26px 26px;
}
.topbar {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between;
padding: 0 18px; height: 52px;
background: rgba(13, 26, 52, 0.55); backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(255,255,255,0.08);
color: #dbe5ff;
}
.topbar .brand { display: flex; align-items: center; gap: 9px; font-weight: 600; }
.topbar .brand .logo { width: 24px; height: 24px; border-radius: 6px; }
.topbar .right { display: flex; align-items: center; gap: 12px; font-size: 13px; }
.topbar .who { font-weight: 600; }
.topbar .role {
font-size: 11px; padding: 2px 8px; border-radius: 999px;
background: rgba(77, 124, 254, 0.25); color: #bcd0ff; border: 1px solid rgba(77,124,254,0.4);
}
.topbar .logout { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); color: #dbe5ff; }
.topbar .logout:hover { background: rgba(255,255,255,0.14); }
.icons { position: relative; z-index: 1; flex: 1; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 10px; padding: 22px; }
.desk-icon {
width: 96px; display: flex; flex-direction: column; align-items: center; gap: 7px;
padding: 10px 6px; border-radius: 12px; cursor: pointer; user-select: none;
border: 1px solid transparent;
}
.desk-icon:hover { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.10); }
.desk-icon .tile {
width: 62px; height: 62px; border-radius: 16px; display: grid; place-items: center;
color: #fff; box-shadow: 0 10px 24px -10px rgba(0,0,0,0.5);
}
.desk-icon .tile svg { width: 30px; height: 30px; }
.desk-icon .label { color: #e6edff; font-size: 12.5px; text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,0.5); }
.tile.dsh { background: linear-gradient(135deg, #4d7cfe, #38d6d0); }
.tile.files { background: linear-gradient(135deg, #f5b14d, #f58a4d); }
.tile.admin { background: linear-gradient(135deg, #9b6cff, #4d7cfe); }
.taskbar {
position: relative; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 10px;
height: 56px; background: rgba(13, 26, 52, 0.6); backdrop-filter: blur(14px);
border-top: 1px solid rgba(255,255,255,0.08);
}
.taskbar .app {
width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; cursor: pointer;
border: 1px solid transparent; transition: background .15s, transform .1s;
}
.taskbar .app:hover { background: rgba(255,255,255,0.1); }
.taskbar .app.active { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.18); }
.taskbar .app svg { width: 22px; height: 22px; }
.taskbar .clock { position: absolute; right: 18px; color: #cfe0ff; font-size: 13px; }
/* ---------- windows ---------- */
.window {
position: absolute; z-index: 10; min-width: 380px; min-height: 220px;
background: var(--surface); border-radius: 16px; box-shadow: var(--shadow);
display: flex; flex-direction: column; overflow: hidden;
animation: win-in .18s ease;
}
@keyframes win-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
.window.focused { box-shadow: 0 26px 70px -20px rgba(0,0,0,0.55); }
.window .rz, .window .resize { position: absolute; z-index: 3; touch-action: none; }
.window .rz-n { top: 0; left: 16px; right: 16px; height: 6px; cursor: ns-resize; }
.window .rz-s { bottom: 0; left: 16px; right: 16px; height: 6px; cursor: ns-resize; }
.window .rz-e { right: 0; top: 48px; bottom: 0; width: 6px; cursor: ew-resize; }
.window .rz-w { left: 0; top: 48px; bottom: 0; width: 6px; cursor: ew-resize; }
.window .rz-ne { top: 30px; right: 0; width: 14px; height: 14px; cursor: nesw-resize; }
.window .rz-nw { top: 30px; left: 0; width: 14px; height: 14px; cursor: nwse-resize; }
.window .rz-se, .window .resize { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; }
.window .rz-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; }
.window .rz-se::after, .window .resize::after {
content: ""; position: absolute; right: 4px; bottom: 4px; width: 9px; height: 9px;
border-right: 2px solid rgba(120, 132, 160, 0.55);
border-bottom: 2px solid rgba(120, 132, 160, 0.55);
border-bottom-right-radius: 4px;
}
.window-title {
display: flex; align-items: center; justify-content: space-between;
padding: 0 14px; height: 44px; background: var(--surface-2); border-bottom: 1px solid var(--line);
cursor: grab; user-select: none;
}
.window-title .t { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; }
.window-title .t svg { width: 16px; height: 16px; color: var(--accent); }
.window-title .controls { display: flex; gap: 6px; }
.window-title .controls button {
width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; cursor: pointer;
color: var(--ink-soft); font-size: 13px; line-height: 1; display: grid; place-items: center;
}
.window-title .controls button:hover { background: #e8ecf5; }
.window-title .controls button.close:hover { background: var(--danger); color: #fff; }
.window-body { flex: 1; overflow: auto; padding: 16px; }
/* file table */
.pathbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.pathbar .crumbs { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--accent); }
.pathbar .crumbs .sep { color: var(--ink-soft); }
.pathbar .crumb { cursor: pointer; }
.pathbar .crumb:hover { text-decoration: underline; }
table.files { width: 100%; border-collapse: collapse; font-size: 13.5px; }
table.files th, table.files td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
table.files th { color: var(--ink-soft); font-weight: 600; font-size: 12px; }
table.files tbody tr:hover { background: var(--surface-2); }
table.files .dir { color: var(--accent); cursor: pointer; font-weight: 600; }
table.files .dir:hover { text-decoration: underline; }
/* dsh status */
.stat { display: flex; align-items: center; gap: 8px; font-size: 14px; padding: 10px 12px; border-radius: 10px; background: var(--surface-2); margin-bottom: 12px; }
.dot { width: 9px; height: 9px; border-radius: 50%; }
.dot.on { background: var(--ok); box-shadow: 0 0 0 4px rgba(31,181,106,0.15); }
.dot.off { background: var(--ink-soft); }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.section-label { font-size: 12px; color: var(--ink-soft); font-weight: 600; margin: 16px 0 8px; }
.plugin-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 8px; font-size: 13.5px; }
.plugin-item:hover { background: var(--surface-2); }
/* admin */
table.admin { width: 100%; border-collapse: collapse; font-size: 13.5px; }
table.admin th, table.admin td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
table.admin th { color: var(--ink-soft); font-weight: 600; font-size: 12px; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; }
.badge.admin { background: #e7edff; color: #3f5fd0; }
.badge.pending { background: #fff3dd; color: #b0761a; }
.badge.active { background: #e4f7ec; color: #15995a; }
.badge.disabled { background: #eceff4; color: #6a7588; }
.hidden { display: none !important; }
/* ---------- mobile ---------- */
@media (max-width: 768px) {
.auth-card { max-width: 100%; overflow-x: auto; }
.desktop { overflow: hidden; }
.topbar { padding: 0 10px; height: 48px; }
.topbar .role { display: none; }
.icons { padding: 10px; gap: 6px; }
.desk-icon { width: 80px; padding: 8px 4px; }
.desk-icon .tile { width: 52px; height: 52px; border-radius: 14px; }
.desk-icon .label { font-size: 11px; }
.desk-path { padding: 8px 10px 0; font-size: 12px; }
.desk-path .loc { max-width: 70vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.taskbar { height: 50px; }
.taskbar .clock { display: none; }
/* windows go full-screen on phones */
.window {
position: fixed !important;
inset: 0 !important;
left: 0 !important; top: 0 !important;
width: 100% !important; height: 100% !important;
min-width: 0 !important; min-height: 0 !important;
max-width: none !important;
border-radius: 0 !important;
}
.window .rz, .window .resize { display: none; }
.window-body { -webkit-overflow-scrolling: touch; }
.window-body table { min-width: 520px; }
.window-title { height: 48px; }
.window-title .controls button { width: 34px; height: 34px; }
}
/* desktop file grid + path bar */
.desk-path { position: relative; z-index: 1; display: flex; align-items: center; gap: 10px; padding: 12px 22px 0; color: #cfe0ff; font-size: 13px; }
.desk-path .back { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #dbe5ff; }
.desk-path .back:hover { background: rgba(255,255,255,0.14); }
.desk-path .loc { opacity: .85; }
.tile { overflow: hidden; }
.tile img { min-width: 0; min-height: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
.tile.file { background: linear-gradient(135deg, #7f9cf5, #5a74d0); }
.desk-divider { width: 100%; height: 0; border-top: 1px solid rgba(255,255,255,0.14); margin: 4px 2px 10px; }
#fileIcons { display: contents; }
#fileIcons .desk-icon .label { font-size: 12px; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="能力网络">
<title>能力网络</title>
<!-- 平台自有品牌图标(2026-09-19,档案 137)。
取「枢纽 / hub」意象:中心节点 + 三条辐条 + 三个外环节点。
配色取自平台自己的 token:ink #0f1c33 作底、accent-2 #38d6d0 作节点。
刻意避开原 DeepSeek 的品牌蓝 #4D6BFE,避免"看起来还是别人家的图标"。
同时兼作 favicon 与 portal 顶栏图标(.logo 的 border-radius 与 rx 对齐)。
⚠️ 本注释内不得出现两个连续 ASCII 连字符:XML 注释语法不允许,会导致整份 SVG 解析失败
(即浏览器静默不显示图标)。CSS 变量写法(双连字符加名字)正是最容易踩的形态。 -->
<rect width="32" height="32" rx="8" fill="#0f1c33"/>
<g stroke="#38d6d0" stroke-width="2" stroke-linecap="round">
<path d="M16 16V8"/>
<path d="M16 16l7 4.5"/>
<path d="M16 16l-7 4.5"/>
</g>
<circle cx="16" cy="16" r="3.5" fill="#ffffff"/>
<circle cx="16" cy="8" r="2.5" fill="#38d6d0"/>
<circle cx="23" cy="20.5" r="2.5" fill="#38d6d0"/>
<circle cx="9" cy="20.5" r="2.5" fill="#38d6d0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+343
View File
@@ -0,0 +1,343 @@
/*!
* 平台多语言运行时(档案 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',
// 2026-09-27 · DSH 官方账号登录(与密码登录并列的第二条路)
'login.or': 'or',
'login.dshAccount': 'Sign in with DSH account',
'login.errOauth': 'Official account sign-in did not complete, please retry',
'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': '注册',
// 2026-09-27 · DSH 官方账号登录(与密码登录并列的第二条路)
'login.or': '或',
'login.dshAccount': '用 DSH 官方账号登录',
'login.errOauth': '官方账号登录未完成,请重试',
'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()
})()
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n="index.title">Workspace</title>
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
<script src="/i18n.js"></script>
</head>
<body>
<script>
;(async () => {
const res = await fetch('/api/auth/me')
if (!res.ok) {
location.href = '/login.html'
return
}
// 所有用户(admin/active)统一直达 DSH 会话窗口
try {
const r = await fetch('/api/dsh/enter', { method: 'POST' })
const b = await r.json().catch(() => ({}))
if (r.ok && b.kind === 'session' && b.url) {
location.href = b.url
return
}
} catch {
// fall through to desktop
}
location.href = '/portal.html'
})()
</script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n="login.title">Sign in</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon-180.png" />
<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; }
.btn.is-busy { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn .sp { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255,255,255,.45);
border-top-color: #fff; border-radius: 50%; animation: btnsp .7s linear infinite; }
@keyframes btnsp { to { transform: rotate(360deg); } }
.msg { min-height: 20px; }
/* 2026-09-27 · 官方账号登录入口:分隔线 + 同宽次级按钮(⛔ 不引外链、⛔ 不加第三方脚本) */
.auth-or { display: flex; align-items: center; gap: 10px; margin: 16px 0 14px;
color: var(--a-dim, #6b7280); font-size: 13px; }
.auth-or::before, .auth-or::after { content: ""; flex: 1; height: 1px; background: var(--a-border, #e3e6ea); }
.btn.dsh-login { width: 100%; border: 1px solid var(--a-border, #e3e6ea); background: #fff;
color: var(--a-text, #24292f); text-decoration: none; }
.btn.dsh-login:hover { background: #eaf1fe; border-color: var(--a-primary, #2f6fed); }
/* 档案 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>
<body class="auth-bg">
<div class="auth-card">
<div class="auth-brand">
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
</div>
<p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p>
<form id="login">
<div class="field">
<label for="username" data-i18n="login.username">Username</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="field">
<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" 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" data-i18n="login.submit">Sign in</button>
<p class="msg" id="msg"></p>
</form>
<!-- 2026-09-27 · 官方账号登录(DSH 账号):走官方 OAuth(PKCE,S256),
回来之后与密码登录**同一套** `sid` 会话 ⇒ 两条路都能去批准扫码配对。
⚠️ `next` 由下面的脚本补上(站内绝对路径,判据与密码登录那条逐条同源)。 -->
<div class="auth-or" data-i18n="login.or">or</div>
<a class="btn dsh-login" id="dshLogin" href="/oauth/dsh/start"
data-i18n="login.dshAccount">Sign in with DSH account</a>
<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>
// 档案 83 · 显示/隐藏密码(对齐 06-工作台UI规范 的 .btn-icon 交互;键盘可达 = 原生 button)
const pwInput = document.getElementById('password')
const pwBtn = document.getElementById('pwToggle')
if (pwInput && pwBtn) {
pwBtn.addEventListener('click', () => {
const show = pwInput.type === 'password'
pwInput.type = show ? 'text' : 'password'
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()
})
}
const form = document.getElementById('login')
const msg = document.getElementById('msg')
// 扫码配对(2026-09-27):确认页在**未登录**时会把人送到这里,并用 `?next=` 记住要去哪。
// ⚠️ 只接受**站内绝对路径**(`/` 开头且不是 `//`,排除协议相对跳转)—— 否则就是开放重定向。
const nextUrl = (() => {
const raw = new URLSearchParams(location.search).get('next') || ''
return raw.startsWith('/') && !raw.startsWith('//') ? raw : null
})()
// 2026-09-27 · 官方账号登录:与上面**同一个 `next` 口径** —— 两条路登完都回原来那一页
// (手机扫码那条路即 `/pair.html?code=…`)。
const dshLink = document.getElementById('dshLogin')
if (dshLink && nextUrl !== null) dshLink.href = '/oauth/dsh/start?next=' + encodeURIComponent(nextUrl)
// 官方入口失败:服务端 302 回 `?dsh_error=start` ⇒ 就地给一句提示。
// ⛔ 不显示官方返回的任何内容(本页拿不到也不需要拿)。
if (new URLSearchParams(location.search).get('dsh_error')) {
msg.className = 'msg err'
msg.textContent = I18N.t('login.errOauth')
}
// 档案 81 · R2-UI:任何提交都要有"看得见"的忙碌态(否则几秒内用户会以为没反应)
function setBusy(busy, label) {
const btn = form.querySelector('button[type=submit]')
if (!btn) return
if (busy) {
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 || I18N.t('common.busy')) + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
msg.textContent = ''
msg.className = 'msg err'
setBusy(true, I18N.t('login.signingIn'))
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
msg.textContent =
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
// 扫码配对(2026-09-27):带着 `?next=` 进来 ⇒ 登完**回那一页**(例如"确认绑定这台电脑")。
// ⛔ 不改成"所有登录都跳过 /api/dsh/enter" —— 那条默认路径逐字不变。
if (nextUrl !== null) {
setBusy(true, I18N.t('login.entering'))
location.href = nextUrl
return
}
// 所有用户(admin/active)登录后统一直达 DSH 会话窗口
setBusy(true, I18N.t('login.entering'))
try {
const r = await fetch('/api/dsh/enter', { method: 'POST' })
const b = await r.json().catch(() => ({}))
if (r.ok && b.kind === 'session' && b.url) {
location.href = b.url
return
}
// 失败兜底:admin 可手动进管理台 /portal.html
const errText =
b.error === 'instance_starting' ? I18N.t('login.errStarting')
: (b.error ?? 'HTTP ' + r.status)
// 档案 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 = I18N.t('login.errNetwork')
setBusy(false)
}
})
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>DeepSeek</title>
<path fill="#4D6BFE" d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+216
View File
@@ -0,0 +1,216 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow" />
<title>确认绑定这台电脑</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<style>
/* 与 portal.html / login.html 同一套 Token(06-工作台UI规范):亮色 + 蓝主色 + 橙强调 */
:root {
--bp-primary: #2f6fed; --bp-primary-soft: #eaf1fe; --bp-primary-hover: #2a5fd0;
--bp-accent: #e8590c; --bp-danger: #d93026;
--bp-bg: #f5f6f8; --bp-panel: #ffffff; --bp-border: #e3e6ea;
--bp-text: #24292f; --bp-dim: #6b7280; --bp-head: #f6f8fa;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 20px max(16px, env(safe-area-inset-left)) calc(20px + env(safe-area-inset-bottom));
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background: var(--bp-bg); color: var(--bp-text);
}
.box { width: 100%; max-width: 420px; }
.brand { display: flex; align-items: center; gap: 10px; justify-content: center; margin-bottom: 16px; font-weight: 600; font-size: 16px; }
.brand img { width: 26px; height: 26px; }
.card { background: var(--bp-panel); border: 1px solid var(--bp-border); border-radius: 14px; padding: 22px 20px; }
h1 { font-size: 19px; margin: 0 0 6px; }
.sub { font-size: 13px; color: var(--bp-dim); margin: 0 0 18px; line-height: 1.7; }
.rows { border-top: 1px solid var(--bp-border); }
.row { display: flex; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--bp-border); font-size: 14px; }
.row .k { color: var(--bp-dim); flex: 0 0 84px; }
.row .v { flex: 1; word-break: break-all; }
.row .v.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .5px; }
.warn { margin: 16px 0 0; padding: 11px 12px; border-radius: 10px; background: #fff4e5; color: #8a4b00; font-size: 13px; line-height: 1.7; }
.acts { display: flex; flex-direction: column; gap: 10px; margin-top: 20px; }
button { font: inherit; border-radius: 10px; padding: 13px 16px; cursor: pointer; border: 1px solid var(--bp-border); background: #fff; color: var(--bp-text); }
button.primary { background: var(--bp-primary); border-color: var(--bp-primary); color: #fff; font-weight: 600; }
button.primary:active { background: var(--bp-primary-hover); }
button:disabled { opacity: .55; cursor: default; }
.state { text-align: center; padding: 10px 0 2px; }
.state .big { font-size: 34px; line-height: 1.2; }
.state h2 { font-size: 17px; margin: 10px 0 6px; }
.state p { font-size: 13px; color: var(--bp-dim); margin: 6px 0 0; line-height: 1.7; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.count { font-variant-numeric: tabular-nums; }
a.link { color: var(--bp-primary); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.hide { display: none !important; }
</style>
</head>
<body>
<div class="box">
<div class="brand"><img src="/favicon.svg" alt="" />ai1net 覆盖网络</div>
<div class="card" id="card"><div class="state"><p>加载中…</p></div></div>
</div>
<script>
/* 扫码配对 · 手机侧确认页(2026-09-27)
*
* 这个页面**只出现在扫码之后**:整页内容 = 一台电脑的绑定请求 + 两个按钮。
* 纪律(与 platform 侧一致):
* · 页面里**不出现**任何令牌 / 口令 / 私钥 —— 凭据是批准之后由平台直接签给那台电脑的,
* 本页拿不到、也不需要拿;
* · 归属由**会话**决定(`POST /api/overlay/pair/approve` 走 requireAuth,请求体里只有 code)
* ⇒ 页面上没有任何"绑给谁"的输入项,也就没有填错的可能。
*/
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
const CODE_LEN = 26
const $ = (id) => document.getElementById(id)
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const postJson = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
/** 从 `?code=` 或 `#code=` 取码,并归一化(去分组符 + 大写)。 */
function codeFromLocation() {
const q = new URLSearchParams(location.search)
const raw = (q.get('code') || location.hash.replace(/^#/, '') || '').replace(/[-\s]/g, '').toUpperCase()
if (raw.length !== CODE_LEN || [...raw].some((c) => !ALPHABET.includes(c))) return null
return raw
}
const code = codeFromLocation()
function render(html) { $('card').innerHTML = html }
function renderBadLink() {
render(`<div class="state">
<div class="big">🔗</div>
<h2>这个链接不完整</h2>
<p>请回到那台电脑上,用相机重新扫一次它显示的二维码。</p>
<div class="acts"><button onclick="location.href='/portal.html'">去门户首页</button></div>
</div>`)
}
/** 未登录 ⇒ 把两条路一次讲清:门户账号在本页就能完成;官方账号必须回那台电脑上做。 */
function renderNeedLogin() {
const back = encodeURIComponent(location.pathname + location.search)
render(`<div class="state">
<div class="big">🔐</div>
<h2>选一种方式确认绑定</h2>
<p><b>用门户账号</b>:先登录,再回到本页点「确认绑定」。</p>
<div class="acts"><button class="primary" onclick="location.href='/login.html?next=${back}'">登录门户账号</button></div>
<p style="margin-top:16px"><b>用官方账号</b>:官方授权只能在显示二维码的那台电脑上完成(官方只认那台电脑本机的回调地址,手机上做不了)。点下面的按钮,那台电脑会自动打开官方授权页,回到电脑上点批准即可。</p>
<div class="acts"><button id="want" onclick="wantOfficial()">让那台电脑打开官方授权页</button></div>
<p class="sub" id="wro"></p>
</div>`)
}
function renderGone(detail) {
render(`<div class="state">
<div class="big">⌛</div>
<h2>这个请求已失效</h2>
<p>${esc(detail || '配对码只活 5 分钟,且只能用一次。')}</p>
<p>请在电脑上重新生成二维码再扫一次。</p>
</div>`)
}
function rowsHtml(info, remain) {
return `<div class="rows">
<div class="row"><span class="k">设备名称</span><span class="v">${info.label ? esc(info.label) : '(未命名,来自那台电脑)'}</span></div>
<div class="row"><span class="k">密钥指纹</span><span class="v mono">${esc(info.fingerprint)}</span></div>
<div class="row"><span class="k">有效期</span><span class="v count" id="remain">${remain}</span></div>
</div>`
}
async function renderDone(kind, body) {
if (kind === 'approved') {
render(`<div class="state">
<div class="big">✅</div>
<h2>已绑定这台电脑</h2>
<p>它会在几秒内自动取走凭据,然后就可以关掉本页了。</p>
<div class="rows" style="text-align:left;margin-top:16px">
<div class="row"><span class="k">设备标识</span><span class="v mono">${esc(body.hostId || '')}</span></div>
<div class="row"><span class="k">租约到期</span><span class="v">${esc(body.expiresAt ? new Date(body.expiresAt).toLocaleString() : '')}</span></div>
</div>
<div class="acts"><button onclick="location.href='/portal.html#/devices'">看我的设备</button></div>
</div>`)
return
}
render(`<div class="state">
<div class="big">🚫</div>
<h2>已拒绝</h2>
<p>那台电脑不会拿到任何凭据。如果这是误操作,让它重新生成二维码再扫一次。</p>
</div>`)
}
/** 请那台电脑去开官方授权页:手机上做不了官方 OAuth,这里只置一个标志,真正的批准在电脑上完成。 */
async function wantOfficial() {
const el = $('wro')
const btn = $('want')
try {
const r = await postJson('/api/overlay/pair/want-official', { code })
const b = await r.json().catch(() => ({}))
if (!r.ok) { if (el) el.textContent = b.detail || '这个请求已失效,请在电脑上重新生成二维码再扫一次。'; return }
if (btn) { btn.disabled = true; btn.textContent = '已通知那台电脑' }
if (el) el.textContent = '已通知那台电脑 —— 它现在应该打开了官方授权页。回到电脑上点「批准」即可。'
} catch { if (el) el.textContent = '网络异常,请重试。' }
}
async function main() {
if (code === null) return renderBadLink()
let me = null
try { const r = await fetch('/api/auth/me'); me = r.ok ? (await r.json()).user : null } catch {}
if (!me) return renderNeedLogin()
let info
try {
const r = await fetch('/api/overlay/pair/info?code=' + encodeURIComponent(code))
const b = await r.json().catch(() => ({}))
if (!r.ok) return renderGone(b.detail)
info = b
} catch { return renderGone('网络异常,请重试。') }
const expiresAt = Date.parse(info.expiresAt)
const countdown = () => {
const left = Math.max(0, Math.round((expiresAt - Date.now()) / 1000))
const el = $('remain')
if (el) el.textContent = left > 0 ? `${Math.floor(left / 60)} 分 ${String(left % 60).padStart(2, '0')} 秒后失效` : '已失效'
return left
}
const timer = setInterval(() => { if (countdown() <= 0) { clearInterval(timer); renderGone() } }, 1000)
render(`<h1>这台电脑请求绑定</h1>
<p class="sub">扫的是一个<b>在你电脑上显示</b>的请求。确认之后,这台电脑就能以 <b>${esc(me.username)}</b> 的身份接入覆盖网络。</p>
${rowsHtml(info, '')}
<div class="warn">只在你<b>自己正看着</b>那台电脑时确认。指纹与电脑上显示的一致吗?不一致就别点。</div>
<div class="rows" style="text-align:left;margin-top:4px">
<div class="row"><span class="k">想用官方账号</span><span class="v">回到那台电脑上点「用 DSH 官方账号绑定」即可(官方授权只能在电脑本机完成);两种方式绑上的是同一台设备。</span></div>
</div>
<div class="acts">
<button class="primary" id="ok">确认绑定</button>
<button id="no">不是我,拒绝</button>
</div>`)
countdown()
const send = async (path, kind) => {
$('ok').disabled = true; $('no').disabled = true
try {
const r = await postJson(path, { code })
const b = await r.json().catch(() => ({}))
if (!r.ok) return renderGone(b.detail || b.error)
clearInterval(timer)
await renderDone(kind, b)
} catch {
$('ok').disabled = false; $('no').disabled = false
alert('网络异常,请重试')
}
}
$('ok').onclick = () => send('/api/overlay/pair/approve', 'approved')
$('no').onclick = () => send('/api/overlay/pair/deny', 'denied')
}
main()
</script>
</body>
</html>
+1132
View File
File diff suppressed because it is too large. Load diff
+351
View File
@@ -0,0 +1,351 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n="register.title">Sign up</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon-180.png" />
<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; }
.btn.is-busy { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.btn .sp { width: 14px; height: 14px; flex: 0 0 14px; border: 2px solid rgba(255,255,255,.45);
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; } }
/* 档案 134:邮箱验证码 + 人机验证(复用 .field 的版式,只加"输入框 + 按钮同一行"的排布) */
.code-row { display: flex; gap: 8px; align-items: stretch; }
.code-row input { flex: 1 1 auto; min-width: 0; }
.btn-code { flex: 0 0 auto; padding: 0 12px; min-height: 38px; border-radius: 8px; cursor: pointer;
border: 1px solid rgba(0,0,0,.18); background: transparent; color: inherit;
font-size: 13px; white-space: nowrap; }
.btn-code[disabled] { opacity: .6; cursor: not-allowed; }
.captcha-box { min-height: 65px; }
.captcha-box .cf-note { font-size: 12px; opacity: .66; }
</style>
</head>
<body class="auth-bg">
<div class="auth-card">
<div class="auth-brand">
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
</div>
<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" 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" 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" data-i18n="common.show"
data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
</div>
</div>
<!-- 档案 134:邮箱 + 验证码(仅在服务端启用邮箱验证时显示;配置读不到时也显示,由服务端定夺) -->
<div class="field" id="emailField">
<label for="email" data-i18n="register.email">E-mail</label>
<div class="code-row">
<input id="email" name="email" type="email" autocomplete="email"
placeholder="[email protected]" data-i18n-attr="placeholder:register.emailPh" />
<button class="btn-code" type="button" id="sendCode" data-i18n="register.sendCode">Send code</button>
</div>
</div>
<div class="field" id="codeField">
<label for="code" data-i18n="register.code">E-mail verification code</label>
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="8"
placeholder="6-digit code" data-i18n-attr="placeholder:register.codePh" />
</div>
<div class="field" id="captchaField" style="display:none">
<label data-i18n="register.captcha">Human verification</label>
<div class="captcha-box" id="captcha"><span class="cf-note" data-i18n="register.captchaLoading">Loading…</span></div>
</div>
<button class="btn primary" type="submit" data-i18n="register.submit">Sign up</button>
<p class="msg" id="msg"></p>
</form>
<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>
// 档案 83 · 显示/隐藏密码(对齐 06-工作台UI规范 的 .btn-icon 交互;键盘可达 = 原生 button)
const pwInput = document.getElementById('password')
const pwBtn = document.getElementById('pwToggle')
if (pwInput && pwBtn) {
pwBtn.addEventListener('click', () => {
const show = pwInput.type === 'password'
pwInput.type = show ? 'text' : 'password'
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()
})
}
const form = document.getElementById('register')
const msg = document.getElementById('msg')
// 档案 81 · R2-UI:任何提交都要有"看得见"的忙碌态(否则几秒内用户会以为没反应)
function setBusy(busy, label) {
const btn = form.querySelector('button[type=submit]')
if (!btn) return
if (busy) {
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 || I18N.t('common.busy')) + '</span>'
} else {
btn.disabled = false
btn.classList.remove('is-busy')
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
}
}
function setMsg(text, kind) { msg.className = 'msg ' + (kind || 'err'); msg.textContent = text }
/* ── 档案 134 · 注册配置 / 邮箱验证码 / 人机验证 ──────────────────────────
三条设计取舍:
① **配置由服务端下发**(`/api/auth/register/config`):sitekey 随环境变,
写死在页面里会让"换环境忘换 key"表现为"控件在、但永远验不过"的静默失效。
② **配置读不到时仍显示邮箱字段**(不校验、照原样提交):服务端才是权威,
读不到配置就"整页降级成不可注册"是最差的失败方式。
③ **Turnstile token 单次有效** ⇒ 每次提交前取**新** token(用完即作废并 reset),
否则第二次请求必然拿到 `timeout-or-duplicate`。 */
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,32}$/
let cfg = { captcha: { enabled: false, siteKey: '', action: 'signup' }, emailCode: { enabled: false } }
let requireEmailCode = false
let captchaToken = ''
let widgetId = null
let resolveToken = null
let cooldownLeft = 0
let cooldownTimer = null
const emailField = document.getElementById('emailField')
const codeField = document.getElementById('codeField')
const captchaField = document.getElementById('captchaField')
const sendBtn = document.getElementById('sendCode')
function sendBtnLabel() {
sendBtn.textContent = cooldownLeft > 0
? I18N.t('register.resendIn', { n: cooldownLeft })
: I18N.t('register.sendCode')
sendBtn.disabled = cooldownLeft > 0
}
function startCooldown(seconds) {
cooldownLeft = Math.max(1, Math.floor(seconds || 60))
sendBtnLabel()
if (cooldownTimer) clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => {
cooldownLeft -= 1
sendBtnLabel()
if (cooldownLeft <= 0) { clearInterval(cooldownTimer); cooldownTimer = null }
}, 1000)
}
/** 取一个**新鲜**的人机验证 token(无 token 时 reset 并等待回调;超时返回空串)。 */
function freshToken() {
if (!cfg.captcha.enabled) return Promise.resolve('')
if (captchaToken) return Promise.resolve(captchaToken)
return new Promise((resolve) => {
let settled = false
const finish = (value) => { if (!settled) { settled = true; resolveToken = null; resolve(value) } }
resolveToken = finish
try { if (widgetId !== null && window.turnstile) window.turnstile.reset(widgetId) } catch (e) { /* 忽略 */ }
setTimeout(() => finish(''), 12000)
})
}
function consumeToken() { captchaToken = '' }
function mapError(code, attemptsLeft) {
switch (code) {
case 'username_taken': return I18N.t('register.errTaken')
case 'email_taken': return I18N.t('register.errEmailTaken')
case 'invalid_email':
case 'email_required': return I18N.t('register.errEmail')
case 'invalid_username': return I18N.t('register.errUsername')
case 'code_attempts_exceeded': return I18N.t('register.errCodeExhausted')
case 'code_invalid':
case 'code_missing':
case 'code_expired':
case 'code_used':
case 'code_username_mismatch':
return attemptsLeft > 0
? I18N.t('register.errCode') + ' (' + attemptsLeft + ')'
: I18N.t('register.errCode')
case 'mail_send_failed': return I18N.t('register.errMail')
case 'captcha_failed':
case 'captcha_unavailable': return I18N.t('register.errCaptcha')
case 'email_code_disabled': return I18N.t('register.errUnavailable')
default: return I18N.t('register.errFailed')
}
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = src
s.async = true
s.defer = true
s.onload = () => resolve()
s.onerror = () => reject(new Error('load-failed'))
document.head.appendChild(s)
})
}
async function boot() {
let loaded = null
try {
const res = await fetch('/api/auth/register/config', { headers: { accept: 'application/json' } })
if (res.ok) loaded = await res.json()
} catch (e) { /* 见取舍 ②:读不到就降级,不放倒整页 */ }
if (loaded) {
cfg = {
captcha: loaded.captcha || { enabled: false, siteKey: '', action: 'signup' },
emailCode: loaded.emailCode || { enabled: false },
}
requireEmailCode = !!cfg.emailCode.enabled
}
// 字段显隐:启用时才必填;配置读不到时"显示但不强制"(服务端会给出明确错误)。
const showEmail = loaded ? requireEmailCode : true
emailField.style.display = showEmail ? '' : 'none'
codeField.style.display = showEmail ? '' : 'none'
sendBtnLabel()
if (!cfg.captcha.enabled || !cfg.captcha.siteKey) return
captchaField.style.display = ''
const box = document.getElementById('captcha')
try {
await loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit')
if (!window.turnstile) throw new Error('no-api')
box.innerHTML = ''
widgetId = window.turnstile.render(box, {
sitekey: cfg.captcha.siteKey,
// action 由服务端下发(与服务端 siteverify 校的是同一份取值)——
// 它是「这枚 token 为哪个业务签发」的标签,⛔ 不要在前端另写一个常量。
action: cfg.captcha.action || 'signup',
callback: (token) => { captchaToken = token; if (resolveToken) resolveToken(token) },
'expired-callback': () => { captchaToken = '' },
'error-callback': () => { captchaToken = '' },
})
} catch (e) {
// 加载失败 ⇒ **明确告知 + 不给提交**(提交也一定被服务端拒,不如先说清楚)
box.innerHTML = ''
setMsg(I18N.t('register.errCaptchaLoad'))
form.querySelector('button[type=submit]').disabled = true
}
}
sendBtn.addEventListener('click', async () => {
if (cooldownLeft > 0) return
setMsg('')
const username = form.username.value.trim()
const email = form.email.value.trim()
if (!USERNAME_RE.test(username)) return setMsg(I18N.t('register.errUsername'))
if (!EMAIL_RE.test(email)) return setMsg(I18N.t('register.errEmail'))
const token = await freshToken()
if (cfg.captcha.enabled && token === '') return setMsg(I18N.t('register.errCaptchaToken'))
sendBtn.disabled = true
sendBtn.textContent = I18N.t('register.sendingCode')
try {
const res = await fetch('/api/auth/register/email-code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, email, captchaToken: token }),
})
const body = await res.json().catch(() => ({}))
consumeToken() // 无论成败,token 都已用过
if (!res.ok) {
const wait = Number(body.retryAfterSeconds || 0)
if (res.status === 429) {
if (wait > 0) startCooldown(wait)
setMsg(I18N.t('register.errCooldown', { n: wait > 0 ? wait : 60 }))
} else {
setMsg(mapError(body.error, body.attemptsLeft))
}
} else {
startCooldown(body.retryAfterSeconds || 60)
setMsg(I18N.t('register.codeSent'), 'ok')
}
} catch (e) {
consumeToken()
setMsg(I18N.t('register.errMail'))
} finally {
if (cooldownLeft <= 0) { sendBtn.disabled = false; sendBtn.textContent = I18N.t('register.sendCode') }
}
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
setMsg('')
const username = form.username.value.trim()
const password = form.password.value
if (!USERNAME_RE.test(username)) return setMsg(I18N.t('register.errUsername'))
if (password.length < 8) return setMsg(I18N.t('register.errPassword'))
let email = ''
let code = ''
if (emailField.style.display !== 'none') {
email = form.email.value.trim()
code = form.code.value.trim()
const touched = email !== '' || code !== ''
if (requireEmailCode || touched) {
if (!EMAIL_RE.test(email)) return setMsg(I18N.t('register.errEmail'))
if (!/^\d{4,8}$/.test(code)) return setMsg(I18N.t('register.errEmailRequired'))
}
}
let token = ''
if (cfg.captcha.enabled) {
token = await freshToken()
if (token === '') return setMsg(I18N.t('register.errCaptchaToken'))
}
setBusy(true, I18N.t('register.signingUp'))
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password, email, code, captchaToken: token }),
})
const body = await res.json().catch(() => ({}))
if (cfg.captcha.enabled) consumeToken()
if (!res.ok) {
setBusy(false)
return setMsg(mapError(body.error, body.attemptsLeft))
}
msg.className = 'msg ok'
msg.textContent = I18N.t('register.ok')
setBusy(false)
form.querySelector('button[type=submit]').disabled = true
form.reset()
} catch (e) {
if (cfg.captcha.enabled) consumeToken()
setBusy(false)
setMsg(I18N.t('register.errFailed'))
}
})
boot()
</script>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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 核心」加载体 —— 与实例页恢复浮层**同一套视觉语言**(配色跟随平台主题)。
2026-09-25 与浮层同步改造(用户:「去掉那个圆角矩形旋转的样式,改为一个闪烁的点,绕着图标圆环转圈」):
撤掉「conic 扫描弧(1.5s 整圈转) + 反向虚线环(7s 整圈转) + 三颗轨道粒子(3.4s)」——
它们转的都是**环/弧本身**,观感像一整圈边框在滚;
改为 `.wk-orbit > i`:**1 个亮点沿圆环巡游 + 呼吸式闪烁**,与浮层 `.__dsh-orbit` 逐项同构
(一圈 2.6s、闪烁 1.3s 对齐;本页无图标,故轨道就挂在既有的 `.wk-ring` 上)。 */
.wk-orb{position:relative;width:104px;height:104px;margin:2px auto 16px}
.wk-halo{position:absolute;inset:-18px;border-radius:50%;
background:radial-gradient(circle, rgba(83,74,183,.20), rgba(139,124,246,.12) 46%, transparent 70%);
animation:wk-halo 2.6s ease-in-out infinite}
.wk-ring{position:absolute;inset:18px;border-radius:50%;border:1px solid rgba(83,74,183,.22)}
.wk-core{position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;
background:radial-gradient(circle, #ffffff 0%, #6B5FD6 46%, rgba(83,74,183,0) 74%);
box-shadow:0 0 12px rgba(83,74,183,.55), 0 0 30px rgba(139,124,246,.35);
animation:wk-core 1.6s ease-in-out infinite}
/* 绕环巡游的亮点:轨道 = 与 .wk-ring 同尺寸,故点恒压在环线上跑(不跑到环外)。 */
.wk-orbit{position:absolute;inset:18px;animation:wk-spin 2.6s linear infinite}
.wk-orbit i{position:absolute;left:50%;top:-3px;width:6px;height:6px;margin-left:-3px;border-radius:50%;
background:radial-gradient(circle, #ffffff 0%, #8B7CF6 46%, rgba(139,124,246,0) 78%);
box-shadow:0 0 8px rgba(139,124,246,.92), 0 0 18px rgba(83,74,183,.5);
animation:wk-blink 1.3s ease-in-out infinite}
@keyframes wk-spin{to{transform:rotate(360deg)}}
@keyframes wk-core{0%,100%{transform:scale(.82);opacity:.6}50%{transform:scale(1.12);opacity:1}}
@keyframes wk-halo{0%,100%{opacity:.45;transform:scale(.96)}50%{opacity:.9;transform:scale(1.04)}}
@keyframes wk-blink{0%,100%{opacity:.22;transform:scale(.78)}50%{opacity:1;transform:scale(1.18)}}
@keyframes wkb{0%{margin-left:-40%}100%{margin-left:100%}}
.wk-bar{height:4px;border-radius:999px;background:rgba(127,127,127,.18);overflow:hidden;margin:16px 0 8px}
.wk-bar > i{display:block;height:100%;width:40%;border-radius:999px;
background:linear-gradient(90deg, rgba(83,74,183,0), #534AB7 45%, #8B7CF6 70%, rgba(83,74,183,0));
animation:wkb 1.6s ease-in-out infinite}
.wk-label{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;
color:#8a8f98;text-transform:uppercase;margin-bottom:10px}
.wk-step{font-size:13px;color:#8a8f98;min-height:20px;text-align:center}
.wk-acts{margin-top:16px;text-align:center}
.wk-note{margin-top:14px;font-size:12px;line-height:1.7;color:#8a8f98;text-align:center}
@media (prefers-reduced-motion: reduce){
.wk-orbit,.wk-orbit i,.wk-core,.wk-halo,.wk-bar > i{animation:none!important}
}
</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px">
<div class="card" style="max-width:420px;width:100%;text-align:center">
<div class="wk-orb" id="spin">
<div class="wk-halo"></div>
<div class="wk-ring"></div><div class="wk-core"></div>
<div class="wk-orbit"><i></i></div>
</div>
<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" 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" data-i18n="common.retry">Retry</button>
</div>
<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>
<script>
(function () {
var NEXT = new URLSearchParams(location.search).get('next') || ''
var step = document.getElementById('step')
var acts = document.getElementById('acts')
var note = document.getElementById('note')
var tried = 0
var MAX = 3
function show(text) { step.textContent = text }
function fail(msg) {
document.getElementById('spin').style.display = 'none'
acts.style.display = 'block'
note.textContent = msg
show(I18N.t('wake.failed'))
}
// 只允许跳回**本站注册域**(运行时从 hostname 推导,⛔ 不写死域名)。
// 规则:去掉最左一段即到注册域 —— `guest.example.com` ⇒ `example.com`。
function registrableDomain() {
var h = location.hostname
if (!h || h === 'localhost' || /^\d+(\.\d+){3}$/.test(h)) return h
var parts = h.split('.')
return parts.length > 2 ? parts.slice(1).join('.') : h
}
function safeNext(u) {
try {
var x = new URL(u, location.href)
var d = registrableDomain()
if (!d) return ''
var hop = '.' + d
return x.hostname === d || x.hostname.slice(-hop.length) === hop ? x.href : ''
} catch (e) { return '' }
}
async function wake() {
tried += 1
show(tried === 1 ? I18N.t('wake.launching') : I18N.t('wake.retrying', { n: tried }))
try {
// 复用平台已有的登录直达接口:它内部会 launch(实例不在则拉起)并返回带 token 的直达 URL
var res = await fetch('/api/dsh/enter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
})
if (res.status === 401) { location.replace('/login.html'); return }
var j = await res.json().catch(function () { return {} })
var url = j.url || j.redirect || ''
if (res.ok && url) {
show(I18N.t('wake.ready'))
location.replace(url)
return
}
if (res.ok && !url) { // 已登录但需要重走门户
location.replace('/portal.html')
return
}
if (j.error === 'instance_circuit_open') { // 档案 78:被熔断冷却,重试没有意义
var waitMin = Math.max(1, Math.ceil((j.retryAfterMs || 0) / 60000))
fail(I18N.t('wake.circuit', { min: waitMin }))
return
}
throw new Error(j.error || ('HTTP ' + res.status))
} catch (err) {
if (tried < MAX) {
show(I18N.t('wake.notReady'))
setTimeout(wake, 2000)
} else {
fail(I18N.t('wake.giveUp', { msg: (err && err.message ? err.message : I18N.t('wake.unknownErr')) }))
}
}
}
document.getElementById('retry').addEventListener('click', function () {
tried = 0
document.getElementById('spin').style.display = ''
acts.style.display = 'none'
note.textContent = I18N.t('wake.noteShort')
wake()
})
// 若 nginx 带了 next(原始目标地址),唤醒后优先回那里
var saved = safeNext(NEXT)
if (saved) { sessionStorage.setItem('dsh.wake.next', saved) }
var t = setInterval(function () {
var n = sessionStorage.getItem('dsh.wake.next')
if (n && n !== NEXT) {
clearInterval(t)
// 直接以原始目标为跳转终点(进入后再由实例侧校验 token)
NEXT = n
}
}, 1000)
setTimeout(function () { clearInterval(t) }, 30000)
wake()
})()
</script>
</body>
</html>