From 052e29317625211bcb0875a87dc04026463eea32 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 11:42:08 +0800 Subject: [PATCH] feat: organize settings into sections that apply changes immediately --- web/src/app/app.tsx | 10 +- web/src/app/main.tsx | 2 +- web/src/features/playback/index.ts | 2 +- web/src/features/playback/reminder-store.ts | 5 - .../playlists/create-playlist-form.tsx | 3 +- ...ccount-section.tsx => account-dialogs.tsx} | 146 +------- web/src/features/settings/github.ts | 16 +- .../settings/notification-permission.test.ts | 104 ++++++ .../settings/notification-permission.ts | 38 ++ .../features/settings/profile-store.test.ts | 39 +- web/src/features/settings/profile-store.ts | 27 +- web/src/features/settings/setting-row.tsx | 53 +++ .../features/settings/settings-account.tsx | 345 ++++++++++++++++++ .../features/settings/settings-appearance.tsx | 94 +++++ .../features/settings/settings-labels.test.ts | 43 +++ web/src/features/settings/settings-labels.ts | 44 +++ web/src/features/settings/settings-page.tsx | 333 +++-------------- .../settings/settings-performance.tsx | 42 +++ .../features/settings/settings-playback.tsx | 78 ++++ .../features/settings/settings-privacy.tsx | 73 ++++ web/src/features/settings/settings.css | 142 ++++--- web/src/features/settings/use-site.ts | 30 ++ web/src/shared/lib/ime.test.ts | 17 + web/src/shared/lib/ime.ts | 5 + web/src/shared/lib/use-inline-save.ts | 67 ++++ web/src/shared/ui/inline-text-field.tsx | 63 +--- web/src/shared/ui/section-layout.tsx | 33 +- 27 files changed, 1282 insertions(+), 572 deletions(-) rename web/src/features/settings/{account-section.tsx => account-dialogs.tsx} (50%) create mode 100644 web/src/features/settings/notification-permission.test.ts create mode 100644 web/src/features/settings/notification-permission.ts create mode 100644 web/src/features/settings/setting-row.tsx create mode 100644 web/src/features/settings/settings-account.tsx create mode 100644 web/src/features/settings/settings-appearance.tsx create mode 100644 web/src/features/settings/settings-labels.test.ts create mode 100644 web/src/features/settings/settings-labels.ts create mode 100644 web/src/features/settings/settings-performance.tsx create mode 100644 web/src/features/settings/settings-playback.tsx create mode 100644 web/src/features/settings/settings-privacy.tsx create mode 100644 web/src/features/settings/use-site.ts create mode 100644 web/src/shared/lib/ime.test.ts create mode 100644 web/src/shared/lib/ime.ts create mode 100644 web/src/shared/lib/use-inline-save.ts diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx index 3300964..db39c5c 100644 --- a/web/src/app/app.tsx +++ b/web/src/app/app.tsx @@ -193,7 +193,13 @@ function RoutedSettings() { const route = useStore(navigationStore, s => s.route) if (route.name !== 'settings') return null - return + return ( + 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' && ( 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' && } diff --git a/web/src/app/main.tsx b/web/src/app/main.tsx index 996fec4..6e8c6fe 100644 --- a/web/src/app/main.tsx +++ b/web/src/app/main.tsx @@ -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() diff --git a/web/src/features/playback/index.ts b/web/src/features/playback/index.ts index 3471b07..947ee0c 100644 --- a/web/src/features/playback/index.ts +++ b/web/src/features/playback/index.ts @@ -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' diff --git a/web/src/features/playback/reminder-store.ts b/web/src/features/playback/reminder-store.ts index 956a785..46e14e7 100644 --- a/web/src/features/playback/reminder-store.ts +++ b/web/src/features/playback/reminder-store.ts @@ -19,11 +19,6 @@ export function systemNotificationsSupported(): boolean { return 'Notification' in window && 'serviceWorker' in navigator } -// 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。 -export async function requestNotificationPermission(): Promise { - return await Notification.requestPermission() === 'granted' -} - class ReminderStore extends Store { #room: RoomState | null = null #firstSeen = new Map() diff --git a/web/src/features/playlists/create-playlist-form.tsx b/web/src/features/playlists/create-playlist-form.tsx index 8acbe54..6c8abf5 100644 --- a/web/src/features/playlists/create-playlist-form.tsx +++ b/web/src/features/playlists/create-playlist-form.tsx @@ -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
{ - if (event.key !== 'Escape' || event.nativeEvent.isComposing) + if (event.key !== 'Escape' || isImeKey(event.nativeEvent)) return // 拦下对话框的 Esc:在“保存到歌单”里,Esc 只回到列表。 event.preventDefault() diff --git a/web/src/features/settings/account-section.tsx b/web/src/features/settings/account-dialogs.tsx similarity index 50% rename from web/src/features/settings/account-section.tsx rename to web/src/features/settings/account-dialogs.tsx index 0a51476..1df678f 100644 --- a/web/src/features/settings/account-section.tsx +++ b/web/src/features/settings/account-dialogs.tsx @@ -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 { event.preventDefault() - event.stopPropagation() void run() } -// 返回 false 时,失败原因已经通过通知显示;成功时页面随即跳到 GitHub。 -async function goToGitHub(intent: GitHubIntent, currentPassword?: string): Promise { - 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(() => 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 ( - <> -
-
-

{account ? account.username ?? '用 GitHub 登录的账号' : '游客'}

- {description &&

{description}

} -
- {member && ( -
- {account - ? ( - <> - - {account.username - ? - : } - - - ) - : ( - <> - {site.registrationOpen && } - - {site.githubLogin && } - - - )} -
- )} - {(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && setDialog(null)} />} - {dialog === 'password' && setDialog(null)} />} - {dialog === 'pairing' && setDialog(null)} />} - {dialog === 'redeem' && ( - setDialog(null)}> - - - )} -
- {account && site.githubLogin && } - - ) -} - -function GitHubRow({ account }: { account: Account }) { - const [dialog, setDialog] = useState<'bind' | 'unbind' | null>(null) - const unbind = async (currentPassword: string): Promise => { - 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 ( -
-
-

GitHub

-

{description}

-
-
- {account.github - ? - : } -
- {dialog && ( - goToGitHub('bind', currentPassword) : unbind} - onClose={() => setDialog(null)} - /> - )} -
- ) -} - // 绑定和解绑 GitHub 都会更换登录方式,先输入当前密码:只凭这台设备上的登录不能更换。onConfirm 返回 false 时,失败原因已经通过通知显示; // 绑定成功时页面随即跳到 GitHub,对话框保持忙碌。 -function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise, onClose: () => void }) { +export function CurrentPasswordDialog({ title, action, onConfirm, onClose }: { title: string, action: string, onConfirm: (currentPassword: string) => Promise, 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('') diff --git a/web/src/features/settings/github.ts b/web/src/features/settings/github.ts index 324d2ed..0901000 100644 --- a/web/src/features/settings/github.ts +++ b/web/src/features/settings/github.ts @@ -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 { + 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 } diff --git a/web/src/features/settings/notification-permission.test.ts b/web/src/features/settings/notification-permission.test.ts new file mode 100644 index 0000000..9b700da --- /dev/null +++ b/web/src/features/settings/notification-permission.test.ts @@ -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 { + 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 }) + }) +}) diff --git a/web/src/features/settings/notification-permission.ts b/web/src/features/settings/notification-permission.ts new file mode 100644 index 0000000..7967259 --- /dev/null +++ b/web/src/features/settings/notification-permission.ts @@ -0,0 +1,38 @@ +import { Store } from '@/shared/lib/store' + +interface NotificationPermissionState { + // 浏览器不通知权限的变化,构造时和每次申请之后各读一次。 + permission: NotificationPermission + waiting: boolean + // 最近一次申请没有拿到授权。 + refused: boolean +} + +// 浏览器的授权提示可能一直不返回:用户没理会,或者浏览器把提示收进了地址栏。所以等待期间可以放弃; +// 放弃之后提示才返回时只更新权限,不算这次申请的结果。 +export class NotificationPermissionRequest extends Store { + #latest = 0 + + constructor() { + super({ permission: Notification.permission, waiting: false, refused: false }) + } + + // 只能在用户操作的事件里调用:Safari 和 Firefox 不接受其他时候发起的权限申请。 + // 返回这次申请是否拿到了授权;等待期间被放弃,或者又发起了新的申请时为 false。 + async request(): Promise { + 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 }) + } +} diff --git a/web/src/features/settings/profile-store.test.ts b/web/src/features/settings/profile-store.test.ts index 89d325f..aef9fec 100644 --- a/web/src/features/settings/profile-store.test.ts +++ b/web/src/features/settings/profile-store.test.ts @@ -39,8 +39,8 @@ let patchError: { status: number, code: string } | null let extraRoutes: Map 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 () => { diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts index 23365ff..aa93b41 100644 --- a/web/src/features/settings/profile-store.ts +++ b/web/src/features/settings/profile-store.ts @@ -67,25 +67,27 @@ class ProfileStore extends Store { return true } - // 返回 false 时,失败原因已经通过通知显示。 - async save(changes: { name?: string, email?: string }): Promise { - return await this.#update(changes) === null + // 一次只改一项,失败时才能说清是哪一项不合格:客户端不能按服务端的 message 区分字段。失败时抛出 Error,message 是给用户看的原因。 + async save(changes: { name: string } | { email: string }): Promise { + 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 { + // 失败时抛出 Error,message 是给用户看的原因。 + async setRecordListens(on: boolean): Promise { 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 { try { const { member } = await api.updateMe(changes) @@ -109,8 +111,6 @@ class ProfileStore extends Store { 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 { 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) diff --git a/web/src/features/settings/setting-row.tsx b/web/src/features/settings/setting-row.tsx new file mode 100644 index 0000000..e8da09b --- /dev/null +++ b/web/src/features/settings/setting-row.tsx @@ -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 ( +
+
+ {labelled ? : {title}} + {description &&

{description}

} + {error && } +
+
+ {status} + {children} +
+
+ ) +} + +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 +} + +export function SwitchRow({ id, title, description, checked, onSave }: { + id: string + title: string + description: ReactNode + checked: boolean + onSave: (checked: boolean) => void | Promise +}) { + const setting = useSettingSave(checked, onSave) + return ( + }> + + + ) +} diff --git a/web/src/features/settings/settings-account.tsx b/web/src/features/settings/settings-account.tsx new file mode 100644 index 0000000..bce3db0 --- /dev/null +++ b/web/src/features/settings/settings-account.tsx @@ -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 ( + + ) +} + +// 编辑时才挂载:每次开始编辑都从当前的昵称开始,保存中和失败的状态随编辑结束一起丢掉。 +function NameInput({ name, onDone }: { name: string, onDone: (saved: boolean, refocus: boolean) => void }) { + const inputRef = useRef(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 ( + <> +
+ 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) + } + }} + /> + +
+ {state.error && } + + ) +} + +function NameEditor({ name }: { name: string }) { + const [editing, setEditing] = useState(false) + const [notice, setNotice] = useState(0) + const pencilRef = useRef(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 ( + { + refocusRef.current = refocus + setEditing(false) + setNotice(count => saved ? count + 1 : 0) + }} + /> + ) + } + return ( +
+ {/* 点名字也能编辑;键盘用旁边的铅笔按钮。 */} + setEditing(true)}>{name} + + +
+ ) +} + +function IdentityCard({ member, account }: { member: Member, account: Account | null }) { + return ( +
+ {/* 有头像时用它模糊后的样子做光晕;没有头像时这一层只有首字,由 CSS 隐藏。 */} + + +
+ +

{identityMeta(account)}

+ {!account &&

注册之后可以在其他设备上登录

} + {account?.github && GitHub 已绑定} +
+
+ ) +} + +function AccountSkeleton() { + return ( +
+
+ +
+ + +
+
+

+
+
+ ) +} + +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(() => 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 => { + 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 = ( + 重试}> + 可能是网络中断,检查网络后再试。 + + ) + } + else if (!member || site.status !== 'ready') { + content = + } + else { + content = ( + <> + + {!account && ( +
+ {site.registrationOpen && } + + {site.githubLogin && } + +
+ )} + +

资料

+
+
+
+ 邮箱 +

{account ? '用来显示头像,各设备一致' : '用来显示头像,只保存在这台设备上'}

+
+ profileStore.save({ email: value.trim() })} /> +
+
+ + {account && ( + <> +

登录方式

+
+
+
+ {account.username ? '密码' : '用户名和密码'} +

{account.username ? '修改后,其他设备需要重新登录' : '设置之后可以用密码登录'}

+
+ {account.username + ? + : } +
+ {site.githubLogin && ( +
+
+ GitHub +

+ {!account.github ? '绑定之后可以用 GitHub 登录这个账号' : account.username ? '可以用 GitHub 登录这个账号' : '先设置用户名和密码,才能解绑'} +

+
+
+ {account.github && 已绑定} + {account.github + ? + : } +
+
+ )} +
+ +

设备

+
+ + +
+ + )} + + ) + } + + return ( + <> +

账号

+ {content} + {(dialog === 'register' || dialog === 'login' || dialog === 'credentials') && } + {dialog === 'password' && } + {dialog === 'pairing' && } + {dialog === 'redeem' && ( + + + + )} + {(dialog === 'bind' || dialog === 'unbind') && ( + goToGitHub('bind', currentPassword) : unbind} + onClose={close} + /> + )} + {dialog === 'logout' && ( + { + await profileStore.logout() + }} + onClose={close} + > + 退出之后,这台设备会换成新的游客身份。 + + )} + + ) +} diff --git a/web/src/features/settings/settings-appearance.tsx b/web/src/features/settings/settings-appearance.tsx new file mode 100644 index 0000000..14fd6f2 --- /dev/null +++ b/web/src/features/settings/settings-appearance.tsx @@ -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(null) + const selected = themes.indexOf(setting.value) + // 方向键在选项之间移动并选中,和原生的单选按钮一样。 + const onKeyDown = (event: KeyboardEvent) => { + const next = rovingIndex(event.key, selected, themes.length) + if (next === null) + return + event.preventDefault() + groupRef.current?.querySelectorAll('[role="radio"]')[next]?.focus() + setting.change(themes[next]) + } + return ( + <> +
+ 主题 + +
+
+ {themes.map((theme, index) => ( + + ))} +
+ {setting.state.error && } + + ) +} + +function FontSizeRow() { + const saved = useStore(appearanceStore, s => String(s.fontSize)) + const setting = useSettingSave(saved, (size: string) => appearanceStore.update(appearanceStore.state.theme, Number(size))) + return ( + } + labelled={false} + > + ({ value: String(size), label: String(size) }))} + onChange={setting.change} + /> + + ) +} + +export function AppearanceSettings() { + return ( + <> +

外观

+ +

界面字号

+
+ +
+ + ) +} diff --git a/web/src/features/settings/settings-labels.test.ts b/web/src/features/settings/settings-labels.test.ts new file mode 100644 index 0000000..23e2f81 --- /dev/null +++ b/web/src/features/settings/settings-labels.test.ts @@ -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('浏览器没有授权,可以在浏览器的网站设置里允许通知。') + }) +}) diff --git a/web/src/features/settings/settings-labels.ts b/web/src/features/settings/settings-labels.ts new file mode 100644 index 0000000..9fde825 --- /dev/null +++ b/web/src/features/settings/settings-labels.ts @@ -0,0 +1,44 @@ +import type { Theme } from './appearance-store' +import type { PerformanceLevel } from './performance-store' + +export const themeNames: Record = { dark: '深色', light: '浅色', system: '跟随系统' } + +export const levelNames: Record = { + 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' ? '浏览器拒绝了显示通知。' : '浏览器没有授权,可以在浏览器的网站设置里允许通知。' +} diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index fcec6ef..3a917c6 100644 --- a/web/src/features/settings/settings-page.tsx +++ b/web/src/features/settings/settings-page.tsx @@ -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 = { dark: '深色', light: '浅色', system: '跟随系统' } - -const levelNames: Record = { - 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 ( -
-
- -

{description}

-
-
{children}
-
- ) -} - -export function SettingsPage(_: { section: SettingsSection | null }) { - const headingRef = useRef(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>({}) - 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 = (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[] = [ + { 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 ( -
-
-

设置

-
- { - event.preventDefault() - void save() - }} - > -
-
-
-
-

账号

-
- - - {!member && profileFailed - ? - : update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />} - - - update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" /> - -
-
-
-

外观

-
- - update('fontSize', Number(value))} - options={fontSizes.map(size => ({ value: String(size), label: `${size} px` }))} - /> - -
-
-
-

播放

-
- - update('volume', next)} /> - - - update('muted', event.target.checked)} /> - - - update('reminderEnabled', event.target.checked)} /> - - {systemNotificationsSupported() && ( - - toggleSystemNotifications(event.target.checked)} /> - - )} -
-
-
-

性能

-
- - update('reducedMotion', event.target.checked)} /> - -
-
-
-

历史记录

-
- - update('searchesEnabled', event.target.checked)} /> - - - update('recordListens', event.target.checked)} /> - -
-
-
-
-
-
-

{dirty ? '有未保存的更改' : '所有更改已保存'}

- -
- -
+ onSection('account')} />} + > + {shown === 'account' && } + {shown === 'appearance' && } + {shown === 'playback' && } + {shown === 'performance' && } + {shown === 'privacy' && } + ) } diff --git a/web/src/features/settings/settings-performance.tsx b/web/src/features/settings/settings-performance.tsx new file mode 100644 index 0000000..0b8a2b5 --- /dev/null +++ b/web/src/features/settings/settings-performance.tsx @@ -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 ( + } labelled={false}> + ({ value: level, label: levelNames[level].name }))} + onChange={setting.change} + /> + + ) +} + +export function PerformanceSettings() { + const reducedMotion = useStore(performanceStore, s => s.reducedMotion) + return ( + <> +

性能

+
+ + performanceStore.update(performanceStore.state.level, on)} + /> +
+ + ) +} diff --git a/web/src/features/settings/settings-playback.tsx b/web/src/features/settings/settings-playback.tsx new file mode 100644 index 0000000..0649622 --- /dev/null +++ b/web/src/features/settings/settings-playback.tsx @@ -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 ( + } + > + {hint.authorize && } + void change(on)} /> + + ) +} + +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 ( + <> +

播放与提醒

+
+ + + volumeStore.setMuted(on)} /> + reminderStore.updateSettings({ enabled: on, systemNotifications: reminderStore.state.systemNotifications })} + /> + {systemNotificationsSupported() && } +
+ + ) +} diff --git a/web/src/features/settings/settings-privacy.tsx b/web/src/features/settings/settings-privacy.tsx new file mode 100644 index 0000000..ac7a79e --- /dev/null +++ b/web/src/features/settings/settings-privacy.tsx @@ -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 ( + + {failed + ? + : + ) + } + return ( + 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 ( + } labelled={false}> + ({ value: String(limit), label: String(limit) }))} + onChange={setting.change} + /> + + ) +} + +export function PrivacySettings({ onOpenHistory }: { onOpenHistory: () => void }) { + const searchesEnabled = useStore(historyStore, s => s.searchesEnabled) + return ( + <> +

隐私与记录

+
+ + + historyStore.updateSettings({ searchesEnabled: on, searchLimit: historyStore.state.searchLimit })} + /> + {searchesEnabled && } +
+ + ) +} diff --git a/web/src/features/settings/settings.css b/web/src/features/settings/settings.css index 4f8753b..83e15f1 100644 --- a/web/src/features/settings/settings.css +++ b/web/src/features/settings/settings.css @@ -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; } -} diff --git a/web/src/features/settings/use-site.ts b/web/src/features/settings/use-site.ts new file mode 100644 index 0000000..c842619 --- /dev/null +++ b/web/src/features/settings/use-site.ts @@ -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({ 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) + }, + } +} diff --git a/web/src/shared/lib/ime.test.ts b/web/src/shared/lib/ime.test.ts new file mode 100644 index 0000000..553cfaa --- /dev/null +++ b/web/src/shared/lib/ime.test.ts @@ -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) + }) +}) diff --git a/web/src/shared/lib/ime.ts b/web/src/shared/lib/ime.ts new file mode 100644 index 0000000..c7f0d7d --- /dev/null +++ b/web/src/shared/lib/ime.ts @@ -0,0 +1,5 @@ +// 这次按键属于输入法,比如用回车确认候选词、用 Esc 取消拼写,不是给输入框的。 +// Safari 先结束组字再触发这次 keydown,isComposing 已经是 false,只能靠 keyCode 229 认出来。 +export function isImeKey(event: Pick): boolean { + return event.isComposing || event.keyCode === 229 +} diff --git a/web/src/shared/lib/use-inline-save.ts b/web/src/shared/lib/use-inline-save.ts new file mode 100644 index 0000000..312c413 --- /dev/null +++ b/web/src/shared/lib/use-inline-save.ts @@ -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) { + const [state, setState] = useState({ 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 => { + 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' }), + } +} diff --git a/web/src/shared/ui/inline-text-field.tsx b/web/src/shared/ui/inline-text-field.tsx index 4d48adf..89ad2f4 100644 --- a/web/src/shared/ui/inline-text-field.tsx +++ b/web/src/shared/ui/inline-text-field.tsx @@ -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({ 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 (
@@ -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() } }} /> diff --git a/web/src/shared/ui/section-layout.tsx b/web/src/shared/ui/section-layout.tsx index 6d547a0..f89b67b 100644 --- a/web/src/shared/ui/section-layout.tsx +++ b/web/src/shared/ui/section-layout.tsx @@ -54,6 +54,7 @@ function moveFocus(event: KeyboardEvent): void { // 宽屏:左侧分类栏加右侧内容,current 为 null 时按 defaultSection 高亮,children 也应当是它的内容。 // 窄屏:current 为 null 时只显示分类列表,header 在列表上方;否则只显示 children,顶部的返回按钮调用 onSelect(null)。 +// header 里带 data-section 的元素是那个分类的入口,比如 hiddenInList 的那一项:从详情回到列表时,焦点落回它。 // children 里第一个 h2 是分类的标题:从分类栏切换过去之后,焦点落在它上面。 export function SectionLayout({ title, eyebrow, sections, current, defaultSection, onSelect, header, footer, children }: { title: string @@ -68,18 +69,27 @@ export function SectionLayout({ title, eyebrow, sections, curr }) { const isWide = useSyncExternalStore(subscribeWide, () => wide().matches) const navRef = useRef(null) + const listRef = useRef(null) const contentRef = useRef(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('[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('[data-section]') ?? []].find(item => item.dataset.section === target.id)?.focus() }, [current]) // 窄的主区域里分类栏是横向滚动的一行,当前项可能在视野之外。 @@ -93,7 +103,7 @@ export function SectionLayout({ title, eyebrow, sections, curr focusHeading(contentRef.current) return } - focusAfterRef.current = { to: 'content' } + focusContentRef.current = true onSelect(id) } @@ -140,7 +150,7 @@ export function SectionLayout({ title, eyebrow, sections, curr if (current === null) { return (
-
+
{heading} {header}