From 2ae286dcb8227a79e52a32f5a761c711ae5dc651 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:16:07 +0800 Subject: [PATCH] feat: pair a new device from settings with a code or QR code --- web/nginx/default.conf | 4 + web/src/app/app.tsx | 10 +- web/src/app/navigation.ts | 3 +- web/src/app/route.test.ts | 7 ++ web/src/app/route.ts | 8 +- web/src/features/settings/account-section.tsx | 11 ++- web/src/features/settings/index.ts | 1 + web/src/features/settings/pair-page.tsx | 28 ++++++ web/src/features/settings/pairing-dialog.tsx | 99 +++++++++++++++++++ web/src/features/settings/pairing.test.ts | 38 +++++++ web/src/features/settings/pairing.ts | 29 ++++++ web/src/features/settings/settings.css | 4 + web/vite.config.ts | 2 +- 13 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 web/src/features/settings/pair-page.tsx create mode 100644 web/src/features/settings/pairing-dialog.tsx create mode 100644 web/src/features/settings/pairing.test.ts create mode 100644 web/src/features/settings/pairing.ts diff --git a/web/nginx/default.conf b/web/nginx/default.conf index 79b70d3..d1200f2 100644 --- a/web/nginx/default.conf +++ b/web/nginx/default.conf @@ -34,6 +34,10 @@ server { add_header Cache-Control "no-cache"; try_files /index.html =404; } + location ~ ^/pair$ { + add_header Cache-Control "no-cache"; + try_files /index.html =404; + } location ~ ^/.+/$ { return 404; diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx index 0ff4172..239f9c4 100644 --- a/web/src/app/app.tsx +++ b/web/src/app/app.tsx @@ -12,7 +12,7 @@ import { MyPlaylistPage, PlaylistsPage, SharedPlaylistPage } from '@/features/pl import { QueuePage, voteSkip } from '@/features/queue' import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms' import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search' -import { profileStore, SettingsPage } from '@/features/settings' +import { PairPage, profileStore, SettingsPage } from '@/features/settings' import { useStore } from '@/shared/lib/use-store' import { Avatar, IconButton, lazyPage, Notifications, notificationStore, Skeleton } from '@/shared/ui' import { DetailPanel } from './detail-panel' @@ -202,6 +202,13 @@ function RoutedMyPlaylist() { return navigationStore.open('playlists')} /> } +function RoutedPair() { + const route = useStore(navigationStore, s => s.route) + if (route.name !== 'pair') + return null + return navigationStore.replace({ name: 'lobby', filter: '', panel: null })} /> +} + function RoutedSearch() { const route = useStore(navigationStore, s => s.route) if (route.name !== 'search') @@ -289,6 +296,7 @@ export function App() { {page === 'my-playlist' && } {page === 'settings' && } {page === 'shared-playlist' && } + {page === 'pair' && } diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index ce726dd..e3352d9 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -45,7 +45,8 @@ class NavigationStore extends Store { route = { name: 'listen', ref: lastRoom, panel: null } } const url = formatRoute(route) - if (url !== window.location.pathname + window.location.search) + // 片段只用来带进配对码这类一次性的值,页面读到之后就从地址里去掉。 + if (url !== window.location.pathname + window.location.search + window.location.hash) writeEntry(url, true) this.#show(route) } diff --git a/web/src/app/route.test.ts b/web/src/app/route.test.ts index 9bdd2a5..877f275 100644 --- a/web/src/app/route.test.ts +++ b/web/src/app/route.test.ts @@ -54,6 +54,13 @@ describe('parseRoute', () => { it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => { expect(parse(url)).toBeNull() }) + + it('配对码从片段中读出,写回的地址不再带它', () => { + const route = parse('/pair?panel=chat#ABCD2345') + expect(route).toEqual({ name: 'pair', code: 'ABCD2345', panel: 'chat' }) + expect(route && formatRoute(route)).toBe('/pair?panel=chat') + expect(parse('/pair')).toEqual({ name: 'pair', code: '', panel: null }) + }) }) describe('isNewEntry', () => { diff --git a/web/src/app/route.ts b/web/src/app/route.ts index a4dc6a1..a468b95 100644 --- a/web/src/app/route.ts +++ b/web/src/app/route.ts @@ -18,6 +18,8 @@ export type Route | { name: 'search', ref: string, panel: Panel | null, query: SearchQuery } | { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number } | { name: 'shared-playlist', shareId: string, panel: Panel | null } + // 配对码在片段里,不进入请求和 Referer;写回地址时去掉,不留在历史记录中。 + | { name: 'pair', code: string, panel: Panel | null } export type Page = Route['name'] @@ -60,7 +62,7 @@ function writeQuery(query: SearchQuery, params: URLSearchParams): void { params.set('page', String(query.page)) } -export function parseRoute({ pathname, search }: Pick): Route | null { +export function parseRoute({ pathname, search, hash }: Pick): Route | null { const params = new URLSearchParams(search) const panel = parsePanel(params) if (pathname === '/') @@ -73,6 +75,8 @@ export function parseRoute({ pathname, search }: Pick + @@ -60,12 +62,19 @@ export function AccountRow() { <> {registrationOpen && } + )} )} {(dialog === 'register' || dialog === 'login') && setDialog(null)} />} {dialog === 'password' && setDialog(null)} />} + {dialog === 'pairing' && setDialog(null)} />} + {dialog === 'redeem' && ( + setDialog(null)}> + + + )} ) } diff --git a/web/src/features/settings/index.ts b/web/src/features/settings/index.ts index 8fa9fd2..5207123 100644 --- a/web/src/features/settings/index.ts +++ b/web/src/features/settings/index.ts @@ -4,3 +4,4 @@ export { appearanceStore } from './appearance-store' export { performanceStore } from './performance-store' export { profileStore } from './profile-store' export const SettingsPage = lazyPage(() => import('./settings-page').then(module => module.SettingsPage)) +export const PairPage = lazyPage(() => import('./pair-page').then(module => module.PairPage)) diff --git a/web/src/features/settings/pair-page.tsx b/web/src/features/settings/pair-page.tsx new file mode 100644 index 0000000..0372a4c --- /dev/null +++ b/web/src/features/settings/pair-page.tsx @@ -0,0 +1,28 @@ +import { useStore } from '@/shared/lib/use-store' +import { PairingCodeForm } from './pairing-dialog' +import { profileStore, switchIdentity } from './profile-store' +import './settings.css' + +// 扫描配对二维码打开的页面。要用户点一下才兑换:打开链接就自动登录的话,别人发来的链接能把你登进他的账号。 +// onSignedIn 在换身份、重新加载页面之前调用,由应用壳决定登录之后停在哪里。 +export function PairPage({ code, onSignedIn }: { code: string, onSignedIn: () => void }) { + const account = useStore(profileStore, s => s.account) + return ( +
+
+

用配对码登录

+
+ {account + ?

这台设备已经登录了账号。要换成其他账号,先在设置里退出登录。

+ : ( + { + onSignedIn() + switchIdentity(memberToken) + }} + /> + )} +
+ ) +} diff --git a/web/src/features/settings/pairing-dialog.tsx b/web/src/features/settings/pairing-dialog.tsx new file mode 100644 index 0000000..242ae13 --- /dev/null +++ b/web/src/features/settings/pairing-dialog.tsx @@ -0,0 +1,99 @@ +import type { CreatePairingCodeResponse } from '@/core/api' +import { useEffect, useState } from 'react' +import { api, IdentityRenewedError } from '@/core/api' +import { errorMessage } from '@/core/errors' +import { Modal, notificationStore } from '@/shared/ui' +import { countdownText, formatPairingCode, isPairingCode, normalizePairingCode, secondsLeft } from './pairing' + +// 已登录的设备生成配对码,新设备输入它或者扫描二维码登录同一个账号。 +export function PairingDialog({ onClose }: { onClose: () => void }) { + const [pairing, setPairing] = useState('loading') + const [attempt, setAttempt] = useState(0) + const [nowMs, setNowMs] = useState(Date.now) + useEffect(() => { + let active = true + api.createPairingCode().then((created) => { + if (!active) + return + setPairing(created) + setNowMs(Date.now()) + }, (error: unknown) => { + if (!active) + return + setPairing('failed') + notificationStore.error(error instanceof IdentityRenewedError ? '没有生成配对码,再试一次。' : errorMessage(error)) + }) + const tick = setInterval(() => setNowMs(Date.now()), 1000) + return () => { + active = false + clearInterval(tick) + } + }, [attempt]) + const retry = () => { + setPairing('loading') + setAttempt(n => n + 1) + } + const seconds = typeof pairing === 'object' ? secondsLeft(pairing.expiresAtMs, nowMs) : 0 + return ( + +
+ {pairing === 'loading' &&

正在生成配对码…

} + {pairing === 'failed' &&

暂时无法生成配对码。

} + {typeof pairing === 'object' && seconds > 0 && ( + <> + 配对二维码 + {/* 读屏软件逐个字符读出配对码,不把可见的 XXXX-XXXX 当作单词来读。 */} +

{`配对码 ${pairing.code.split('').join(' ')}`}

+ +

在新设备上扫描二维码,或者打开设置里的“输入配对码”。配对码只能用一次。

+ {`${countdownText(seconds)} 后失效`} + + )} + {typeof pairing === 'object' && seconds === 0 &&

配对码已经失效。

} + {(pairing === 'failed' || (typeof pairing === 'object' && seconds === 0)) && ( + + )} +
+
+ ) +} + +// 兑换配对码就是登录:成功时把新会话的成员 token 交给调用方,由它换身份。 +export function PairingCodeForm({ initialCode = '', onSignedIn }: { initialCode?: string, onSignedIn: (memberToken: string) => void }) { + const [input, setInput] = useState(() => normalizePairingCode(initialCode)) + const [busy, setBusy] = useState(false) + const code = normalizePairingCode(input) + const submit = async () => { + if (!isPairingCode(code)) { + notificationStore.error('配对码是 8 位字母和数字,不含 0、O、1、I。') + return + } + setBusy(true) + try { + const { memberToken } = await api.redeemPairingCode({ code }) + onSignedIn(memberToken) + } + catch (error) { + notificationStore.error(error instanceof IdentityRenewedError ? '没有登录,再试一次。' : errorMessage(error, { INVALID_ARGUMENT: '配对码是 8 位字母和数字,不含 0、O、1、I。' })) + setBusy(false) + } + } + return ( +
{ + // 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单。 + event.preventDefault() + event.stopPropagation() + void submit() + }} + > + +

在已登录的设备上打开设置,点“在新设备上登录”就能看到配对码。只输入你自己生成的配对码:登录之后,这台设备上的操作都属于那个账号。

+ +
+ ) +} diff --git a/web/src/features/settings/pairing.test.ts b/web/src/features/settings/pairing.test.ts new file mode 100644 index 0000000..63f4148 --- /dev/null +++ b/web/src/features/settings/pairing.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { countdownText, formatPairingCode, isPairingCode, normalizePairingCode, secondsLeft } from './pairing' + +describe('normalizePairingCode', () => { + it.each([ + ['abcd2345', 'ABCD2345'], + [' ABCD-2345 ', 'ABCD2345'], + ['abcd 2345', 'ABCD2345'], + ['ABCD%2D2345', 'ABCD2345'], + ['%E0%A4%A', '%E0%A4%A'], + ])('%s → %s', (input, code) => { + expect(normalizePairingCode(input)).toBe(code) + }) +}) + +describe('isPairingCode', () => { + it.each(['ABCD2345', 'ZZZZ9999', '23456789'])('%s 是配对码', (code) => { + expect(isPairingCode(code)).toBe(true) + }) + + it.each(['ABCD234', 'ABCD23456', 'ABCD2340', 'ABCD2341', 'ABCO2345', 'ABCI2345', 'abcd2345', ''])('%s 不是配对码', (code) => { + expect(isPairingCode(code)).toBe(false) + }) +}) + +it('配对码分成两组显示', () => { + expect(formatPairingCode('ABCD2345')).toBe('ABCD-2345') +}) + +it('倒计时', () => { + expect(secondsLeft(10_000, 0)).toBe(10) + expect(secondsLeft(10_000, 9_001)).toBe(1) + expect(secondsLeft(10_000, 10_000)).toBe(0) + expect(secondsLeft(10_000, 20_000)).toBe(0) + expect(countdownText(300)).toBe('5:00') + expect(countdownText(65)).toBe('1:05') + expect(countdownText(0)).toBe('0:00') +}) diff --git a/web/src/features/settings/pairing.ts b/web/src/features/settings/pairing.ts new file mode 100644 index 0000000..8f01e11 --- /dev/null +++ b/web/src/features/settings/pairing.ts @@ -0,0 +1,29 @@ +// 配对码由数字 2–9 和除 I、O 以外的大写字母组成,与协议中的 PairingCode 一致。 +const pairingCodePattern = /^[2-9A-HJ-NP-Z]{8}$/ + +// 手动输入时常带空格、连字符或小写字母,片段里的配对码也可能被转义过。 +export function normalizePairingCode(input: string): string { + let text = input + try { + text = decodeURIComponent(input) + } + catch {} + return text.toUpperCase().replace(/[\s-]/g, '') +} + +export function isPairingCode(code: string): boolean { + return pairingCodePattern.test(code) +} + +// 分成两组显示,照着输入时不容易看串。 +export function formatPairingCode(code: string): string { + return `${code.slice(0, 4)}-${code.slice(4)}` +} + +export function secondsLeft(expiresAtMs: number, nowMs: number): number { + return Math.max(0, Math.ceil((expiresAtMs - nowMs) / 1000)) +} + +export function countdownText(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` +} diff --git a/web/src/features/settings/settings.css b/web/src/features/settings/settings.css index e811fb6..4f8753b 100644 --- a/web/src/features/settings/settings.css +++ b/web/src/features/settings/settings.css @@ -34,6 +34,10 @@ .settings-save-status { flex: 1; min-width: 0; overflow-wrap: anywhere; } .settings-page-footer .button-primary { min-width: 96px; } .settings-account-actions { flex-wrap: wrap; gap: 8px; } +.settings-pairing { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; } +.settings-pairing-qr { width: 200px; height: 200px; padding: 8px; border-radius: 8px; background: #fff; image-rendering: pixelated; } +.settings-pairing-code { font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: .12em; user-select: all; } +.settings-pair-page { max-width: 480px; } @container settings (max-width: 640px) { .settings-page-header { padding: 20px 20px 16px; } .settings-page-body { padding: 12px 16px 32px; } diff --git a/web/vite.config.ts b/web/vite.config.ts index 85687c5..03a75ca 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -62,7 +62,7 @@ export default defineConfig({ workbox: { // 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。 // Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。 - navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/], + navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/, /^\/s\/[^/?]+(?:\?.*)?$/, /^\/pair(?:\?.*)?$/], importScripts: ['notification-click.js'], }, }),