216 lines
11 KiB
HTML
216 lines
11 KiB
HTML
<!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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|