feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)

三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
This commit is contained in:
admin committed 2026-09-19 09:11:24 +08:00
1 parent d2ef362a98
commit 971ccc3703
56 files changed
+3498 -193

No files matched your search

+2 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理台</title>
<link rel="icon" href="/logo.svg" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" />
</head>
<body class="auth-bg">
@@ -26,8 +26,7 @@
<div class="auth-card" style="max-width: 720px">
<div class="auth-brand">
<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>
<span class="wordmark">能力枢纽</span>
</div>
<p class="auth-sub" id="sub">仅限管理员</p>
+5 -1
View File
@@ -49,7 +49,11 @@ body {
.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; }
.auth-brand .wordmark { height: 22px; color: var(--ink); }
/* 2026-09-19:登录 / 注册页的品牌标识由 **DeepSeek 图形**改为**本平台文字**
(中文「能力枢纽」/ 英文及其他语言 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; }
+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

+58
View File
@@ -37,6 +37,11 @@
'common.hidePw': 'Hide password',
'common.retry': 'Retry',
// 品牌名(2026-09-19):登录 / 注册页的品牌标识由 DeepSeek 图形改为**本平台文字标识**。
// ⚠️ 走词条而不是写死在 HTML 里:英文/其他语言 = CapabilityNet(en 也是**其他语言的回退**,
// `lookup()` 缺 key 时正是回退到 en),中文 = 能力枢纽。
'brand.name': 'CapabilityNet',
'index.title': 'Workspace',
'login.title': 'Sign in',
@@ -67,6 +72,31 @@
'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…',
@@ -92,6 +122,9 @@
'common.hidePw': '隐藏密码',
'common.retry': '重试',
// 品牌名(2026-09-19):中文界面显示中文名;其余语言由 en 词条(CapabilityNet)兜底。
'brand.name': '能力枢纽',
'index.title': '工作台',
'login.title': '登录',
@@ -122,6 +155,31 @@
'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': '正在启动你的工作区…',
+2 -3
View File
@@ -4,7 +4,7 @@
<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="/logo.svg" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(词条表 + data-i18n 就地替换;默认英语) -->
@@ -28,8 +28,7 @@
<body class="auth-bg">
<div class="auth-card">
<div class="auth-brand">
<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>
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
</div>
<p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理门户</title>
<link rel="icon" href="/logo.svg" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" />
<style>
/* 06-工作台UI规范 Token:亮色工具后台 + 蓝主色 + 橙强调 */
@@ -125,7 +125,7 @@
</head>
<body>
<header class="topbar">
<div class="brand"><img class="logo" src="/logo.svg" alt="logo" /><span>管理门户</span></div>
<div class="brand"><img class="logo" src="/favicon.svg" alt="" /><span>管理门户</span></div>
<nav class="crumbs" id="crumbs"></nav>
<div class="top-actions">
<span class="who" id="who"></span>
+252 -19
View File
@@ -4,7 +4,7 @@
<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="/logo.svg" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
@@ -23,13 +23,21 @@
.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">
<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>
<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>
@@ -46,6 +54,24 @@
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>
@@ -90,27 +116,234 @@
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()
msg.textContent = ''
msg.className = 'msg err'
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'))
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
msg.textContent = body.error === 'username_taken' ? I18N.t('register.errTaken') : body.message || I18N.t('register.errFailed')
return
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'))
}
msg.className = 'msg ok'
msg.textContent = I18N.t('register.ok')
setBusy(false)
form.querySelector('button[type=submit]').disabled = true
form.reset()
})
boot()
</script>
</body>
</html>
+2 -2
View File
@@ -89,9 +89,9 @@
show(I18N.t('wake.failed'))
}
// 只允许跳回 *.alotbuy.com,避免被 next 参数带偏
// 只允许跳回 *.ai1net.com,避免被 next 参数带偏
function safeNext(u) {
try { var x = new URL(u, location.href); return /(^|\.)alotbuy\.com$/.test(x.hostname) ? x.href : '' } catch (e) { return '' }
try { var x = new URL(u, location.href); return /(^|\.)ai1net\.com$/.test(x.hostname) ? x.href : '' } catch (e) { return '' }
}
async function wake() {