feat: pair a new device from settings with a code or QR code
This commit is contained in:
1 parent
c1d5bb2de0
commit
2ae286dcb8
13 files changed
+239
-5
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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
@@ -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'],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user