Files
dsh_shenxian/web/pair.html
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

217 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>