feat: organize settings into sections that apply changes immediately

This commit is contained in:
DiheChen committed 2026-11-11 11:42:08 +08:00
1 parent 87a7b37730
commit 052e293176
27 files changed
+1282 -572

No files matched your search

+8 -2
View File
@@ -193,7 +193,13 @@ function RoutedSettings() {
const route = useStore(navigationStore, s => s.route)
if (route.name !== 'settings')
return null
return <SettingsPage section={route.section} />
return (
<SettingsPage
section={route.section}
onSection={section => section === null ? navigationStore.up({ ...route, section }) : navigationStore.navigate({ ...route, section })}
onOpenHistory={() => navigationStore.open('history')}
/>
)
}
function RoutedAdmin({ onEntered }: { onEntered: (ref: string) => void }) {
@@ -327,7 +333,7 @@ export function App() {
{page === 'playlists' && (
<PlaylistsPage
onOpen={playlist => navigationStore.navigate({ name: 'my-playlist', id: playlist.id, panel: navigationStore.state.route.panel }, playlist.name)}
onRegister={() => navigationStore.open('settings')}
onRegister={() => navigationStore.navigate({ name: 'settings', section: 'account', panel: navigationStore.state.route.panel })}
/>
)}
{page === 'my-playlist' && <RoutedMyPlaylist />}
+1 -1
View File
@@ -28,7 +28,7 @@ volumeStore.start()
brandStore.start()
profileStore.start()
playlistsStore.start()
setAccountSettingsOpener(() => navigationStore.open('settings'))
setAccountSettingsOpener(() => navigationStore.navigate({ name: 'settings', section: 'account', panel: navigationStore.state.route.panel }))
playerStore.start()
reminderStore.start()
historyStore.start()
+1 -1
View File
@@ -2,5 +2,5 @@ export { AudioEngine } from './audio-engine'
export { ManualStartPrompt } from './manual-start-prompt'
export { PlayerBar } from './player-bar'
export { playerStore } from './player-store'
export { reminderStore, requestNotificationPermission, systemNotificationsSupported } from './reminder-store'
export { reminderStore, systemNotificationsSupported } from './reminder-store'
export { volumeStore } from './volume-store'
@@ -19,11 +19,6 @@ export function systemNotificationsSupported(): boolean {
return 'Notification' in window && 'serviceWorker' in navigator
}
// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。
export async function requestNotificationPermission(): Promise<boolean> {
return await Notification.requestPermission() === 'granted'
}
class ReminderStore extends Store<ReminderSettings> {
#room: RoomState | null = null
#firstSeen = new Map<string, number>()
@@ -1,5 +1,6 @@
import type { MyPlaylist } from '@/core/realtime'
import { useState } from 'react'
import { isImeKey } from '@/shared/lib/ime'
import { playlistsStore } from './playlists-store'
export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playlist: MyPlaylist) => void, onCancel: () => void }) {
@@ -9,7 +10,7 @@ export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playli
<form
className="form-stack"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing)
if (event.key !== 'Escape' || isImeKey(event.nativeEvent))
return
// 拦下对话框的 Esc:在“保存到歌单”里,Esc 只回到列表。
event.preventDefault()
@@ -1,156 +1,21 @@
import type { FormEvent } from 'react'
import type { Account, GitHubIntent } from '@/core/realtime'
import { useEffect, useId, useState } from 'react'
import { useId, useState } from 'react'
import { api, ApiError, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { Modal, notificationStore } from '@/shared/ui'
import { startGithubLogin, takeCredentialsPrompt } from './github'
import { PairingCodeForm, PairingDialog } from './pairing-dialog'
import { loginWithPassword } from './password-login'
import { profileStore, switchIdentity } from './profile-store'
// credentials:只绑定了 GitHub 的账号补设用户名和密码,用的是注册接口。
type Dialog = 'register' | 'login' | 'credentials' | 'password' | 'pairing' | 'redeem' | null
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3–32 位小写字母、数字、_ 或 -,密码要 8–128 位。' }
// 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单,提交时要拦住,否则会顺带保存整页设置。
function submitted(event: FormEvent, run: () => Promise<void>): void {
event.preventDefault()
event.stopPropagation()
void run()
}
// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise<boolean> {
try {
window.location.assign(await startGithubLogin(intent, currentPassword))
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有跳转到 GitHub,再试一次。' : errorMessage(error))
return false
}
}
export function AccountRow() {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const failed = useStore(profileStore, s => s.failed)
const [site, setSite] = useState({ registrationOpen: false, githubLogin: false })
const [dialog, setDialog] = useState<Dialog>(() => takeCredentialsPrompt() ? 'credentials' : null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
api.getSite().then(({ registrationOpen, githubLogin }) => {
if (active)
setSite({ registrationOpen, githubLogin })
}, () => {})
return () => {
active = false
}
}, [])
const logout = async () => {
setBusy(true)
if (!await profileStore.logout())
setBusy(false)
}
const github = async (intent: GitHubIntent) => {
setBusy(true)
if (!await goToGitHub(intent))
setBusy(false)
}
const description = member ? null : failed ? '暂时无法获取你的资料。' : '正在获取你的资料…'
return (
<>
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">{account ? account.username ?? '用 GitHub 登录的账号' : '游客'}</p>
{description && <p className="text-ui-caption muted">{description}</p>}
</div>
{member && (
<div className="settings-row-control settings-account-actions">
{account
? (
<>
<button type="button" className="button-secondary" onClick={() => setDialog('pairing')}>在新设备上登录</button>
{account.username
? <button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
: <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('verify')}>设置用户名和密码</button>}
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
</>
)
: (
<>
{site.registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
{site.githubLogin && <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('login')}>用 GitHub 登录</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && <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>
{account && site.githubLogin && <GitHubRow account={account} />}
</>
)
}
function GitHubRow({ account }: { account: Account }) {
const [dialog, setDialog] = useState<'bind' | 'unbind' | null>(null)
const unbind = async (currentPassword: string): Promise<boolean> => {
try {
await api.unbindGithub({ currentPassword })
profileStore.updateAccount({ ...account, github: false })
notificationStore.success('已解绑 GitHub')
setDialog(null)
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有解绑,再试一次。' : errorMessage(error))
return false
}
}
const description = !account.github
? '绑定之后,可以用 GitHub 登录这个账号。'
: account.username
? '已绑定,可以用 GitHub 登录这个账号。'
: '已绑定。先设置用户名和密码,才能解绑。'
return (
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">GitHub</p>
<p className="text-ui-caption muted">{description}</p>
</div>
<div className="settings-row-control settings-account-actions">
{account.github
? <button type="button" className="button-secondary" disabled={!account.username} onClick={() => setDialog('unbind')}>解绑</button>
: <button type="button" className="button-secondary" onClick={() => setDialog('bind')}>绑定 GitHub</button>}
</div>
{dialog && (
<CurrentPasswordDialog
title={dialog === 'bind' ? '绑定 GitHub' : '解绑 GitHub'}
action={dialog === 'bind' ? '去 GitHub 授权' : '解绑'}
onConfirm={dialog === 'bind' ? currentPassword => goToGitHub('bind', currentPassword) : unbind}
onClose={() => setDialog(null)}
/>
)}
</div>
)
}
// 绑定和解绑 GitHub 都会更换登录方式,先输入当前密码:只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示;
// 绑定成功时页面随即跳到 GitHub,对话框保持忙碌。
function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise<boolean>, onClose: () => void }) {
export function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise<boolean>, onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
@@ -171,7 +36,8 @@ function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: s
)
}
function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'credentials', onClose: () => void }) {
// credentials:只绑定了 GitHub 的账号补设用户名和密码,用的是注册接口。
export function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'credentials', onClose: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [repeated, setRepeated] = useState('')
@@ -193,7 +59,7 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'cr
catch (error) {
// 用 GitHub 确认身份已经过期:在对话框里重试也没用,关掉它,从按钮重新确认。
if (mode === 'credentials' && error instanceof ApiError && error.code === 'FORBIDDEN') {
notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请再点一次“设置用户名和密码”。')
notificationStore.error('用 GitHub 确认身份已经超过 10 分钟,请在“用户名和密码”一行再点一次“设置”。')
onClose()
return
}
@@ -227,7 +93,7 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login' | 'cr
)
}
function PasswordDialog({ onClose }: { onClose: () => void }) {
export function PasswordDialog({ onClose }: { onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [repeated, setRepeated] = useState('')
+15 -1
View File
@@ -1,6 +1,8 @@
import type { FinishGithubLoginResponse } from '@/core/api'
import type { GitHubIntent } from '@/core/realtime'
import { api } from '@/core/api'
import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { notificationStore } from '@/shared/ui'
// GitHub 跳回来时仍在发起授权的这个标签页,所以 nonce 存在 sessionStorage:别的标签页、别的浏览器拿到兑换码也没有它。
// 意图也存在这里,回来之后据此决定接下来做什么。
@@ -26,6 +28,18 @@ export async function startGithubLogin(intent: GitHubIntent, currentPassword?: s
return url
}
// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。
export async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise<boolean> {
try {
window.location.assign(await startGithubLogin(intent, currentPassword))
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有跳转到 GitHub,再试一次。' : errorMessage(error))
return false
}
}
export type GithubLanding
= | { kind: 'exchange', code: string, nonce: string, intent: GitHubIntent }
| { kind: 'failed', message: string }
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NotificationPermissionRequest } from './notification-permission'
// 浏览器的 Notification:每次申请都弹出一个提示,由测试决定用户怎么回应、什么时候回应。
class FakeNotification {
static permission: NotificationPermission = 'default'
static prompts: ((permission: NotificationPermission) => void)[] = []
static requestPermission(): Promise<NotificationPermission> {
return new Promise((resolve) => {
FakeNotification.prompts.push((permission) => {
FakeNotification.permission = permission
resolve(permission)
})
})
}
}
function answer(index: number, permission: NotificationPermission): void {
FakeNotification.prompts[index]!(permission)
}
beforeEach(() => {
FakeNotification.permission = 'default'
FakeNotification.prompts = []
vi.stubGlobal('Notification', FakeNotification)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('申请系统通知的权限', () => {
it('从浏览器读到当前的权限', () => {
FakeNotification.permission = 'denied'
expect(new NotificationPermissionRequest().state).toEqual({ permission: 'denied', waiting: false, refused: false })
})
it('申请之后等待浏览器回应,允许之后拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
expect(request.state).toEqual({ permission: 'default', waiting: true, refused: false })
answer(0, 'granted')
expect(await result).toBe(true)
expect(request.state).toEqual({ permission: 'granted', waiting: false, refused: false })
})
it('浏览器拒绝时没有拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
answer(0, 'denied')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'denied', waiting: false, refused: true })
})
it('用户关掉提示、权限仍是 default 时,也算没有拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
answer(0, 'default')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'default', waiting: false, refused: true })
})
it('提示一直不返回时可以放弃,放弃之后提示才返回也不算这次申请的结果,只更新权限', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
request.abandon()
expect(request.state).toEqual({ permission: 'default', waiting: false, refused: false })
answer(0, 'granted')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'granted', waiting: false, refused: false })
})
it('放弃后被拒绝的提示晚回来,不显示没有拿到授权', async () => {
const request = new NotificationPermissionRequest()
const result = request.request()
request.abandon()
answer(0, 'denied')
expect(await result).toBe(false)
expect(request.state).toEqual({ permission: 'denied', waiting: false, refused: false })
})
it('放弃之后重新申请,之前的提示晚回来不结束新的等待', async () => {
const request = new NotificationPermissionRequest()
const first = request.request()
request.abandon()
const second = request.request()
answer(0, 'default')
expect(await first).toBe(false)
expect(request.state).toEqual({ permission: 'default', waiting: true, refused: false })
answer(1, 'granted')
expect(await second).toBe(true)
expect(request.state).toEqual({ permission: 'granted', waiting: false, refused: false })
})
it('再次申请时清掉上次没有拿到授权的说明', async () => {
const request = new NotificationPermissionRequest()
const first = request.request()
answer(0, 'default')
await first
void request.request()
expect(request.state).toEqual({ permission: 'default', waiting: true, refused: false })
})
})
@@ -0,0 +1,38 @@
import { Store } from '@/shared/lib/store'
interface NotificationPermissionState {
// 浏览器不通知权限的变化,构造时和每次申请之后各读一次。
permission: NotificationPermission
waiting: boolean
// 最近一次申请没有拿到授权。
refused: boolean
}
// 浏览器的授权提示可能一直不返回:用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃;
// 放弃之后提示才返回时只更新权限,不算这次申请的结果。
export class NotificationPermissionRequest extends Store<NotificationPermissionState> {
#latest = 0
constructor() {
super({ permission: Notification.permission, waiting: false, refused: false })
}
// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。
// 返回这次申请是否拿到了授权;等待期间被放弃,或者又发起了新的申请时为 false。
async request(): Promise<boolean> {
const id = ++this.#latest
this.setState({ waiting: true, refused: false })
const permission = await Notification.requestPermission()
if (id !== this.#latest) {
this.setState({ permission })
return false
}
this.setState({ permission, waiting: false, refused: permission !== 'granted' })
return permission === 'granted'
}
abandon(): void {
this.#latest++
this.setState({ waiting: false, refused: false })
}
}
+28 -11
View File
@@ -39,8 +39,8 @@ let patchError: { status: number, code: string } | null
let extraRoutes: Map<string, () => Response>
async function load() {
const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')])
return { profileStore, switchIdentity, identityEvents }
const [{ profileStore, switchIdentity }, { identityEvents }, { notificationStore }] = await Promise.all([import('./profile-store'), import('@/core/api'), import('@/shared/ui')])
return { profileStore, switchIdentity, identityEvents, notificationStore }
}
beforeEach(() => {
@@ -232,12 +232,12 @@ describe('邮箱', () => {
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true)
await profileStore.save({ email: ' Alice@Example.COM ' })
expect(updates).toEqual([{ email: ' Alice@Example.COM ' }])
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM', github: false } })
expect(local.getItem(emailKey)).toBeNull()
expect(await profileStore.save({ email: '' })).toBe(true)
await profileStore.save({ email: '' })
expect(profileStore.state.email).toBe('')
expect(profileStore.state.account).toEqual({ username: 'alice', github: false })
})
@@ -245,7 +245,7 @@ describe('邮箱', () => {
it('游客保存到本机', async () => {
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: 'guest@example.com' })).toBe(true)
await profileStore.save({ email: 'guest@example.com' })
expect(local.getItem(emailKey)).toBe('guest@example.com')
expect(profileStore.state.email).toBe('guest@example.com')
})
@@ -323,12 +323,28 @@ describe('邮箱', () => {
expect(updates).toEqual([])
})
it('保存失败时抛出按字段说明的原因,不弹通知,资料不变', async () => {
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
const { profileStore, notificationStore } = await load()
await profileStore.load()
patchError = { status: 400, code: 'INVALID_ARGUMENT' }
await expect(profileStore.save({ email: 'alice' })).rejects.toThrow('邮箱要写成 name@example.com 这样的格式。')
await expect(profileStore.save({ name: '' })).rejects.toThrow('昵称不能为空。')
patchError = null
patchFails = true
await expect(profileStore.save({ name: 'bob' })).rejects.toThrow('无法连接服务器,请检查网络后再试。')
expect(profileStore.state).toMatchObject({ member, email: 'alice@example.com' })
expect(notificationStore.state.notifications).toEqual([])
})
it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => {
local.setItem(emailKey, 'guest@example.com')
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: '' })).toBe(true)
await profileStore.save({ email: '' })
expect(local.getItem(emailKey)).toBeNull()
updates.length = 0
@@ -358,20 +374,21 @@ describe('记录播放历史', () => {
expect(profileStore.state.recordListens).toBeNull()
await profileStore.load()
expect(profileStore.state.recordListens).toBe(false)
expect(await profileStore.setRecordListens(true)).toBe(true)
await profileStore.setRecordListens(true)
expect(profileStore.state.recordListens).toBe(true)
expect(await profileStore.setRecordListens(false)).toBe(true)
await profileStore.setRecordListens(false)
expect(profileStore.state.recordListens).toBe(false)
expect(switched).toEqual(['on', 'off'])
})
it('修改失败时开关不变', async () => {
it('修改失败时抛出原因,不弹通知,开关不变', async () => {
me = { member, recordListens: true }
extraRoutes.set('DELETE /api/me/listen-recording', () => new Response(JSON.stringify({ error: { code: 'UNAVAILABLE', message: '暂时不可用' } }), { status: 503 }))
const { profileStore } = await load()
const { profileStore, notificationStore } = await load()
await profileStore.load()
expect(await profileStore.setRecordListens(false)).toBe(false)
await expect(profileStore.setRecordListens(false)).rejects.toThrow('暂时不可用')
expect(profileStore.state.recordListens).toBe(true)
expect(notificationStore.state.notifications).toEqual([])
})
it('身份被拒绝时开关未知,换成新的游客之后是默认的开启', async () => {
+15 -12
View File
@@ -67,25 +67,27 @@ class ProfileStore extends Store<ProfileState> {
return true
}
// 返回 false 时,失败原因已经通过通知显示。
async save(changes: { name?: string, email?: string }): Promise<boolean> {
return await this.#update(changes) === null
// 一次只改一项,失败时才能说清是哪一项不合格:客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。
async save(changes: { name: string } | { email: string }): Promise<void> {
const error = await this.#update(changes)
if (error === null)
return
const invalid = 'name' in changes ? '昵称不能为空。' : '邮箱要写成 name@example.com 这样的格式。'
throw new Error(error instanceof IdentityRenewedError ? '没有保存,再试一次。' : errorMessage(error, { INVALID_ARGUMENT: invalid }))
}
// 返回 false 时,失败原因已经通过通知显示。
async setRecordListens(on: boolean): Promise<boolean> {
// 失败时抛出 Error,message 是给用户看的原因。
async setRecordListens(on: boolean): Promise<void> {
try {
await (on ? api.enableListenRecording() : api.disableListenRecording())
this.setState({ recordListens: on })
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '设置没有保存,再保存一次。' : errorMessage(error))
return false
throw new Error(error instanceof IdentityRenewedError ? '没有保存,再试一次。' : errorMessage(error))
}
this.setState({ recordListens: on })
}
// 失败时返回错误,失败原因已经通过通知显示。
// 失败时返回错误。
async #update(changes: { name?: string, email?: string }): Promise<unknown> {
try {
const { member } = await api.updateMe(changes)
@@ -109,8 +111,6 @@ class ProfileStore extends Store<ProfileState> {
return null
}
catch (error) {
// 客户端不能按服务端的 message 区分是哪个字段不合格。
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
return error
}
}
@@ -137,6 +137,9 @@ class ProfileStore extends Store<ProfileState> {
if (!email)
return
const error = await this.#update({ email })
if (error === null)
return
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
// 服务端不接受的副本重试也不会成功,删掉免得每次加载都报错;其他错误(网络、服务端故障)留到下次再试。
if (error instanceof ApiError && error.code === 'INVALID_ARGUMENT')
localStorage.removeItem(emailKey)
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { SaveStatus, Switch } from '@/shared/ui'
// id 是行内控件的 id:标题用 label 指向它,说明和错误的 id 由它派生。
export function SettingRow({ id, title, description, error, status, labelled = true, children }: {
id: string
title: ReactNode
description?: ReactNode
error?: string | null
status?: ReactNode
// 控件不能被 label 指向时(比如分段控件自带 aria-label)为 false。
labelled?: boolean
children: ReactNode
}) {
return (
<div className="section-row">
<div className="section-row-copy">
{labelled ? <label htmlFor={id} className="section-row-title">{title}</label> : <span className="section-row-title">{title}</span>}
{description && <p id={`${id}-description`} className="section-row-description">{description}</p>}
{error && <p id={`${id}-error`} className="settings-row-error text-ui-caption" role="alert">{error}</p>}
</div>
<div className="settings-row-control">
{status}
{children}
</div>
</div>
)
}
function describedBy(id: string, error: string | null): string {
return error ? `${id}-description ${id}-error` : `${id}-description`
}
// 放在 SettingRow 里的开关,说明和错误由同一个 id 派生。
export function SettingSwitch({ id, checked, error, disabled, onChange }: { id: string, checked: boolean, error: string | null, disabled?: boolean, onChange: (checked: boolean) => void }) {
return <Switch id={id} checked={checked} onChange={onChange} disabled={disabled} aria-describedby={describedBy(id, error)} />
}
export function SwitchRow({ id, title, description, checked, onSave }: {
id: string
title: string
description: ReactNode
checked: boolean
onSave: (checked: boolean) => void | Promise<void>
}) {
const setting = useSettingSave(checked, onSave)
return (
<SettingRow id={id} title={title} description={description} error={setting.state.error} status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />}>
<SettingSwitch id={id} checked={setting.value} error={setting.state.error} onChange={setting.change} />
</SettingRow>
)
}
@@ -0,0 +1,345 @@
import type { Site } from './use-site'
import type { Account, GitHubIntent, Member } from '@/core/realtime'
import { ChevronRight, CircleAlert, Pencil } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { isImeKey } from '@/shared/lib/ime'
import { useInlineSave } from '@/shared/lib/use-inline-save'
import { savedNoticeMs } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { Avatar, ConfirmDialog, EmptyState, InlineTextField, Modal, notificationStore, SaveStatus } from '@/shared/ui'
import { CredentialsDialog, CurrentPasswordDialog, PasswordDialog } from './account-dialogs'
import { goToGitHub, takeCredentialsPrompt } from './github'
import { PairingCodeForm, PairingDialog } from './pairing-dialog'
import { profileStore, switchIdentity } from './profile-store'
type Dialog = 'register' | 'login' | 'credentials' | 'password' | 'pairing' | 'redeem' | 'bind' | 'unbind' | 'logout' | null
function identityMeta(account: Account | null): string {
if (!account)
return '游客'
return account.username ? `@${account.username}` : '用 GitHub 登录的账号'
}
// 手机端分类列表顶部的身份卡,是账号分类的入口。
export function IdentityEntry({ onOpen }: { onOpen: () => void }) {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const failed = useStore(profileStore, s => s.failed)
return (
<button type="button" className="settings-identity-entry" data-section="account" onClick={onOpen}>
{member
? <Avatar hash={member.avatarHash} name={member.name} className="settings-identity-entry-avatar" />
: <span className="settings-identity-entry-avatar settings-skeleton" />}
<span className="settings-identity-entry-copy">
<span className="sr-only">账号:</span>
{member || failed
? <span className="settings-identity-entry-name">{member?.name ?? '账号'}</span>
: <span className="settings-skeleton settings-skeleton-line" />}
<span className="settings-identity-entry-meta">{member ? identityMeta(account) : failed ? '资料没有加载出来' : ''}</span>
</span>
<ChevronRight className="settings-chevron" size={18} aria-hidden="true" />
</button>
)
}
// 编辑时才挂载:每次开始编辑都从当前的昵称开始,保存中和失败的状态随编辑结束一起丢掉。
function NameInput({ name, onDone }: { name: string, onDone: (saved: boolean, refocus: boolean) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
// 结束编辑之后输入框随即卸载,有的浏览器会在卸载时触发失焦,不能再提交一次。
const doneRef = useRef(false)
const errorId = useId()
const { state, edit, submit } = useInlineSave(name, value => profileStore.save({ name: value.trim() }))
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const done = (saved: boolean, refocus: boolean) => {
doneRef.current = true
onDone(saved, refocus)
}
const finish = async (fromKeyboard: boolean) => {
const changed = state.draft !== state.saved
if (await submit() && !doneRef.current)
done(changed, fromKeyboard && document.activeElement === inputRef.current)
}
return (
<>
<div className="settings-name">
<input
ref={inputRef}
className="settings-name-input"
value={state.draft}
aria-label="昵称"
maxLength={80}
autoComplete="nickname"
aria-invalid={state.status === 'error'}
aria-describedby={state.error ? errorId : undefined}
aria-busy={state.status === 'saving'}
onChange={event => edit(event.target.value)}
onBlur={() => {
if (!doneRef.current)
void finish(false)
}}
onKeyDown={(event) => {
if (isImeKey(event.nativeEvent))
return
if (event.key === 'Enter') {
event.preventDefault()
void finish(true)
}
// 已经发出的保存撤不回来,等它的结果。
else if (event.key === 'Escape' && state.status !== 'saving') {
event.preventDefault()
done(false, true)
}
}}
/>
<SaveStatus status={state.status === 'saving' ? 'saving' : 'idle'} />
</div>
{state.error && <p id={errorId} className="settings-row-error text-ui-caption" role="alert">{state.error}</p>}
</>
)
}
function NameEditor({ name }: { name: string }) {
const [editing, setEditing] = useState(false)
const [notice, setNotice] = useState(0)
const pencilRef = useRef<HTMLButtonElement>(null)
const refocusRef = useRef(false)
useEffect(() => {
if (editing || !refocusRef.current)
return
refocusRef.current = false
pencilRef.current?.focus()
}, [editing])
useEffect(() => {
if (!notice)
return
const timer = setTimeout(setNotice, savedNoticeMs, 0)
return () => clearTimeout(timer)
}, [notice])
if (editing) {
return (
<NameInput
name={name}
onDone={(saved, refocus) => {
refocusRef.current = refocus
setEditing(false)
setNotice(count => saved ? count + 1 : 0)
}}
/>
)
}
return (
<div className="settings-name">
{/* 点名字也能编辑;键盘用旁边的铅笔按钮。 */}
<span className="settings-name-text" onClick={() => setEditing(true)}>{name}</span>
<button ref={pencilRef} type="button" className="icon-button settings-name-edit" aria-label="编辑昵称" title="编辑昵称" onClick={() => setEditing(true)}>
<Pencil size={15} strokeWidth={1.9} aria-hidden="true" />
</button>
<SaveStatus status={notice ? 'saved' : 'idle'} version={notice} />
</div>
)
}
function IdentityCard({ member, account }: { member: Member, account: Account | null }) {
return (
<div className="settings-identity">
{/* 有头像时用它模糊后的样子做光晕;没有头像时这一层只有首字,由 CSS 隐藏。 */}
<Avatar hash={member.avatarHash} name={member.name} className="settings-identity-glow" />
<Avatar hash={member.avatarHash} name={member.name} className="settings-identity-avatar" />
<div className="settings-identity-copy">
<NameEditor name={member.name} />
<p className="settings-identity-meta">{identityMeta(account)}</p>
{!account && <p className="settings-identity-meta">注册之后可以在其他设备上登录</p>}
{account?.github && <span className="settings-badge">GitHub 已绑定</span>}
</div>
</div>
)
}
function AccountSkeleton() {
return (
<div role="status" aria-label="正在加载">
<div className="settings-identity">
<span className="settings-identity-avatar settings-skeleton" />
<div className="settings-identity-copy">
<span className="settings-skeleton settings-skeleton-line" />
<span className="settings-skeleton settings-skeleton-line settings-skeleton-short" />
</div>
</div>
<p className="section-group-label"><span className="settings-skeleton settings-skeleton-label" /></p>
<div className="section-group settings-skeleton-group" />
</div>
)
}
export function AccountSettings({ site, onRetrySite }: { site: Site, onRetrySite: () => void }) {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const email = useStore(profileStore, s => s.email)
const failed = useStore(profileStore, s => s.failed)
const [dialog, setDialog] = useState<Dialog>(() => takeCredentialsPrompt() ? 'credentials' : null)
const [busy, setBusy] = useState(false)
const close = () => setDialog(null)
const github = async (intent: GitHubIntent) => {
setBusy(true)
if (!await goToGitHub(intent))
setBusy(false)
}
const unbind = async (currentPassword: string): Promise<boolean> => {
if (!account)
return false
try {
await api.unbindGithub({ currentPassword })
profileStore.updateAccount({ ...account, github: false })
notificationStore.success('已解绑 GitHub')
close()
return true
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '没有解绑,再试一次。' : errorMessage(error))
return false
}
}
const loadFailed = (!member && failed) || site.status === 'failed'
const retry = () => {
if (!member && failed)
void profileStore.load().catch(() => {})
if (site.status === 'failed')
onRetrySite()
}
let content
if (loadFailed) {
content = (
<EmptyState icon={CircleAlert} title="账号信息没有加载出来" action={<button type="button" className="button-secondary" onClick={retry}>重试</button>}>
可能是网络中断,检查网络后再试。
</EmptyState>
)
}
else if (!member || site.status !== 'ready') {
content = <AccountSkeleton />
}
else {
content = (
<>
<IdentityCard member={member} account={account} />
{!account && (
<div className="settings-guest-actions">
{site.registrationOpen && <button type="button" className="button-primary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className={site.registrationOpen ? 'button-secondary' : 'button-primary'} onClick={() => setDialog('login')}>登录</button>
{site.githubLogin && <button type="button" className="button-secondary" disabled={busy} onClick={() => void github('login')}>用 GitHub 登录</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('redeem')}>输入配对码</button>
</div>
)}
<p className="section-group-label">资料</p>
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">邮箱</span>
<p className="section-row-description">{account ? '用来显示头像,各设备一致' : '用来显示头像,只保存在这台设备上'}</p>
</div>
<InlineTextField label="邮箱" type="email" value={email} placeholder="name@example.com" maxLength={320} onSave={value => profileStore.save({ email: value.trim() })} />
</div>
</div>
{account && (
<>
<p className="section-group-label">登录方式</p>
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">{account.username ? '密码' : '用户名和密码'}</span>
<p className="section-row-description">{account.username ? '修改后,其他设备需要重新登录' : '设置之后可以用密码登录'}</p>
</div>
{account.username
? <button type="button" className="button-secondary settings-button" onClick={() => setDialog('password')}>修改</button>
: <button type="button" className="button-secondary settings-button" disabled={busy} onClick={() => void github('verify')}>设置</button>}
</div>
{site.githubLogin && (
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">GitHub</span>
<p className="section-row-description">
{!account.github ? '绑定之后可以用 GitHub 登录这个账号' : account.username ? '可以用 GitHub 登录这个账号' : '先设置用户名和密码,才能解绑'}
</p>
</div>
<div className="settings-row-control">
{account.github && <span className="settings-badge">已绑定</span>}
{account.github
? <button type="button" className="button-secondary settings-button" disabled={!account.username} onClick={() => setDialog('unbind')}>解绑</button>
: <button type="button" className="button-secondary settings-button" onClick={() => setDialog('bind')}>绑定</button>}
</div>
</div>
)}
</div>
<p className="section-group-label">设备</p>
<div className="section-group">
<button type="button" className="section-row" onClick={() => setDialog('pairing')}>
<span className="section-row-copy">
<span className="section-row-title">在新设备上登录</span>
<span className="section-row-description">显示配对码和二维码</span>
</span>
<ChevronRight className="settings-chevron" size={18} aria-hidden="true" />
</button>
<button type="button" className="section-row" onClick={() => setDialog('logout')}>
<span className="section-row-copy">
<span className="section-row-title settings-danger">退出登录</span>
</span>
</button>
</div>
</>
)}
</>
)
}
return (
<>
<h2 className="settings-title">账号</h2>
{content}
{(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && <CredentialsDialog mode={dialog} onClose={close} />}
{dialog === 'password' && <PasswordDialog onClose={close} />}
{dialog === 'pairing' && <PairingDialog onClose={close} />}
{dialog === 'redeem' && (
<Modal title="输入配对码" onClose={close}>
<PairingCodeForm onSignedIn={switchIdentity} />
</Modal>
)}
{(dialog === 'bind' || dialog === 'unbind') && (
<CurrentPasswordDialog
title={dialog === 'bind' ? '绑定 GitHub' : '解绑 GitHub'}
action={dialog === 'bind' ? '去 GitHub 授权' : '解绑'}
onConfirm={dialog === 'bind' ? currentPassword => goToGitHub('bind', currentPassword) : unbind}
onClose={close}
/>
)}
{dialog === 'logout' && (
<ConfirmDialog
title="退出登录"
confirmLabel="退出登录"
danger
onConfirm={async () => {
await profileStore.logout()
}}
onClose={close}
>
退出之后,这台设备会换成新的游客身份。
</ConfirmDialog>
)}
</>
)
}
@@ -0,0 +1,94 @@
import type { KeyboardEvent } from 'react'
import type { Theme } from './appearance-store'
import { Check } from 'lucide-react'
import { useRef } from 'react'
import { rovingIndex } from '@/shared/lib/roving'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus, SegmentedControl } from '@/shared/ui'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { SettingRow } from './setting-row'
import { themeNames } from './settings-labels'
function ThemePicker() {
const saved = useStore(appearanceStore, s => s.theme)
const setting = useSettingSave(saved, (theme: Theme) => appearanceStore.update(theme, appearanceStore.state.fontSize))
const groupRef = useRef<HTMLDivElement>(null)
const selected = themes.indexOf(setting.value)
// 方向键在选项之间移动并选中,和原生的单选按钮一样。
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const next = rovingIndex(event.key, selected, themes.length)
if (next === null)
return
event.preventDefault()
groupRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus()
setting.change(themes[next])
}
return (
<>
<div className="section-group-label settings-group-label">
<span id="settings-theme-label">主题</span>
<SaveStatus status={setting.state.status} version={setting.savedVersion} />
</div>
<div ref={groupRef} role="radiogroup" aria-labelledby="settings-theme-label" aria-describedby={setting.state.error ? 'settings-theme-error' : undefined} className="settings-themes" onKeyDown={onKeyDown}>
{themes.map((theme, index) => (
<button
type="button"
role="radio"
key={theme}
className="settings-theme"
data-theme-preview={theme}
aria-checked={index === selected}
tabIndex={index === selected ? 0 : -1}
onClick={() => setting.change(theme)}
>
<span className="settings-theme-preview" aria-hidden="true">
<i />
<i />
</span>
<span className="settings-theme-caption">
{themeNames[theme]}
{index === selected && <Check size={14} strokeWidth={2.4} aria-hidden="true" />}
</span>
</button>
))}
</div>
{setting.state.error && <p id="settings-theme-error" className="settings-row-error text-ui-caption" role="alert">{setting.state.error}</p>}
</>
)
}
function FontSizeRow() {
const saved = useStore(appearanceStore, s => String(s.fontSize))
const setting = useSettingSave(saved, (size: string) => appearanceStore.update(appearanceStore.state.theme, Number(size)))
return (
<SettingRow
id="settings-font-size"
title="房间里正在播放《晴天》"
description="预览会跟着字号变化,布局和间距不变"
error={setting.state.error}
status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />}
labelled={false}
>
<SegmentedControl
label="界面字号"
value={setting.value}
options={fontSizes.map(size => ({ value: String(size), label: String(size) }))}
onChange={setting.change}
/>
</SettingRow>
)
}
export function AppearanceSettings() {
return (
<>
<h2 className="settings-title">外观</h2>
<ThemePicker />
<p className="section-group-label">界面字号</p>
<div className="section-group">
<FontSizeRow />
</div>
</>
)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { notificationHint, notificationRefusal, sectionSummaries } from './settings-labels'
const defaults = { theme: 'dark', level: 'medium', volume: 75, muted: false, recordListens: true } as const
describe('sectionSummaries', () => {
it('手机端分类列表显示各分类的当前值', () => {
expect(sectionSummaries(defaults)).toEqual({ appearance: '深色', playback: '75%', performance: '平衡', privacy: '开' })
expect(sectionSummaries({ ...defaults, theme: 'system', level: 'off', recordListens: false })).toEqual({ appearance: '跟随系统', playback: '75%', performance: '极简', privacy: '关' })
})
it('静音时显示静音', () => {
expect(sectionSummaries({ ...defaults, muted: true }).playback).toBe('静音')
})
it('还没读到记录播放历史的设置时不显示', () => {
expect(sectionSummaries({ ...defaults, recordListens: null }).privacy).toBeUndefined()
})
})
describe('notificationHint', () => {
it('还没授权时提供授权按钮', () => {
expect(notificationHint('default')).toEqual({ description: '需要浏览器授权,现在还没授权', authorize: true })
})
it('已经授权时不提供授权按钮', () => {
expect(notificationHint('granted').authorize).toBe(false)
})
it('浏览器拒绝之后不能再申请,提示去网站设置里允许', () => {
expect(notificationHint('denied')).toEqual({ description: '浏览器不允许显示通知,要在浏览器的网站设置里允许', authorize: false })
})
})
describe('notificationRefusal', () => {
it('被拒绝之后说明里已经写了去哪里允许,只说结果', () => {
expect(notificationRefusal('denied')).toBe('浏览器拒绝了显示通知。')
})
it('提示被关掉或者被浏览器收起时,告诉用户去浏览器的网站设置里允许', () => {
expect(notificationRefusal('default')).toBe('浏览器没有授权,可以在浏览器的网站设置里允许通知。')
})
})
@@ -0,0 +1,44 @@
import type { Theme } from './appearance-store'
import type { PerformanceLevel } from './performance-store'
export const themeNames: Record<Theme, string> = { dark: '深色', light: '浅色', system: '跟随系统' }
export const levelNames: Record<PerformanceLevel, { name: string, description: string }> = {
high: { name: '高质量', description: '所有动画效果,适合高性能设备。' },
medium: { name: '平衡', description: '优化过的动画效果,推荐使用。' },
low: { name: '省电', description: '简化动画效果,适合移动设备。' },
off: { name: '极简', description: '关闭所有动画,对设备的负担最小。' },
}
export function sectionSummaries({ theme, level, volume, muted, recordListens }: {
theme: Theme
level: PerformanceLevel
volume: number
muted: boolean
recordListens: boolean | null
}): { appearance: string, playback: string, performance: string, privacy?: string } {
return {
appearance: themeNames[theme],
playback: muted ? '静音' : `${volume}%`,
performance: levelNames[level].name,
...recordListens !== null && { privacy: recordListens ? '开' : '关' },
}
}
// 浏览器拒绝之后,网页不能再弹出授权请求。
export function notificationHint(permission: NotificationPermission): { description: string, authorize: boolean } {
switch (permission) {
case 'granted':
return { description: '轮到你点的歌时,在系统通知里提醒', authorize: false }
case 'denied':
return { description: '浏览器不允许显示通知,要在浏览器的网站设置里允许', authorize: false }
default:
return { description: '需要浏览器授权,现在还没授权', authorize: true }
}
}
// 申请没有拿到授权时的说明。被拒绝之后 notificationHint 已经写了去哪里允许;提示被关掉或者被浏览器收起时,权限仍是 default,
// 再申请可能不会弹出提示,所以也指向网站设置。
export function notificationRefusal(permission: NotificationPermission): string {
return permission === 'denied' ? '浏览器拒绝了显示通知。' : '浏览器没有授权,可以在浏览器的网站设置里允许通知。'
}
+51 -282
View File
@@ -1,294 +1,63 @@
import type { ReactNode } from 'react'
import type { Theme } from './appearance-store'
import type { PerformanceLevel } from './performance-store'
import { useEffect, useRef, useState } from 'react'
import { errorMessage } from '@/core/errors'
import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import type { SectionItem } from '@/shared/ui'
import { Contrast, Gauge, Music, Shield, UserRound } from 'lucide-react'
import { useEffect } from 'react'
import { volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Select } from '@/shared/ui'
import { AccountRow } from './account-section'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { SectionLayout } from '@/shared/ui'
import { appearanceStore } from './appearance-store'
import { performanceStore } from './performance-store'
import { profileStore } from './profile-store'
import { VolumeInput } from './volume-input'
import { AccountSettings, IdentityEntry } from './settings-account'
import { AppearanceSettings } from './settings-appearance'
import { sectionSummaries } from './settings-labels'
import { PerformanceSettings } from './settings-performance'
import { PlaybackSettings } from './settings-playback'
import { PrivacySettings } from './settings-privacy'
import { useSite } from './use-site'
import './settings.css'
interface SettingsDraft {
name: string
email: string
theme: Theme
fontSize: number
volume: number
muted: boolean
reminderEnabled: boolean
systemNotifications: boolean
level: PerformanceLevel
reducedMotion: boolean
searchesEnabled: boolean
searchLimit: number
recordListens: boolean
}
type DraftKey = keyof SettingsDraft
export type SettingsSection = 'account' | 'appearance' | 'playback' | 'performance' | 'privacy'
const themeNames: Record<Theme, string> = { dark: '深色', light: '浅色', system: '跟随系统' }
const levelNames: Record<PerformanceLevel, { name: string, description: string }> = {
high: { name: '高质量', description: '所有动画效果,适合高性能设备。' },
medium: { name: '平衡', description: '优化过的动画效果,推荐使用。' },
low: { name: '省电', description: '简化动画效果,适合移动设备。' },
off: { name: '极简', description: '关闭所有动画,对设备的负担最小。' },
}
function loadProfile(): void {
void profileStore.load().catch((error: unknown) => {
notificationStore.error(errorMessage(error))
})
}
function SettingRow({ id, title, description, field = false, children }: {
id: string
title: string
description: string
field?: boolean
children: ReactNode
// section 为 null 时地址是 /settings:宽屏显示账号,窄屏显示分类列表。onSection(null) 回到分类列表。
export function SettingsPage({ section, onSection, onOpenHistory }: {
section: SettingsSection | null
onSection: (section: SettingsSection | null) => void
onOpenHistory: () => void
}) {
return (
<div className={`settings-row${field ? ' settings-row-field' : ''}`}>
<div className="settings-row-copy">
<label htmlFor={id} className="settings-row-title text-ui-base">{title}</label>
<p id={`${id}-description`} className="text-ui-caption muted">{description}</p>
</div>
<div className="settings-row-control">{children}</div>
</div>
)
}
export function SettingsPage(_: { section: SettingsSection | null }) {
const headingRef = useRef<HTMLHeadingElement>(null)
const member = useStore(profileStore, s => s.member)
const email = useStore(profileStore, s => s.email)
const account = useStore(profileStore, s => s.account)
const recordListens = useStore(profileStore, s => s.recordListens)
const profileFailed = useStore(profileStore, s => s.failed)
const theme = useStore(appearanceStore, s => s.theme)
const fontSize = useStore(appearanceStore, s => s.fontSize)
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const reminderEnabled = useStore(reminderStore, s => s.enabled)
const systemNotifications = useStore(reminderStore, s => s.systemNotifications)
const level = useStore(performanceStore, s => s.level)
const reducedMotion = useStore(performanceStore, s => s.reducedMotion)
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
const searchLimit = useStore(historyStore, s => s.searchLimit)
const [changes, setChanges] = useState<Partial<SettingsDraft>>({})
const [saving, setSaving] = useState(false)
// state 要到下一次渲染才更新,连续提交时由这个 ref 阻止重复保存。
const savingRef = useRef(false)
const { site, reload: reloadSite } = useSite()
const summaries = sectionSummaries({
theme: useStore(appearanceStore, s => s.theme),
level: useStore(performanceStore, s => s.level),
volume: useStore(volumeStore, s => s.volume),
muted: useStore(volumeStore, s => s.muted),
recordListens: useStore(profileStore, s => s.recordListens),
})
// 资料的读取失败由各分类就地显示。
useEffect(() => {
headingRef.current?.focus({ preventScroll: true })
loadProfile()
void profileStore.load().catch(() => {})
}, [])
const current: SettingsDraft = { name: member?.name ?? '', email, theme, fontSize, volume, muted, reminderEnabled, systemNotifications, level, reducedMotion, searchesEnabled, searchLimit, recordListens: recordListens ?? false }
const draft: SettingsDraft = { ...current, ...changes }
const changed = (keys: DraftKey[]) => keys.some(key => draft[key] !== current[key])
const dirty = changed(Object.keys(changes) as DraftKey[])
const update = <K extends DraftKey>(key: K, value: SettingsDraft[K]) => {
setChanges(previous => ({ ...previous, [key]: value }))
}
const toggleSystemNotifications = (enabled: boolean) => {
if (!enabled) {
update('systemNotifications', false)
return
}
void requestNotificationPermission().then((allowed) => {
if (allowed)
update('systemNotifications', true)
else
notificationStore.error('浏览器没有允许显示通知,可以在浏览器的网站设置里允许。')
})
}
const save = async () => {
if (savingRef.current || !dirty)
return
savingRef.current = true
setSaving(true)
const saved: DraftKey[] = []
let failed = false
try {
if (changed(['name', 'email'])) {
// 只发送改动过的字段:空邮箱会清除头像,本机没有保存邮箱时不能一并发送。
const profile: { name?: string, email?: string } = {}
if (changed(['name']))
profile.name = draft.name.trim()
if (changed(['email']))
profile.email = draft.email.trim()
if (await profileStore.save(profile))
saved.push('name', 'email')
else
failed = true
}
if (changed(['theme', 'fontSize'])) {
appearanceStore.update(draft.theme, draft.fontSize)
saved.push('theme', 'fontSize')
}
if (changed(['volume', 'muted'])) {
volumeStore.setVolume(draft.volume)
volumeStore.setMuted(draft.muted)
saved.push('volume', 'muted')
}
if (changed(['reminderEnabled', 'systemNotifications'])) {
reminderStore.updateSettings({ enabled: draft.reminderEnabled, systemNotifications: draft.systemNotifications })
saved.push('reminderEnabled', 'systemNotifications')
}
if (changed(['level', 'reducedMotion'])) {
performanceStore.update(draft.level, draft.reducedMotion)
saved.push('level', 'reducedMotion')
}
if (changed(['searchesEnabled', 'searchLimit'])) {
historyStore.updateSettings({ searchesEnabled: draft.searchesEnabled, searchLimit: draft.searchLimit })
saved.push('searchesEnabled', 'searchLimit')
}
if (changed(['recordListens'])) {
if (await profileStore.setRecordListens(draft.recordListens))
saved.push('recordListens')
else
failed = true
}
setChanges((previous) => {
const rest = { ...previous }
for (const key of saved)
delete rest[key]
return rest
})
if (!failed)
notificationStore.success('已保存设置')
}
finally {
savingRef.current = false
setSaving(false)
}
}
const sections: SectionItem<SettingsSection>[] = [
{ id: 'account', label: '账号', icon: UserRound, hiddenInList: true },
{ id: 'appearance', label: '外观', icon: Contrast, summary: summaries.appearance },
{ id: 'playback', label: '播放与提醒', icon: Music, summary: summaries.playback },
{ id: 'performance', label: '性能', icon: Gauge, summary: summaries.performance },
{ id: 'privacy', label: '隐私与记录', icon: Shield, summary: summaries.privacy },
]
const shown = section ?? 'account'
return (
<section className="settings-page" aria-labelledby="settings-title">
<header className="settings-page-header">
<h1 id="settings-title" className="text-ui-xl" tabIndex={-1} ref={headingRef}>设置</h1>
</header>
<form
className="settings-page-form"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="settings-page-body">
<fieldset className="settings-fields" disabled={saving}>
<div className="settings-content">
<section className="settings-section" aria-labelledby="settings-account-title">
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
<div className="settings-group">
<AccountRow />
<SettingRow id="settings-name" title="昵称" description={member ? '房间里的其他人看到的名字。' : profileFailed ? '暂时无法获取你的资料。' : '正在获取你的资料…'} field>
{!member && profileFailed
? <button type="button" className="button-secondary" aria-describedby="settings-name-description" onClick={loadProfile}>重试</button>
: <input id="settings-name" aria-describedby="settings-name-description" value={draft.name} onChange={event => update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />}
</SettingRow>
<SettingRow id="settings-email" title="邮箱" description={account ? '保存在账号里,用来显示头像。留空就不显示。' : '只保存在这台设备上,用来显示头像。留空就不显示。'} field>
<input id="settings-email" aria-describedby="settings-email-description" type="email" value={draft.email} onChange={event => update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" />
</SettingRow>
</div>
</section>
<section className="settings-section" aria-labelledby="settings-appearance-title">
<h2 id="settings-appearance-title" className="text-ui-lg">外观</h2>
<div className="settings-group">
<SettingRow id="settings-theme" title="主题" description="选择界面的明暗外观。">
<Select
id="settings-theme"
ariaDescribedBy="settings-theme-description"
value={draft.theme}
onChange={value => update('theme', themes.find(theme => theme === value) ?? draft.theme)}
options={themes.map(value => ({ value, label: themeNames[value] }))}
/>
</SettingRow>
<SettingRow id="settings-font-size" title="界面字号" description="调整文字大小,布局和间距保持不变。">
<Select
id="settings-font-size"
ariaDescribedBy="settings-font-size-description"
value={String(draft.fontSize)}
onChange={value => update('fontSize', Number(value))}
options={fontSizes.map(size => ({ value: String(size), label: `${size} px` }))}
/>
</SettingRow>
</div>
</section>
<section className="settings-section" aria-labelledby="settings-playback-title">
<h2 id="settings-playback-title" className="text-ui-lg">播放</h2>
<div className="settings-group">
<SettingRow id="settings-volume" title="音量" description="只调整这台设备的播放音量。" field>
<VolumeInput id="settings-volume" className="settings-volume-control" volume={draft.volume} onCommit={next => update('volume', next)} />
</SettingRow>
<SettingRow id="settings-muted" title="静音" description="暂时关闭这台设备的声音。">
<input id="settings-muted" aria-describedby="settings-muted-description" className="settings-switch" type="checkbox" role="switch" checked={draft.muted} onChange={event => update('muted', event.target.checked)} />
</SettingRow>
<SettingRow id="settings-reminder" title="自己点的歌开播时提醒" description="轮到你点的歌时,在页面上显示一条通知。">
<input id="settings-reminder" aria-describedby="settings-reminder-description" className="settings-switch" type="checkbox" role="switch" checked={draft.reminderEnabled} onChange={event => update('reminderEnabled', event.target.checked)} />
</SettingRow>
{systemNotificationsSupported() && (
<SettingRow id="settings-system-notifications" title="页面在后台时,用系统通知提醒" description={systemNotifications && Notification.permission !== 'granted' ? '浏览器现在不允许显示通知,关掉再打开可以重新申请。' : '轮到你点的歌时,在这台设备上显示系统通知。'}>
<input id="settings-system-notifications" aria-describedby="settings-system-notifications-description" className="settings-switch" type="checkbox" role="switch" checked={draft.systemNotifications} disabled={!draft.reminderEnabled} onChange={event => toggleSystemNotifications(event.target.checked)} />
</SettingRow>
)}
</div>
</section>
<section className="settings-section" aria-labelledby="settings-performance-title">
<h2 id="settings-performance-title" className="text-ui-lg">性能</h2>
<div className="settings-group">
<SettingRow id="settings-performance" title="性能模式" description={levelNames[draft.level].description}>
<Select
id="settings-performance"
ariaDescribedBy="settings-performance-description"
value={draft.level}
onChange={value => update('level', performanceLevels.find(level => level === value) ?? draft.level)}
options={performanceLevels.map(value => ({ value, label: levelNames[value].name }))}
/>
</SettingRow>
<SettingRow id="settings-reduced-motion" title="减少动态效果" description="关闭界面动画和歌词的平滑滚动。">
<input id="settings-reduced-motion" aria-describedby="settings-reduced-motion-description" className="settings-switch" type="checkbox" role="switch" checked={draft.reducedMotion} onChange={event => update('reducedMotion', event.target.checked)} />
</SettingRow>
</div>
</section>
<section className="settings-section" aria-labelledby="settings-history-title">
<h2 id="settings-history-title" className="text-ui-lg">历史记录</h2>
<div className="settings-group">
<SettingRow id="settings-search-history" title="保存搜索记录" description="在这台设备上保留最近搜索的内容。">
<input id="settings-search-history" aria-describedby="settings-search-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.searchesEnabled} onChange={event => update('searchesEnabled', event.target.checked)} />
</SettingRow>
<SettingRow id="settings-search-limit" title="搜索记录数量" description="最多保留的搜索记录条数。">
<Select
id="settings-search-limit"
ariaDescribedBy="settings-search-limit-description"
value={String(draft.searchLimit)}
disabled={!draft.searchesEnabled}
onChange={value => update('searchLimit', Number(value))}
options={searchLimits.map(limit => ({ value: String(limit), label: `${limit} 条` }))}
/>
</SettingRow>
<SettingRow id="settings-play-history" title="记录播放历史" description={recordListens === null && profileFailed ? '暂时无法获取这项设置。' : '把你在房间里听过的歌记进播放历史。'}>
<input id="settings-play-history" aria-describedby="settings-play-history-description" className="settings-switch" type="checkbox" role="switch" checked={draft.recordListens} disabled={recordListens === null} onChange={event => update('recordListens', event.target.checked)} />
</SettingRow>
</div>
</section>
</div>
</fieldset>
</div>
<footer className="settings-page-footer">
<p className="settings-save-status text-ui-caption muted" role="status">{dirty ? '有未保存的更改' : '所有更改已保存'}</p>
<button type="submit" className="button-primary" disabled={!dirty || saving} aria-busy={saving}>{saving ? '正在保存…' : '保存'}</button>
</footer>
</form>
</section>
<SectionLayout
title="设置"
sections={sections}
current={section}
defaultSection="account"
onSelect={onSection}
header={<IdentityEntry onOpen={() => onSection('account')} />}
>
{shown === 'account' && <AccountSettings site={site} onRetrySite={reloadSite} />}
{shown === 'appearance' && <AppearanceSettings />}
{shown === 'playback' && <PlaybackSettings />}
{shown === 'performance' && <PerformanceSettings />}
{shown === 'privacy' && <PrivacySettings onOpenHistory={onOpenHistory} />}
</SectionLayout>
)
}
@@ -0,0 +1,42 @@
import type { PerformanceLevel } from './performance-store'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus, SegmentedControl } from '@/shared/ui'
import { performanceLevels, performanceStore } from './performance-store'
import { SettingRow, SwitchRow } from './setting-row'
import { levelNames } from './settings-labels'
function LevelRow() {
const saved = useStore(performanceStore, s => s.level)
const setting = useSettingSave(saved, (level: PerformanceLevel) => performanceStore.update(level, performanceStore.state.reducedMotion))
const { name, description } = levelNames[setting.value]
return (
<SettingRow id="settings-performance" title="性能模式" description={`${name}:${description}`} error={setting.state.error} status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />} labelled={false}>
<SegmentedControl
label="性能模式"
value={setting.value}
options={performanceLevels.map(level => ({ value: level, label: levelNames[level].name }))}
onChange={setting.change}
/>
</SettingRow>
)
}
export function PerformanceSettings() {
const reducedMotion = useStore(performanceStore, s => s.reducedMotion)
return (
<>
<h2 className="settings-title">性能</h2>
<div className="section-group">
<LevelRow />
<SwitchRow
id="settings-reduced-motion"
title="减少动态效果"
description="关闭界面动画和歌词的平滑滚动"
checked={reducedMotion}
onSave={on => performanceStore.update(performanceStore.state.level, on)}
/>
</div>
</>
)
}
@@ -0,0 +1,78 @@
import { Volume2, VolumeX } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { reminderStore, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus } from '@/shared/ui'
import { NotificationPermissionRequest } from './notification-permission'
import { SettingRow, SettingSwitch, SwitchRow } from './setting-row'
import { notificationHint, notificationRefusal } from './settings-labels'
import { VolumeInput } from './volume-input'
function SystemNotificationsRow() {
const reminderEnabled = useStore(reminderStore, s => s.enabled)
const saved = useStore(reminderStore, s => s.systemNotifications)
const setting = useSettingSave(saved, (on: boolean) => reminderStore.updateSettings({ enabled: reminderStore.state.enabled, systemNotifications: on }))
const [permissionRequest] = useState(() => new NotificationPermissionRequest())
const { permission, waiting, refused } = useStore(permissionRequest, s => s)
const hint = notificationHint(permission)
const error = refused ? notificationRefusal(permission) : setting.state.error
const id = 'settings-system-notifications'
// 浏览器答复之后“授权”按钮不再显示,焦点要是在它上面就掉到了 body:交给同一行的开关。
const authorizeShownRef = useRef(hint.authorize)
useEffect(() => {
const shown = authorizeShownRef.current
authorizeShownRef.current = hint.authorize
if (shown && !hint.authorize && document.activeElement === document.body)
document.getElementById(id)?.focus()
}, [hint.authorize])
// 打开之前先拿到授权,申请要在点击的同一个事件里发起。等待授权时开关显示为开,再点一次就是放弃。
const change = async (on: boolean) => {
if (!on) {
permissionRequest.abandon()
setting.change(false)
return
}
if (Notification.permission === 'granted' || await permissionRequest.request())
setting.change(true)
}
return (
<SettingRow
id={id}
title="页面在后台时用系统通知"
description={hint.description}
error={error}
status={<SaveStatus status={waiting ? 'saving' : setting.state.status} version={setting.savedVersion} savingText="等待浏览器授权…" />}
>
{hint.authorize && <button type="button" className="button-secondary settings-button" disabled={!reminderEnabled} onClick={() => void change(true)}>授权</button>}
<SettingSwitch id={id} checked={waiting || setting.value} error={error} disabled={!reminderEnabled} onChange={on => void change(on)} />
</SettingRow>
)
}
export function PlaybackSettings() {
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const reminderEnabled = useStore(reminderStore, s => s.enabled)
const VolumeIcon = muted || volume === 0 ? VolumeX : Volume2
return (
<>
<h2 className="settings-title">播放与提醒</h2>
<div className="section-group">
<SettingRow id="settings-volume" title="音量" description="只影响这台设备">
<VolumeIcon className="settings-volume-icon" size={18} strokeWidth={1.8} aria-hidden="true" />
<VolumeInput id="settings-volume" className="settings-volume-control" volume={volume} onCommit={next => volumeStore.setVolume(next)} />
</SettingRow>
<SwitchRow id="settings-muted" title="静音" description="暂时关闭这台设备的声音" checked={muted} onSave={on => volumeStore.setMuted(on)} />
<SwitchRow
id="settings-reminder"
title="自己点的歌开播时提醒"
description="在页面上显示一条通知"
checked={reminderEnabled}
onSave={on => reminderStore.updateSettings({ enabled: on, systemNotifications: reminderStore.state.systemNotifications })}
/>
{systemNotificationsSupported() && <SystemNotificationsRow />}
</div>
</>
)
}
@@ -0,0 +1,73 @@
import { ChevronRight } from 'lucide-react'
import { historyStore, searchLimits } from '@/features/history'
import { useSettingSave } from '@/shared/lib/use-setting-save'
import { useStore } from '@/shared/lib/use-store'
import { SaveStatus, SegmentedControl } from '@/shared/ui'
import { profileStore } from './profile-store'
import { SettingRow, SwitchRow } from './setting-row'
function RecordListensRow() {
const recordListens = useStore(profileStore, s => s.recordListens)
const failed = useStore(profileStore, s => s.failed)
const account = useStore(profileStore, s => s.account)
const id = 'settings-record-listens'
if (recordListens === null) {
return (
<SettingRow id={id} title="记录播放历史" description={failed ? '没有读到这项设置' : '正在读取…'} labelled={false}>
{failed
? <button type="button" className="button-secondary settings-button" onClick={() => void profileStore.load().catch(() => {})}>重试</button>
: <span className="settings-switch-placeholder" aria-hidden="true" />}
</SettingRow>
)
}
return (
<SwitchRow
id={id}
title="记录播放历史"
description={account ? '保存在服务器上,登录这个账号的设备共用' : '保存在服务器上'}
checked={recordListens}
onSave={on => profileStore.setRecordListens(on)}
/>
)
}
function SearchLimitRow() {
const saved = useStore(historyStore, s => String(s.searchLimit))
const setting = useSettingSave(saved, (limit: string) => historyStore.updateSettings({ searchesEnabled: historyStore.state.searchesEnabled, searchLimit: Number(limit) }))
return (
<SettingRow id="settings-search-limit" title="搜索记录数量" error={setting.state.error} status={<SaveStatus status={setting.state.status} version={setting.savedVersion} />} labelled={false}>
<SegmentedControl
label="搜索记录数量"
value={setting.value}
options={searchLimits.map(limit => ({ value: String(limit), label: String(limit) }))}
onChange={setting.change}
/>
</SettingRow>
)
}
export function PrivacySettings({ onOpenHistory }: { onOpenHistory: () => void }) {
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
return (
<>
<h2 className="settings-title">隐私与记录</h2>
<div className="section-group">
<RecordListensRow />
<button type="button" className="section-row" onClick={onOpenHistory}>
<span className="section-row-copy">
<span className="section-row-title">查看播放历史</span>
</span>
<ChevronRight className="settings-chevron" size={18} aria-hidden="true" />
</button>
<SwitchRow
id="settings-search-history"
title="保存搜索记录"
description="只保存在这台设备上"
checked={searchesEnabled}
onSave={on => historyStore.updateSettings({ searchesEnabled: on, searchLimit: historyStore.state.searchLimit })}
/>
{searchesEnabled && <SearchLimitRow />}
</div>
</>
)
}
+93 -49
View File
@@ -1,54 +1,98 @@
.settings-page { container: settings / inline-size; display: flex; flex-direction: column; height: 100%; min-height: 0; }
.settings-page-header { flex-shrink: 0; width: 100%; max-width: 1120px; margin: 0 auto; padding: 28px 40px 20px; }
.settings-page-header h1 { outline: none; }
.settings-page-form { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.settings-page-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 16px 40px 48px; }
.settings-fields { min-width: 0; margin: 0; padding: 0; border: 0; }
.settings-content { display: flex; flex-direction: column; gap: 40px; max-width: 1040px; margin: 0 auto; }
.settings-section { min-width: 0; }
.settings-section h2 { margin-bottom: 16px; }
.settings-group { padding: 0 20px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface); }
.settings-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; min-height: 86px; padding: 18px 0; }
.settings-row + .settings-row { border-top: 1px solid var(--color-border); }
.settings-row-copy { min-width: 0; }
.settings-row-title { display: block; font-weight: 600; }
.settings-row-copy p { margin-top: 4px; overflow-wrap: anywhere; }
.settings-row-control { display: flex; justify-content: flex-end; align-items: center; min-width: 0; max-width: 320px; }
.settings-row-control .select { width: auto; max-width: 100%; min-width: 128px; }
.settings-row-control .select-trigger { padding: 8px 12px; }
.settings-row-control .select-trigger:disabled, .settings-row-control input:disabled { opacity: .5; cursor: not-allowed; }
.settings-row-field .settings-row-control { width: 280px; }
.settings-volume-control { display: flex; align-items: center; gap: 16px; width: 100%; }
.settings-volume-control .volume-input-control { display: flex; align-items: center; gap: 4px; }
.settings-volume-control .volume-input { -moz-appearance: textfield; }
.settings-title { margin-bottom: 20px; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; }
.settings-title + .section-group-label { margin-top: 0; }
.settings-group-label { display: flex; align-items: center; gap: 10px; }
.settings-row-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; max-width: 100%; margin-left: auto; }
.settings-row-error { color: var(--color-destructive); overflow-wrap: anywhere; }
.section-row-copy > .settings-row-error { margin-top: 2px; }
.settings-themes + .settings-row-error { margin: 8px 4px 0; }
.settings-button { min-height: 32px; padding: 5px 14px; border-radius: 999px; font-size: var(--text-ui-caption); }
.settings-chevron { margin-left: auto; color: var(--color-foreground-subtlest); }
.settings-danger { color: var(--color-destructive); }
.settings-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; background: var(--color-card-hover); color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); white-space: nowrap; }
.settings-row-control > .settings-badge { background: var(--color-accent); color: var(--color-success); }
.settings-identity { --color-input: var(--color-input-on-card); position: relative; isolation: isolate; display: flex; align-items: center; gap: 18px; overflow: hidden; padding: 22px; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-card); }
.settings-identity-avatar { width: 64px; font-size: 24px; }
/* 光晕的中心对准头像:内边距 22px 加头像半径 32px。只是几乎察觉不到的氛围,浅色主题的 --glow-opacity 更低,在浅底上同样淡。 */
.settings-identity-glow { position: absolute; z-index: -1; top: 50%; left: 54px; width: 160px; background: transparent; box-shadow: none; color: transparent; opacity: calc(var(--glow-opacity) * .22); filter: blur(44px) saturate(1.3); transform: translate(-50%, -50%); pointer-events: none; }
.settings-identity-glow:not(:has(img)) { display: none; }
:root:is([data-performance='low'], [data-performance='off']) .settings-identity-glow { display: none; }
.settings-identity-copy { display: flex; flex: 1; flex-direction: column; align-items: flex-start; gap: 3px; min-width: 0; }
.settings-identity-meta { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); overflow-wrap: anywhere; }
.settings-identity .settings-badge { margin-top: 6px; }
.settings-name { display: flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; }
.settings-name-text { min-width: 0; overflow-wrap: anywhere; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; cursor: text; }
.settings-name-edit { width: 28px; height: 28px; border-radius: 50%; color: var(--color-foreground-subtlest); }
.settings-name-edit:hover:not(:disabled) { background: var(--color-surface-hover); color: var(--color-foreground); }
.settings-name-edit:focus-visible { outline-offset: 0; }
/* 输入框的文字和平时的名字对齐:负的外边距抵消边框和内边距。 */
input.settings-name-input { width: min(100%, 280px); margin: -5px 0 -5px -9px; padding: 4px 8px; font-size: var(--text-ui-lg); font-weight: 700; line-height: 1.3; }
input.settings-name-input[aria-invalid='true']:not(:focus) { border-color: var(--color-destructive); }
.settings-identity-copy > .settings-row-error { margin-top: 4px; }
.settings-guest-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.settings-guest-actions > button { border-radius: 999px; }
.settings-identity-entry { display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 14px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-card); color: var(--color-foreground); text-align: left; }
.settings-identity-entry:hover { background: var(--color-card-hover); }
.settings-identity-entry:active:not(:disabled) { transform: none; background: var(--color-hover); }
.settings-identity-entry:focus-visible { outline-offset: -2px; }
.settings-identity-entry-avatar { width: 42px; font-size: 18px; }
.settings-identity-entry-copy { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
.settings-identity-entry-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.settings-identity-entry-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
.settings-skeleton { display: block; flex-shrink: 0; border-radius: 6px; background: var(--color-surface-hover); }
:is(.settings-identity-avatar, .settings-identity-entry-avatar).settings-skeleton { aspect-ratio: 1; border-radius: 50%; }
.settings-skeleton-line { width: 140px; max-width: 100%; height: 16px; }
.settings-skeleton-short { width: 90px; height: 12px; }
.settings-skeleton-label { width: 48px; height: 12px; }
.settings-skeleton-group { height: 112px; }
.settings-switch-placeholder { flex-shrink: 0; width: 40px; height: 24px; border-radius: 999px; background: var(--color-surface-hover); }
.settings-themes { display: flex; gap: 12px; }
.settings-theme { display: flex; flex: 0 1 140px; flex-direction: column; gap: 8px; min-width: 0; padding: 8px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-card); color: var(--color-foreground); text-align: left; }
.settings-theme:hover { border-color: var(--color-border-hover); }
.settings-theme[aria-checked='true'] { border-color: var(--color-brand); box-shadow: inset 0 0 0 1px var(--color-brand); }
.settings-theme:focus-visible { outline-offset: 2px; }
.settings-theme-preview { display: flex; gap: 4px; height: 64px; padding: 6px; border-radius: 8px; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-foreground) 10%, transparent); }
.settings-theme-preview > i { border-radius: 3px; }
.settings-theme-preview > i:first-child { width: 22%; }
.settings-theme-preview > i:last-child { flex: 1; }
/* 缩略图画的是各主题本身的颜色,不随当前主题变化,所以写死 theme.css 里对应的值。 */
[data-theme-preview='dark'] .settings-theme-preview { background: #121212; }
[data-theme-preview='dark'] .settings-theme-preview > i:first-child { background: #232326; }
[data-theme-preview='dark'] .settings-theme-preview > i:last-child { background: #1c1c1e; }
[data-theme-preview='light'] .settings-theme-preview { background: #f3f3f6; }
[data-theme-preview='light'] .settings-theme-preview > i:first-child { background: #e8e8ed; }
[data-theme-preview='light'] .settings-theme-preview > i:last-child { background: #fff; }
/* 浅的一半用浅色主题的内容底色:用 #f3f3f6 会和浅色主题下的卡片融成一片,整张看起来只剩深的一半。 */
[data-theme-preview='system'] .settings-theme-preview { background: linear-gradient(90deg, #121212 50%, #fff 50%); }
[data-theme-preview='system'] .settings-theme-preview > i:first-child { background: #232326; }
.settings-theme-caption { display: flex; align-items: center; justify-content: space-between; gap: 4px; min-height: 18px; font-size: var(--text-ui-caption); }
.settings-theme-caption > svg { color: var(--color-brand); }
.settings-volume-icon { color: var(--color-foreground-subtle); }
.settings-volume-control { display: flex; align-items: center; gap: 10px; width: 220px; max-width: 100%; }
/* 浅的 accent 会让浏览器把未填充的一段画成近黑色,在浅色主题的卡片里很扎眼;brand 在浅色主题里是深绿,未填充的一段就是浅色。 */
.settings-volume-control .volume-slider { flex: 1; min-width: 0; accent-color: var(--color-brand); }
.settings-volume-control .volume-input-control { display: flex; align-items: center; }
/* 百分比平时像一段文字,悬停或聚焦时才显出输入框。 */
.settings-volume-control input.volume-input { box-sizing: border-box; width: 4.5ch; padding: 4px 2px; border-color: transparent; background: transparent; color: var(--color-foreground-subtle); text-align: right; font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; -moz-appearance: textfield; }
.settings-volume-control input.volume-input:hover { border-color: var(--color-input-border); }
.settings-volume-control .volume-input::-webkit-inner-spin-button, .settings-volume-control .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
.settings-volume-control .volume-input { box-sizing: border-box; width: 5ch; padding: 6px 4px; text-align: center; font-variant-numeric: tabular-nums; }
.settings-volume-control .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
.settings-switch[type='checkbox'] { appearance: none; position: relative; width: 40px; height: 24px; margin: 0; border: 1px solid var(--color-border-hover); border-radius: 999px; background: var(--color-selected); cursor: pointer; }
.settings-switch[type='checkbox']::before { content: ''; display: block; width: 18px; height: 18px; margin: 2px; border-radius: 50%; background: var(--color-foreground); transform: translateX(0); transition: transform 150ms ease; }
.settings-switch[type='checkbox']:checked { border-color: var(--color-primary); background: var(--color-primary); }
.settings-switch[type='checkbox']:checked::before { background: var(--color-primary-foreground); transform: translateX(16px); }
:root[data-performance='low'] .settings-switch[type='checkbox']::before { transition: none; }
.settings-switch[type='checkbox']:focus-visible, .settings-row-control input[type='range']:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 4px; }
.settings-page-footer { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-shrink: 0; width: 100%; max-width: 1120px; margin: 0 auto; padding: 16px 40px 24px; }
.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-volume-control .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); }
/* 窄的内容区里,换到下一行的分段控件和音量占满整行。 */
@container section-layout (max-width: 480px) {
.settings-row-control:has(> :is(.segmented-control, .settings-volume-control)) { flex: 1 1 100%; }
.settings-row-control > .segmented-control { flex: 1; }
.settings-identity { padding: 18px; gap: 14px; }
.settings-identity-glow { left: 46px; width: 128px; }
.settings-identity-avatar { width: 56px; font-size: 21px; }
.settings-volume-control { flex: 1; width: auto; }
}
.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; }
.settings-content { gap: 28px; }
.settings-section h2 { margin-left: 4px; margin-bottom: 12px; }
.settings-group { padding: 0 16px; }
.settings-row { gap: 16px; min-height: 80px; padding: 16px 0; }
.settings-row-control { max-width: 160px; }
.settings-row-control .select { min-width: 100px; }
.settings-row-control .select-trigger { padding: 8px; }
.settings-row-field { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.settings-row-field .settings-row-control { width: 100%; max-width: none; }
.settings-page-footer { gap: 12px; padding: 12px 20px 16px; }
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import { api } from '@/core/api'
// 站点是否开放注册、是否开启 GitHub 登录。
export type Site = { status: 'loading' } | { status: 'failed' } | { status: 'ready', registrationOpen: boolean, githubLogin: boolean }
export function useSite(): { site: Site, reload: () => void } {
const [site, setSite] = useState<Site>({ status: 'loading' })
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
api.getSite().then(({ registrationOpen, githubLogin }) => {
if (active)
setSite({ status: 'ready', registrationOpen, githubLogin })
}, () => {
if (active)
setSite({ status: 'failed' })
})
return () => {
active = false
}
}, [attempt])
return {
site,
reload: () => {
setSite({ status: 'loading' })
setAttempt(count => count + 1)
},
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { isImeKey } from './ime'
describe('isImeKey', () => {
it('组字期间的按键交给输入法', () => {
expect(isImeKey({ isComposing: true, keyCode: 13 })).toBe(true)
})
it('在 Safari 里用回车确认候选词时,组字已经先结束,只剩 keyCode 229 能认出来', () => {
expect(isImeKey({ isComposing: false, keyCode: 229 })).toBe(true)
})
it('平时的回车和 Esc 交给输入框', () => {
expect(isImeKey({ isComposing: false, keyCode: 13 })).toBe(false)
expect(isImeKey({ isComposing: false, keyCode: 27 })).toBe(false)
})
})
+5
View File
@@ -0,0 +1,5 @@
// 这次按键属于输入法,比如用回车确认候选词、用 Esc 取消拼写,不是给输入框的。
// Safari 先结束组字再触发这次 keydown,isComposing 已经是 false,只能靠 keyCode 229 认出来。
export function isImeKey(event: Pick<KeyboardEvent, 'isComposing' | 'keyCode'>): boolean {
return event.isComposing || event.keyCode === 229
}
+67
View File
@@ -0,0 +1,67 @@
import type { InlineSaveEvent, InlineSaveState } from './inline-save'
import { useEffect, useRef, useState } from 'react'
import { inlineSave } from './inline-save'
const savedNoticeMs = 1500
// 文本的保存流程:失焦或回车时 submit,Esc 时 revert。onSave 失败时抛出 Error,它的 message 是给用户看的原因。
// submit 返回提交之后输入框里的值是否已经保存:没有改动也算已经保存;正在保存或者保存失败时返回 false。
export function useInlineSave(value: string, onSave: (value: string) => Promise<void>) {
const [state, setState] = useState<InlineSaveState>({ draft: value, saved: value, status: 'idle', error: null })
const [seenValue, setSeenValue] = useState(value)
const [savedNotice, setSavedNotice] = useState(0)
// state 要到下一次渲染才更新,回车之后紧跟着失焦时,由这个 ref 阻止重复保存。
const savingRef = useRef(false)
const apply = (event: InlineSaveEvent) => setState(current => inlineSave(current, event))
if (value !== seenValue) {
setSeenValue(value)
apply({ type: 'external', value })
}
useEffect(() => {
if (!savedNotice)
return
const timer = setTimeout(setSavedNotice, savedNoticeMs, 0)
return () => clearTimeout(timer)
}, [savedNotice])
const submit = async (): Promise<boolean> => {
if (savingRef.current)
return false
if (inlineSave(state, { type: 'submit' }).status !== 'saving') {
apply({ type: 'submit' })
return true
}
savingRef.current = true
const submitted = state.draft
const savedBefore = state.saved
apply({ type: 'submit' })
try {
await onSave(submitted)
setState((current) => {
const done = inlineSave(current, { type: 'succeeded', value: submitted })
// 保存期间外部传入了新值时以它为准:服务端可能改写了提交的值,比如去掉首尾空白。
return current.saved === savedBefore ? done : inlineSave(done, { type: 'external', value: current.saved })
})
setSavedNotice(count => count + 1)
return true
}
catch (error) {
apply({ type: 'failed', error: error instanceof Error && error.message ? error.message : '没有保存,再试一次。' })
return false
}
finally {
savingRef.current = false
}
}
return {
state,
// “已保存”在保存成功后显示 1.5 秒;又改了输入框就不再显示。
savedNotice: state.status === 'saved' && savedNotice > 0 && state.draft === state.saved,
edit: (draft: string) => apply({ type: 'edit', value: draft }),
submit,
revert: () => apply({ type: 'revert' }),
}
}
+9 -54
View File
@@ -1,11 +1,8 @@
import type { InlineSaveEvent, InlineSaveState } from '@/shared/lib/inline-save'
import { Check } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { inlineSave } from '@/shared/lib/inline-save'
import { useId } from 'react'
import { isImeKey } from '@/shared/lib/ime'
import { useInlineSave } from '@/shared/lib/use-inline-save'
const savedNoticeMs = 1500
// onSave 失败时抛出 Error,它的 message 就地显示在输入框下面。
export function InlineTextField({ label, value, onSave, placeholder, maxLength, type = 'text' }: {
label: string
value: string
@@ -15,52 +12,8 @@ export function InlineTextField({ label, value, onSave, placeholder, maxLength,
type?: 'text' | 'email'
}) {
const errorId = useId()
const [state, setState] = useState<InlineSaveState>({ draft: value, saved: value, status: 'idle', error: null })
const [seenValue, setSeenValue] = useState(value)
const [savedNotice, setSavedNotice] = useState(0)
// state 要到下一次渲染才更新,回车之后紧跟着失焦时,由这个 ref 阻止重复保存。
const savingRef = useRef(false)
const apply = (event: InlineSaveEvent) => setState(current => inlineSave(current, event))
if (value !== seenValue) {
setSeenValue(value)
apply({ type: 'external', value })
}
useEffect(() => {
if (!savedNotice)
return
const timer = setTimeout(setSavedNotice, savedNoticeMs, 0)
return () => clearTimeout(timer)
}, [savedNotice])
const submit = async () => {
if (savingRef.current || inlineSave(state, { type: 'submit' }).status !== 'saving') {
apply({ type: 'submit' })
return
}
savingRef.current = true
const submitted = state.draft
const savedBefore = state.saved
apply({ type: 'submit' })
try {
await onSave(submitted)
setState((current) => {
const done = inlineSave(current, { type: 'succeeded', value: submitted })
// 保存期间外部传入了新值时以它为准:服务端可能改写了提交的值,比如去掉首尾空白。
return current.saved === savedBefore ? done : inlineSave(done, { type: 'external', value: current.saved })
})
setSavedNotice(count => count + 1)
}
catch (error) {
apply({ type: 'failed', error: error instanceof Error && error.message ? error.message : '没有保存,再试一次。' })
}
finally {
savingRef.current = false
}
}
const noticeVisible = state.status === 'saving' || (state.status === 'saved' && savedNotice > 0 && state.draft === state.saved)
const { state, savedNotice, edit, submit, revert } = useInlineSave(value, onSave)
const noticeVisible = state.status === 'saving' || savedNotice
return (
<div className="inline-text-field">
<div className="inline-text-field-box">
@@ -73,9 +26,11 @@ export function InlineTextField({ label, value, onSave, placeholder, maxLength,
aria-invalid={state.status === 'error'}
aria-describedby={state.error ? errorId : undefined}
aria-busy={state.status === 'saving'}
onChange={event => apply({ type: 'edit', value: event.target.value })}
onChange={event => edit(event.target.value)}
onBlur={() => void submit()}
onKeyDown={(event) => {
if (isImeKey(event.nativeEvent))
return
if (event.key === 'Enter') {
event.preventDefault()
void submit()
@@ -83,7 +38,7 @@ export function InlineTextField({ label, value, onSave, placeholder, maxLength,
// 没有可恢复的内容时不拦截 Esc,外层的对话框照常关闭。
else if (event.key === 'Escape' && (state.draft !== state.saved || state.error)) {
event.preventDefault()
apply({ type: 'revert' })
revert()
}
}}
/>
+20 -13
View File
@@ -54,6 +54,7 @@ function moveFocus(event: KeyboardEvent<HTMLElement>): void {
// 宽屏:左侧分类栏加右侧内容,current 为 null 时按 defaultSection 高亮,children 也应当是它的内容。
// 窄屏:current 为 null 时只显示分类列表,header 在列表上方;否则只显示 children,顶部的返回按钮调用 onSelect(null)。
// header 里带 data-section 的元素是那个分类的入口,比如 hiddenInList 的那一项:从详情回到列表时,焦点落回它。
// children 里第一个 h2 是分类的标题:从分类栏切换过去之后,焦点落在它上面。
export function SectionLayout<T extends string>({ title, eyebrow, sections, current, defaultSection, onSelect, header, footer, children }: {
title: string
@@ -68,18 +69,27 @@ export function SectionLayout<T extends string>({ title, eyebrow, sections, curr
}) {
const isWide = useSyncExternalStore(subscribeWide, () => wide().matches)
const navRef = useRef<HTMLElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
// 地址改写之后 current 才会变,所以先记下切换完成后焦点要去哪里。
const focusAfterRef = useRef<{ to: 'content' } | { to: 'item', id: T } | null>(null)
// 地址改写之后 current 才会变,所以先记下切换完成后焦点要不要去标题。
const focusContentRef = useRef(false)
const previousRef = useRef(current)
const shown = current ?? defaultSection
useEffect(() => {
const target = focusAfterRef.current
focusAfterRef.current = null
if (target?.to === 'content')
const previous = previousRef.current
previousRef.current = current
const toContent = focusContentRef.current
focusContentRef.current = false
if (previous === current)
return
// 窄屏上列表和详情互相替换,宽屏上内容区按分类重新挂载:原来有焦点的元素已经不在了,焦点落到了 body。
// 切换也可能来自 header 里的入口或者浏览器的后退、前进;焦点还在分类栏或顶栏上时留在原处。
const narrow = !wide().matches
if (narrow && current === null)
[...listRef.current?.querySelectorAll<HTMLElement>('[data-section]') ?? []].find(item => item.dataset.section === previous)?.focus()
else if (toContent || (narrow && previous === null) || document.activeElement === document.body)
focusHeading(contentRef.current)
else if (target?.to === 'item')
[...navRef.current?.querySelectorAll<HTMLElement>('[data-section]') ?? []].find(item => item.dataset.section === target.id)?.focus()
}, [current])
// 窄的主区域里分类栏是横向滚动的一行,当前项可能在视野之外。
@@ -93,7 +103,7 @@ export function SectionLayout<T extends string>({ title, eyebrow, sections, curr
focusHeading(contentRef.current)
return
}
focusAfterRef.current = { to: 'content' }
focusContentRef.current = true
onSelect(id)
}
@@ -140,7 +150,7 @@ export function SectionLayout<T extends string>({ title, eyebrow, sections, curr
if (current === null) {
return (
<div className="section-layout">
<div className="section-layout-list" key="list">
<div ref={listRef} className="section-layout-list" key="list">
{heading}
{header}
<nav ref={navRef} className="section-group" aria-label={title} onKeyDown={moveFocus}>
@@ -167,10 +177,7 @@ export function SectionLayout<T extends string>({ title, eyebrow, sections, curr
type="button"
className="section-layout-back"
aria-label={`返回${title}`}
onClick={() => {
focusAfterRef.current = { to: 'item', id: current }
onSelect(null)
}}
onClick={() => onSelect(null)}
>
<ChevronLeft size={20} aria-hidden="true" />
{title}