feat: pair a new device from settings with a code or QR code

This commit is contained in:
DiheChen committed 2026-11-10 12:59:53 +08:00
1 parent 63279125a5
commit dd18ea7e2c
13 files changed
+239 -5

No files matched your search

+4
View File
@@ -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;
+9 -1
View File
@@ -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 <MyPlaylistPage id={route.id} onBack={() => navigationStore.open('playlists')} />
}
function RoutedPair() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'pair')
return null
return <PairPage code={route.code} onSignedIn={() => 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' && <RoutedMyPlaylist />}
{page === 'settings' && <SettingsPage />}
{page === 'shared-playlist' && <RoutedSharedPlaylist />}
{page === 'pair' && <RoutedPair />}
</Suspense>
</ErrorBoundary>
</main>
+2 -1
View File
@@ -45,7 +45,8 @@ class NavigationStore extends Store<NavigationState> {
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)
}
+7
View File
@@ -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', () => {
+7 -1
View File
@@ -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<Location, 'pathname' | 'search'>): Route | null {
export function parseRoute({ pathname, search, hash }: Pick<Location, 'pathname' | 'search' | 'hash'>): Route | null {
const params = new URLSearchParams(search)
const panel = parsePanel(params)
if (pathname === '/')
@@ -73,6 +75,8 @@ export function parseRoute({ pathname, search }: Pick<Location, 'pathname' | 'se
return { name: 'history', filter: params.get('q') ?? '', panel }
if (pathname === '/playlists')
return { name: 'playlists', panel }
if (pathname === '/pair')
return { name: 'pair', code: hash.slice(1), panel }
const own = /^\/playlists\/([^/]+)$/.exec(pathname)
if (own)
return { name: 'my-playlist', id: own[1], panel }
@@ -112,6 +116,8 @@ function pathOf(route: Route): string {
return `/${route.name}`
case 'my-playlist':
return `/playlists/${route.id}`
case 'pair':
return '/pair'
case 'listen':
return `/r/${route.ref}`
case 'playlist':
+10 -1
View File
@@ -4,10 +4,11 @@ import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { Modal, notificationStore } from '@/shared/ui'
import { PairingCodeForm, PairingDialog } from './pairing-dialog'
import { loginWithPassword } from './password-login'
import { profileStore, switchIdentity } from './profile-store'
type Dialog = 'register' | 'login' | 'password' | null
type Dialog = 'register' | 'login' | 'password' | 'pairing' | 'redeem' | null
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3–32 位小写字母、数字、_ 或 -,密码要 8–128 位。' }
@@ -52,6 +53,7 @@ export function AccountRow() {
{account
? (
<>
<button type="button" className="button-secondary" onClick={() => setDialog('pairing')}>在新设备上登录</button>
<button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
</>
@@ -60,12 +62,19 @@ export function AccountRow() {
<>
{registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
{dialog === 'pairing' && <PairingDialog onClose={() => setDialog(null)} />}
{dialog === 'redeem' && (
<Modal title="输入配对码" onClose={() => setDialog(null)}>
<PairingCodeForm onSignedIn={switchIdentity} />
</Modal>
)}
</div>
)
}
+1
View File
@@ -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))
+28
View File
@@ -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 (
<div className="page-content settings-pair-page">
<div className="page-title">
<h1 className="text-ui-xl">用配对码登录</h1>
</div>
{account
? <p className="text-ui-base muted">这台设备已经登录了账号。要换成其他账号,先在设置里退出登录。</p>
: (
<PairingCodeForm
initialCode={code}
onSignedIn={(memberToken) => {
onSignedIn()
switchIdentity(memberToken)
}}
/>
)}
</div>
)
}
@@ -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<CreatePairingCodeResponse | 'loading' | 'failed'>('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 (
<Modal title="在新设备上登录" onClose={onClose}>
<div className="settings-pairing">
{pairing === 'loading' && <p className="text-ui-base muted">正在生成配对码…</p>}
{pairing === 'failed' && <p className="text-ui-base muted">暂时无法生成配对码。</p>}
{typeof pairing === 'object' && seconds > 0 && (
<>
<img className="settings-pairing-qr" src={pairing.qrImage} alt="配对二维码" />
{/* 读屏软件逐个字符读出配对码,不把可见的 XXXX-XXXX 当作单词来读。 */}
<p className="sr-only">{`配对码 ${pairing.code.split('').join(' ')}`}</p>
<p className="settings-pairing-code text-ui-xl" aria-hidden="true">{formatPairingCode(pairing.code)}</p>
<p className="text-ui-caption muted">在新设备上扫描二维码,或者打开设置里的“输入配对码”。配对码只能用一次。</p>
<span className="text-ui-sm muted" role="timer">{`${countdownText(seconds)} 后失效`}</span>
</>
)}
{typeof pairing === 'object' && seconds === 0 && <p className="text-ui-base muted" role="status">配对码已经失效。</p>}
{(pairing === 'failed' || (typeof pairing === 'object' && seconds === 0)) && (
<button type="button" className="button-primary" onClick={retry}>重新生成</button>
)}
</div>
</Modal>
)
}
// 兑换配对码就是登录:成功时把新会话的成员 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 (
<form
className="form-stack"
onSubmit={(event) => {
// 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单。
event.preventDefault()
event.stopPropagation()
void submit()
}}
>
<label>
配对码
<input data-autofocus required autoComplete="one-time-code" autoCapitalize="characters" spellCheck={false} maxLength={16} value={input} onChange={event => setInput(event.target.value)} />
</label>
<p className="text-ui-caption muted">在已登录的设备上打开设置,点“在新设备上登录”就能看到配对码。只输入你自己生成的配对码:登录之后,这台设备上的操作都属于那个账号。</p>
<button type="submit" className="button-primary" disabled={busy}>{busy ? '正在登录…' : '登录'}</button>
</form>
)
}
+38
View File
@@ -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')
})
+29
View File
@@ -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')}`
}
+4
View File
@@ -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; }
+1 -1
View File
@@ -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'],
},
}),