From d8e6116fd6f1a2c0ee857fce279212586281e10e Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 11:05:49 +0800 Subject: [PATCH] feat: add sectioned page layout, switches, segmented controls, paging and neutral avatars --- web/nginx/default.conf | 2 +- web/src/app/app.tsx | 20 ++- web/src/app/navigation.ts | 17 +- web/src/app/route.test.ts | 71 +++++++- web/src/app/route.ts | 76 ++++++-- web/src/features/admin/admin-page.tsx | 5 +- web/src/features/admin/index.ts | 1 + web/src/features/settings/index.ts | 1 + web/src/features/settings/settings-page.tsx | 4 +- web/src/shared/lib/inline-save.test.ts | 74 ++++++++ web/src/shared/lib/inline-save.ts | 45 +++++ web/src/shared/lib/page-items.test.ts | 21 +++ web/src/shared/lib/page-items.ts | 20 +++ web/src/shared/lib/range-selection.test.ts | 35 ++++ web/src/shared/lib/range-selection.ts | 18 ++ web/src/shared/lib/roving.test.ts | 24 +++ web/src/shared/lib/roving.ts | 19 ++ web/src/shared/lib/session-history.test.ts | 42 ++++- web/src/shared/lib/session-history.ts | 15 +- web/src/shared/ui/index.ts | 7 + web/src/shared/ui/inline-text-field.tsx | 103 +++++++++++ web/src/shared/ui/pagination.tsx | 63 +++++++ web/src/shared/ui/section-layout.css | 60 +++++++ web/src/shared/ui/section-layout.tsx | 182 ++++++++++++++++++++ web/src/shared/ui/segmented-control.tsx | 46 +++++ web/src/shared/ui/switch.tsx | 23 +++ web/src/shared/ui/theme.css | 13 +- web/src/shared/ui/ui.css | 47 ++++- web/vite.config.ts | 2 +- 29 files changed, 1018 insertions(+), 38 deletions(-) create mode 100644 web/src/shared/lib/inline-save.test.ts create mode 100644 web/src/shared/lib/inline-save.ts create mode 100644 web/src/shared/lib/page-items.test.ts create mode 100644 web/src/shared/lib/page-items.ts create mode 100644 web/src/shared/lib/range-selection.test.ts create mode 100644 web/src/shared/lib/range-selection.ts create mode 100644 web/src/shared/lib/roving.test.ts create mode 100644 web/src/shared/lib/roving.ts create mode 100644 web/src/shared/ui/inline-text-field.tsx create mode 100644 web/src/shared/ui/pagination.tsx create mode 100644 web/src/shared/ui/section-layout.css create mode 100644 web/src/shared/ui/section-layout.tsx create mode 100644 web/src/shared/ui/segmented-control.tsx create mode 100644 web/src/shared/ui/switch.tsx diff --git a/web/nginx/default.conf b/web/nginx/default.conf index 76cd177..ba350ef 100644 --- a/web/nginx/default.conf +++ b/web/nginx/default.conf @@ -22,7 +22,7 @@ server { include /tmp/alisten.d/*.conf; - location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ { + location ~ ^/(?:settings(?:/(?:account|appearance|playback|performance|privacy))?|admin(?:/(?:site|registration|users|rooms|sources|media))?|history|playlists(?:/[^/]+)?)?$ { add_header Cache-Control "no-cache"; try_files /index.html =404; } diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx index 032b11c..ac934da 100644 --- a/web/src/app/app.tsx +++ b/web/src/app/app.tsx @@ -188,6 +188,20 @@ function RoutedLobby({ onEntered }: { onEntered: (ref: string) => void }) { return navigationStore.navigate({ ...route, filter })} onEntered={onEntered} /> } +function RoutedSettings() { + const route = useStore(navigationStore, s => s.route) + if (route.name !== 'settings') + return null + return +} + +function RoutedAdmin({ onEntered }: { onEntered: (ref: string) => void }) { + const route = useStore(navigationStore, s => s.route) + if (route.name !== 'admin') + return null + return +} + function RoutedHistory() { const route = useStore(navigationStore, s => s.route) if (route.name !== 'history') @@ -213,7 +227,7 @@ function RoutedGitHubLogin() { const route = useStore(navigationStore, s => s.route) if (route.name !== 'github-login') return null - return navigationStore.replace({ name: 'settings', panel: null })} /> + return navigationStore.replace({ name: 'settings', section: 'account', panel: null })} /> } function RoutedSearch() { @@ -287,7 +301,7 @@ export function App() { {page === 'lobby' && } - {page === 'admin' && } + {page === 'admin' && } {page === 'listen' && } {page === 'queue' && } {page === 'search' && } @@ -301,7 +315,7 @@ export function App() { /> )} {page === 'my-playlist' && } - {page === 'settings' && } + {page === 'settings' && } {page === 'shared-playlist' && } {page === 'pair' && } {page === 'github-login' && } diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index e3352d9..2f7b5ca 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -4,7 +4,7 @@ import { roomEntryStore } from '@/features/rooms' import { searchStore } from '@/features/search' import { canGoBack, canGoForward, currentEntry, goBack, startHistory, writeEntry } from '@/shared/lib/session-history' import { Store } from '@/shared/lib/store' -import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route' +import { formatRoute, isNewEntry, leaveStep, parseRoute, upStep } from './route' interface NavigationState { route: Route @@ -65,12 +65,25 @@ class NavigationStore extends Store { this.#show(route) } + // 从设置或后台的分类回到分类列表,不新增记录。 + up(list: Route): void { + const { position, from } = currentEntry() + if (upStep(list, from, canGoBack(position)) === 'back') + goBack() + else + this.replace(list) + } + // 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。 open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void { const { panel } = this.state.route if (page === 'lobby' || page === 'history') this.navigate({ name: page, filter: '', panel }) - else if (page === 'admin' || page === 'settings' || page === 'playlists') + else if (page === 'settings') + this.navigate({ name: page, section: null, panel }) + else if (page === 'admin') + this.navigate({ name: page, section: null, panel, q: '', page: 1, sort: 'stored' }) + else if (page === 'playlists') this.navigate({ name: page, panel }) else this.#openRoomPage(ref => ({ name: page, ref, panel })) diff --git a/web/src/app/route.test.ts b/web/src/app/route.test.ts index 5034bf1..f81ba8b 100644 --- a/web/src/app/route.test.ts +++ b/web/src/app/route.test.ts @@ -1,7 +1,9 @@ import type { Route } from './route' +import type { MediaSort } from '@/core/realtime' +import type { AdminSection } from '@/features/admin' import type { SearchQuery } from '@/features/search' import { describe, expect, it } from 'vitest' -import { formatRoute, isNewEntry, leaveStep, parseRoute } from './route' +import { formatRoute, isNewEntry, leaveStep, parseRoute, upStep } from './route' function parse(url: string): Route | null { return parseRoute(new URL(url, 'https://alisten.test')) @@ -11,8 +13,12 @@ describe('parseRoute', () => { it.each<[string, Route]>([ ['/', { name: 'lobby', filter: '', panel: null }], ['/?q=+%E5%A4%9C&panel=chat', { name: 'lobby', filter: ' 夜', panel: 'chat' }], - ['/admin', { name: 'admin', panel: null }], - ['/settings?panel=queue', { name: 'settings', panel: 'queue' }], + ['/admin', { name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }], + ['/admin/site', { name: 'admin', section: 'site', panel: null, q: '', page: 1, sort: 'stored' }], + ['/admin/rooms?q=jazz&page=2&panel=chat', { name: 'admin', section: 'rooms', panel: 'chat', q: 'jazz', page: 2, sort: 'stored' }], + ['/admin/media?q=lemon&sort=size&page=3', { name: 'admin', section: 'media', panel: null, q: 'lemon', page: 3, sort: 'size' }], + ['/settings?panel=queue', { name: 'settings', section: null, panel: 'queue' }], + ['/settings/privacy', { name: 'settings', section: 'privacy', panel: null }], ['/history', { name: 'history', filter: '', panel: null }], ['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }], ['/playlists', { name: 'playlists', panel: null }], @@ -36,6 +42,9 @@ describe('parseRoute', () => { it.each([ ['/admin?from=menu', '/admin'], + ['/admin/users?q=a&page=2&sort=size', '/admin/users'], + ['/admin/rooms?sort=size', '/admin/rooms'], + ['/admin/media?sort=idle&page=1', '/admin/media?sort=idle'], ['/settings?panel=lyrics', '/settings'], ['/?q=', '/'], ['/r/my-room?panel=lyrics', '/r/my-room'], @@ -46,15 +55,39 @@ describe('parseRoute', () => { ['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'], ['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'], ['/r/my-room/playlists/netease/123?page=1.5', '/r/my-room/playlists/netease/123'], + ['/admin/media?page=99999999999999999999', '/admin/media'], + ['/admin/media?page=1e300', '/admin/media'], + ['/admin/media?page=9007199254740991', '/admin/media?page=9007199254740991'], + ['/r/my-room/playlists/netease/123?page=99999999999999999999', '/r/my-room/playlists/netease/123'], ])('%s 规范化为 %s', (url, canonical) => { const route = parse(url) expect(route && formatRoute(route)).toBe(canonical) }) - it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => { + it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => { expect(parse(url)).toBeNull() }) + it('解析设置和后台的分类', () => { + expect(parseRoute({ pathname: '/settings/appearance', search: '', hash: '' })).toEqual({ name: 'settings', section: 'appearance', panel: null }) + expect(parseRoute({ pathname: '/settings', search: '', hash: '' })).toEqual({ name: 'settings', section: null, panel: null }) + expect(parseRoute({ pathname: '/settings/unknown', search: '', hash: '' })).toBeNull() + expect(parseRoute({ pathname: '/admin/media', search: '?q=lemon&sort=size&page=3', hash: '' })).toEqual({ name: 'admin', section: 'media', panel: null, q: 'lemon', page: 3, sort: 'size' }) + expect(parseRoute({ pathname: '/admin/media', search: '?sort=bogus&page=0', hash: '' })).toEqual({ name: 'admin', section: 'media', panel: null, q: '', page: 1, sort: 'stored' }) + }) + + it('媒体的搜索词截到接口允许的 100 个字符,按码点计;房间的搜索词在本地筛选,不截', () => { + const long = `${'a'.repeat(99)}🎵🎵` + expect(parse(`/admin/media?q=${encodeURIComponent(long)}`)).toMatchObject({ section: 'media', q: `${'a'.repeat(99)}🎵` }) + expect(parse(`/admin/media?q=${'b'.repeat(100)}`)).toMatchObject({ q: 'b'.repeat(100) }) + expect(parse(`/admin/rooms?q=${'c'.repeat(150)}`)).toMatchObject({ section: 'rooms', q: 'c'.repeat(150) }) + }) + + it('写回地址时省略默认值', () => { + expect(formatRoute({ name: 'admin', section: 'media', panel: null, q: '', page: 1, sort: 'stored' })).toBe('/admin/media') + expect(formatRoute({ name: 'admin', section: 'rooms', panel: null, q: 'jazz', page: 2, sort: 'stored' })).toBe('/admin/rooms?q=jazz&page=2') + }) + it('配对码从片段中读出,写回的地址不再带它', () => { const route = parse('/pair?panel=chat#ABCD2345') expect(route).toEqual({ name: 'pair', code: 'ABCD2345', panel: 'chat' }) @@ -80,10 +113,20 @@ describe('isNewEntry', () => { it('开关侧栏和输入筛选词只替换当前地址', () => { expect(isNewEntry({ name: 'queue', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: 'chat' })).toBe(false) - expect(isNewEntry({ name: 'settings', panel: null }, { name: 'settings', panel: 'chat' })).toBe(false) + expect(isNewEntry({ name: 'settings', section: null, panel: null }, { name: 'settings', section: null, panel: 'chat' })).toBe(false) expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false) }) + it('换分类写入新记录,换搜索词、排序和页码只替换当前地址', () => { + const admin = (section: AdminSection | null, q: string, page: number, sort: MediaSort): Route => ({ name: 'admin', section, panel: null, q, page, sort }) + expect(isNewEntry({ name: 'settings', section: null, panel: null }, { name: 'settings', section: 'account', panel: null })).toBe(true) + expect(isNewEntry({ name: 'settings', section: 'account', panel: null }, { name: 'settings', section: 'privacy', panel: null })).toBe(true) + expect(isNewEntry({ name: 'settings', section: 'privacy', panel: null }, { name: 'settings', section: null, panel: null })).toBe(true) + expect(isNewEntry(admin('rooms', '', 1, 'stored'), admin('media', '', 1, 'stored'))).toBe(true) + expect(isNewEntry(admin('media', '', 1, 'stored'), admin('media', 'lemon', 1, 'stored'))).toBe(false) + expect(isNewEntry(admin('media', 'lemon', 1, 'stored'), admin('media', 'lemon', 2, 'size'))).toBe(false) + }) + it('打开另一个自己的歌单写入新记录,同一个歌单只换侧栏时替换当前地址', () => { expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'b', panel: null })).toBe(true) expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'a', panel: 'queue' })).toBe(false) @@ -112,11 +155,27 @@ describe('isNewEntry', () => { }) }) +describe('upStep', () => { + const list: Route = { name: 'settings', section: null, panel: null } + + it('上一条记录就是分类列表时后退,不新增记录', () => { + expect(upStep(list, '/settings', true)).toBe('back') + expect(upStep({ name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }, '/admin', true)).toBe('back') + }) + + it('从别的页面直接打开分类、或者没有可以后退的记录时,把当前记录换成分类列表', () => { + expect(upStep(list, '/playlists', true)).toBe('replace') + expect(upStep(list, '/settings/account', true)).toBe('replace') + expect(upStep(list, undefined, true)).toBe('replace') + expect(upStep(list, '/settings', false)).toBe('replace') + }) +}) + describe('leaveStep', () => { const leaving = new Set(['my-room', 'a1b2c3']) it('不在这个房间的页面上时留在原处', () => { - expect(leaveStep({ name: 'admin', panel: null }, leaving, true)).toBe('stay') + expect(leaveStep({ name: 'admin', section: null, panel: null, q: '', page: 1, sort: 'stored' }, leaving, true)).toBe('stay') expect(leaveStep({ name: 'listen', ref: 'other-room', panel: null }, leaving, true)).toBe('stay') }) diff --git a/web/src/app/route.ts b/web/src/app/route.ts index 438d984..4849d0b 100644 --- a/web/src/app/route.ts +++ b/web/src/app/route.ts @@ -1,6 +1,8 @@ -import type { Source } from '@/core/realtime' +import type { MediaSort, Source } from '@/core/realtime' +import type { AdminSection } from '@/features/admin' import type { SearchSource } from '@/features/history' import type { SearchQuery } from '@/features/search' +import type { SettingsSection } from '@/features/settings' import { canOpenPlaylists, defaultSearchSource, keywordSearch } from '@/features/search' export type RoomPage = 'listen' | 'queue' | 'chat' @@ -9,8 +11,10 @@ export type Panel = 'queue' | 'history' | 'chat' // panel 是宽屏右侧的侧栏,和当前页面相同时不显示。 export type Route = | { name: 'lobby', filter: string, panel: Panel | null } - | { name: 'admin', panel: Panel | null } - | { name: 'settings', panel: Panel | null } + // section 为 null 时地址是 /admin 或 /settings:宽屏显示第一个分类,窄屏显示分类列表。 + // q 和 page 只用于后台的房间和媒体,sort 只用于媒体。 + | { name: 'admin', section: AdminSection | null, panel: Panel | null, q: string, page: number, sort: MediaSort } + | { name: 'settings', section: SettingsSection | null, panel: Panel | null } | { name: 'history', filter: string, panel: Panel | null } | { name: 'playlists', panel: Panel | null } | { name: 'my-playlist', id: string, panel: Panel | null } @@ -25,6 +29,20 @@ export type Route export type Page = Route['name'] +const settingsSections: Record = { account: true, appearance: true, playback: true, performance: true, privacy: true } +const adminSections: Record = { site: true, registration: true, users: true, rooms: true, sources: true, media: true } +const mediaSorts: Record = { stored: true, idle: true, size: true } +// 与接口对媒体搜索词的长度限制一致,按码点计:手改或截断的链接里更长的搜索词会让每次读取都失败。 +const mediaQueryMaxLength = 100 + +function isOneOf(values: Record, value: string): value is T { + return Object.hasOwn(values, value) +} + +function isPaged(section: AdminSection | null): boolean { + return section === 'rooms' || section === 'media' +} + function parseSource(value: string | null): SearchSource | null { return value === 'qq' || value === 'netease' ? value : null } @@ -34,9 +52,15 @@ function parsePanel(params: URLSearchParams): Panel | null { return panel === 'queue' || panel === 'history' || panel === 'chat' ? panel : null } +// 超出安全整数的页码写回地址时会变成另一个数,接口也不接受:当作第 1 页。 function parsePage(params: URLSearchParams): number { const page = Number(params.get('page')) - return Number.isInteger(page) && page > 1 ? page : 1 + return Number.isSafeInteger(page) && page > 1 ? page : 1 +} + +function parseAdminQuery(section: AdminSection | null, params: URLSearchParams): string { + const q = isPaged(section) ? params.get('q') ?? '' : '' + return section === 'media' ? [...q].slice(0, mediaQueryMaxLength).join('') : q } function parseQuery(params: URLSearchParams): SearchQuery { @@ -69,10 +93,26 @@ export function parseRoute({ pathname, search, hash }: Pick 1) params.set('page', String(route.page)) + if (route.name === 'admin' && isPaged(route.section)) { + if (route.q) + params.set('q', route.q) + if (route.section === 'media' && route.sort !== 'stored') + params.set('sort', route.sort) + if (route.page > 1) + params.set('page', String(route.page)) + } if (route.panel) params.set('panel', route.panel) const query = params.toString() return query ? `${pathOf(route)}?${query}` : pathOf(route) } -// 换页面、进入另一个房间、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面或上一次搜索; -// 同一个页面里换展示方式、开关侧栏和输入筛选词只替换当前地址。 +// 换页面、进入另一个房间、换设置或后台的分类、提交新的关键词和打开另一个歌单写入新记录,后退一次回到上一个页面、分类或上一次搜索; +// 同一个页面里换展示方式、开关侧栏、输入筛选词、排序和翻页只替换当前地址。 export function isNewEntry(from: Route, to: Route): boolean { if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref)) return true + if ((from.name === 'settings' && to.name === 'settings') || (from.name === 'admin' && to.name === 'admin')) + return from.section !== to.section if (from.name === 'my-playlist' && to.name === 'my-playlist') return from.id !== to.id if (from.name === 'search' && to.name === 'search') @@ -165,6 +216,11 @@ export function isNewEntry(from: Route, to: Route): boolean { return false } +// 从设置或后台的分类回到分类列表:上一条记录就是列表时后退,否则把当前记录换成列表。两种都不新增记录,返回键不会再回到刚离开的分类。 +export function upStep(list: Route, from: string | undefined, canBack: boolean): 'back' | 'replace' { + return canBack && from === formatRoute(list) ? 'back' : 'replace' +} + // 取消进入房间时离开这个房间的地址:在它的页面上就后退,一直退到进入房间之前的页面;没有可以后退的记录(直接打开的房间链接)时换成发现房间;不在它的页面上就留在原处。 export function leaveStep(route: Route, leaving: ReadonlySet, canBack: boolean): 'back' | 'lobby' | 'stay' { if (!('ref' in route) || !leaving.has(route.ref)) diff --git a/web/src/features/admin/admin-page.tsx b/web/src/features/admin/admin-page.tsx index b04d3ee..6e4b914 100644 --- a/web/src/features/admin/admin-page.tsx +++ b/web/src/features/admin/admin-page.tsx @@ -1,3 +1,4 @@ +import type { MediaSort } from '@/core/realtime' import { LogOut } from 'lucide-react' import { useState } from 'react' import { brandStore } from '@/core/brand' @@ -11,6 +12,8 @@ import { SourceAccounts } from './source-accounts' import { StoredMedia } from './stored-media' import './admin.css' +export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media' + function SignIn() { const [token, setToken] = useState('') return ( @@ -31,7 +34,7 @@ function SignIn() { ) } -export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) { +export function AdminPage({ onEntered }: { section: AdminSection | null, q: string, page: number, sort: MediaSort, onEntered: (ref: string) => void }) { const signedIn = useStore(adminStore, s => s.signedIn) const brandName = useStore(brandStore, s => s.name) return ( diff --git a/web/src/features/admin/index.ts b/web/src/features/admin/index.ts index 04688eb..81d6154 100644 --- a/web/src/features/admin/index.ts +++ b/web/src/features/admin/index.ts @@ -1,2 +1,3 @@ export { AdminPage } from './admin-page' +export type { AdminSection } from './admin-page' export { adminStore } from './admin-store' diff --git a/web/src/features/settings/index.ts b/web/src/features/settings/index.ts index da7d291..aff835e 100644 --- a/web/src/features/settings/index.ts +++ b/web/src/features/settings/index.ts @@ -3,6 +3,7 @@ import { lazyPage } from '@/shared/ui' export { appearanceStore } from './appearance-store' export { performanceStore } from './performance-store' export { profileStore } from './profile-store' +export type { SettingsSection } from './settings-page' export const SettingsPage = lazyPage(() => import('./settings-page').then(module => module.SettingsPage)) export const PairPage = lazyPage(() => import('./pair-page').then(module => module.PairPage)) export const GitHubLoginPage = lazyPage(() => import('./github-login-page').then(module => module.GitHubLoginPage)) diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx index 05f6e95..fcec6ef 100644 --- a/web/src/features/settings/settings-page.tsx +++ b/web/src/features/settings/settings-page.tsx @@ -32,6 +32,8 @@ interface SettingsDraft { type DraftKey = keyof SettingsDraft +export type SettingsSection = 'account' | 'appearance' | 'playback' | 'performance' | 'privacy' + const themeNames: Record = { dark: '深色', light: '浅色', system: '跟随系统' } const levelNames: Record = { @@ -65,7 +67,7 @@ function SettingRow({ id, title, description, field = false, children }: { ) } -export function SettingsPage() { +export function SettingsPage(_: { section: SettingsSection | null }) { const headingRef = useRef(null) const member = useStore(profileStore, s => s.member) const email = useStore(profileStore, s => s.email) diff --git a/web/src/shared/lib/inline-save.test.ts b/web/src/shared/lib/inline-save.test.ts new file mode 100644 index 0000000..6b6154b --- /dev/null +++ b/web/src/shared/lib/inline-save.test.ts @@ -0,0 +1,74 @@ +import type { InlineSaveState } from './inline-save' +import { describe, expect, it } from 'vitest' +import { inlineSave } from './inline-save' + +const idle: InlineSaveState = { draft: '大猫', saved: '大猫', status: 'idle', error: null } + +describe('inlineSave', () => { + it('edit 只改 draft', () => { + expect(inlineSave(idle, { type: 'edit', value: '小猫' })).toEqual({ ...idle, draft: '小猫' }) + const failed: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' } + expect(inlineSave(failed, { type: 'edit', value: '小狗' })).toEqual({ ...failed, draft: '小狗' }) + }) + + it('submit 时 draft 没变则不进入 saving', () => { + expect(inlineSave(idle, { type: 'submit' })).toBe(idle) + const saved: InlineSaveState = { ...idle, status: 'saved' } + expect(inlineSave(saved, { type: 'submit' })).toBe(saved) + }) + + it('submit 时 draft 改过则进入 saving,并清掉上次的错误', () => { + expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'submit' })).toEqual({ draft: '小猫', saved: '大猫', status: 'saving', error: null }) + expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'submit' })).toEqual({ draft: '小猫', saved: '大猫', status: 'saving', error: null }) + }) + + it('改回已保存的值再 submit 时清掉错误', () => { + expect(inlineSave({ draft: '大猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'submit' })).toEqual(idle) + }) + + it('saving 时忽略再次 submit', () => { + const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null } + expect(inlineSave(saving, { type: 'submit' })).toBe(saving) + }) + + it('succeeded 后 saved = draft,status = saved', () => { + const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null } + expect(inlineSave(saving, { type: 'succeeded', value: '小猫' })).toEqual({ draft: '小猫', saved: '小猫', status: 'saved', error: null }) + }) + + it('保存期间又改了 draft 时,succeeded 保留新的 draft', () => { + const saving: InlineSaveState = { draft: '小猫咪', saved: '大猫', status: 'saving', error: null } + expect(inlineSave(saving, { type: 'succeeded', value: '小猫' })).toEqual({ draft: '小猫咪', saved: '小猫', status: 'saved', error: null }) + }) + + it('failed 后 status = error,保留 draft 和 error', () => { + const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null } + expect(inlineSave(saving, { type: 'failed', error: '名字太长' })).toEqual({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }) + }) + + it('不在 saving 时忽略 succeeded 和 failed', () => { + expect(inlineSave(idle, { type: 'succeeded', value: '小猫' })).toBe(idle) + expect(inlineSave(idle, { type: 'failed', error: '名字太长' })).toBe(idle) + }) + + it('revert 把 draft 恢复为 saved,清掉 error', () => { + expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'revert' })).toEqual(idle) + expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'revert' })).toEqual(idle) + }) + + it('saving 时忽略 revert', () => { + const saving: InlineSaveState = { draft: '小猫', saved: '大猫', status: 'saving', error: null } + expect(inlineSave(saving, { type: 'revert' })).toBe(saving) + }) + + it('external 在 idle 或 saved 时更新 saved 和 draft', () => { + expect(inlineSave(idle, { type: 'external', value: '老虎' })).toEqual({ ...idle, draft: '老虎', saved: '老虎' }) + expect(inlineSave({ ...idle, status: 'saved' }, { type: 'external', value: '老虎' })).toEqual({ draft: '老虎', saved: '老虎', status: 'saved', error: null }) + }) + + it('external 在 saving 或编辑中时只更新 saved', () => { + expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'saving', error: null }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'saving', error: null }) + expect(inlineSave({ ...idle, draft: '小猫' }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'idle', error: null }) + expect(inlineSave({ draft: '小猫', saved: '大猫', status: 'error', error: '名字太长' }, { type: 'external', value: '老虎' })).toEqual({ draft: '小猫', saved: '老虎', status: 'error', error: '名字太长' }) + }) +}) diff --git a/web/src/shared/lib/inline-save.ts b/web/src/shared/lib/inline-save.ts new file mode 100644 index 0000000..caacc4d --- /dev/null +++ b/web/src/shared/lib/inline-save.ts @@ -0,0 +1,45 @@ +// saved 是已经保存的值,draft 是输入框里的值;两者不同就是“编辑中”。 +export interface InlineSaveState { + draft: string + saved: string + status: 'idle' | 'saving' | 'saved' | 'error' + error: string | null +} + +// succeeded 的 value 是提交的值;external 是外部传入的新值,比如其他设备改了资料。 +export type InlineSaveEvent + = | { type: 'edit', value: string } + | { type: 'submit' } + | { type: 'succeeded', value: string } + | { type: 'failed', error: string } + | { type: 'revert' } + | { type: 'external', value: string } + +export function inlineSave(state: InlineSaveState, event: InlineSaveEvent): InlineSaveState { + switch (event.type) { + case 'edit': + return { ...state, draft: event.value } + case 'submit': + if (state.status === 'saving') + return state + if (state.draft === state.saved) + return state.status === 'error' ? { ...state, status: 'idle', error: null } : state + return { ...state, status: 'saving', error: null } + case 'succeeded': + return state.status === 'saving' ? { ...state, saved: event.value, status: 'saved', error: null } : state + case 'failed': + return state.status === 'saving' ? { ...state, status: 'error', error: event.error } : state + case 'revert': + // 已经发出的保存撤不回来,等它的结果。 + if (state.status === 'saving') + return state + return { ...state, draft: state.saved, status: 'idle', error: null } + case 'external': + if (event.value === state.saved) + return state + // 保存中或者编辑中时不覆盖输入框,免得冲掉用户正在输入的内容。 + if (state.status === 'saving' || state.draft !== state.saved) + return { ...state, saved: event.value } + return { ...state, draft: event.value, saved: event.value } + } +} diff --git a/web/src/shared/lib/page-items.test.ts b/web/src/shared/lib/page-items.test.ts new file mode 100644 index 0000000..a6b518a --- /dev/null +++ b/web/src/shared/lib/page-items.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { pageItems } from './page-items' + +describe('pageItems', () => { + it('页数不多时列出全部页码', () => { + expect(pageItems(1, 1)).toEqual([1]) + expect(pageItems(1, 5)).toEqual([1, 2, 3, 4, 5]) + expect(pageItems(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7]) + }) + + it('页数多时只列出首页、末页和当前页前后各一页,其余用省略号', () => { + expect(pageItems(2, 26)).toEqual([1, 2, 3, 'gap', 26]) + expect(pageItems(13, 26)).toEqual([1, 'gap', 12, 13, 14, 'gap', 26]) + expect(pageItems(26, 26)).toEqual([1, 'gap', 25, 26]) + }) + + it('省略号只盖住一页时直接显示那一页', () => { + expect(pageItems(4, 8)).toEqual([1, 2, 3, 4, 5, 'gap', 8]) + expect(pageItems(5, 8)).toEqual([1, 'gap', 4, 5, 6, 7, 8]) + }) +}) diff --git a/web/src/shared/lib/page-items.ts b/web/src/shared/lib/page-items.ts new file mode 100644 index 0000000..7dfbe0d --- /dev/null +++ b/web/src/shared/lib/page-items.ts @@ -0,0 +1,20 @@ +// 页码条最多 7 项:首页、省略号、当前页和前后各一页、省略号、末页。不超过 7 页时全部列出; +// 省略号只盖住一页时,直接显示那一页,占的位置一样。 +export function pageItems(current: number, totalPages: number): (number | 'gap')[] { + if (totalPages <= 7) + return Array.from({ length: totalPages }, (_, index) => index + 1) + const pages = [...new Set([1, current - 1, current, current + 1, totalPages])] + .filter(page => page >= 1 && page <= totalPages) + .sort((a, b) => a - b) + const items: (number | 'gap')[] = [] + let previous = 0 + for (const page of pages) { + if (page - previous === 2) + items.push(previous + 1) + else if (page - previous > 2) + items.push('gap') + items.push(page) + previous = page + } + return items +} diff --git a/web/src/shared/lib/range-selection.test.ts b/web/src/shared/lib/range-selection.test.ts new file mode 100644 index 0000000..b5b5080 --- /dev/null +++ b/web/src/shared/lib/range-selection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { toggleSelection } from './range-selection' + +const ids = ['a', 'b', 'c', 'd', 'e'] + +describe('toggleSelection', () => { + it('普通点击切换单个 id,并把它设为锚点', () => { + expect(toggleSelection(new Set(), ids, 'b', null, false)).toEqual({ selected: new Set(['b']), anchor: 'b' }) + expect(toggleSelection(new Set(['b', 'c']), ids, 'b', 'c', false)).toEqual({ selected: new Set(['c']), anchor: 'b' }) + }) + + it('按住 Shift 点击时选中锚点到当前 id 之间的全部,含两端,方向不限', () => { + expect(toggleSelection(new Set(['b']), ids, 'd', 'b', true)).toEqual({ selected: new Set(['b', 'c', 'd']), anchor: 'd' }) + expect(toggleSelection(new Set(['d']), ids, 'a', 'd', true)).toEqual({ selected: new Set(['a', 'b', 'c', 'd']), anchor: 'a' }) + }) + + it('按住 Shift 点击时保留范围之外已经选中的 id', () => { + expect(toggleSelection(new Set(['a', 'c']), ids, 'e', 'c', true)).toEqual({ selected: new Set(['a', 'c', 'd', 'e']), anchor: 'e' }) + }) + + it('没有锚点时的 Shift 点击等同于普通点击', () => { + expect(toggleSelection(new Set(), ids, 'c', null, true)).toEqual({ selected: new Set(['c']), anchor: 'c' }) + expect(toggleSelection(new Set(['c']), ids, 'c', null, true)).toEqual({ selected: new Set(), anchor: 'c' }) + }) + + it('锚点不在当前列表里时的 Shift 点击等同于普通点击', () => { + expect(toggleSelection(new Set(), ids, 'c', 'z', true)).toEqual({ selected: new Set(['c']), anchor: 'c' }) + }) + + it('不修改传入的集合', () => { + const selected = new Set(['a']) + toggleSelection(selected, ids, 'c', 'a', true) + expect(selected).toEqual(new Set(['a'])) + }) +}) diff --git a/web/src/shared/lib/range-selection.ts b/web/src/shared/lib/range-selection.ts new file mode 100644 index 0000000..03cfa61 --- /dev/null +++ b/web/src/shared/lib/range-selection.ts @@ -0,0 +1,18 @@ +// 锚点是上一次点击的 id。按住 Shift 点击时选中锚点到这一项之间的全部,不取消范围外已经选中的项; +// 没有锚点,或者锚点已经不在列表里(比如翻了页)时,和普通点击一样只切换这一项。 +export function toggleSelection(selected: ReadonlySet, ids: readonly string[], id: string, anchor: string | null, shift: boolean): { selected: Set, anchor: string } { + const next = new Set(selected) + const from = anchor === null ? -1 : ids.indexOf(anchor) + const to = ids.indexOf(id) + if (shift && from !== -1 && to !== -1) { + for (const item of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) + next.add(item) + } + else if (next.has(id)) { + next.delete(id) + } + else { + next.add(id) + } + return { selected: next, anchor: id } +} diff --git a/web/src/shared/lib/roving.test.ts b/web/src/shared/lib/roving.test.ts new file mode 100644 index 0000000..47894d5 --- /dev/null +++ b/web/src/shared/lib/roving.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { rovingIndex } from './roving' + +describe('rovingIndex', () => { + it('← 和 ↑ 去上一项,→ 和 ↓ 去下一项,到头之后从另一头接着走', () => { + expect(rovingIndex('ArrowLeft', 1, 3)).toBe(0) + expect(rovingIndex('ArrowUp', 1, 3)).toBe(0) + expect(rovingIndex('ArrowRight', 1, 3)).toBe(2) + expect(rovingIndex('ArrowDown', 1, 3)).toBe(2) + expect(rovingIndex('ArrowUp', 0, 3)).toBe(2) + expect(rovingIndex('ArrowDown', 2, 3)).toBe(0) + }) + + it('按 Home 和 End 去第一项和最后一项', () => { + expect(rovingIndex('Home', 2, 3)).toBe(0) + expect(rovingIndex('End', 0, 3)).toBe(2) + }) + + it('其他按键和空的组不处理', () => { + expect(rovingIndex('Enter', 1, 3)).toBeNull() + expect(rovingIndex(' ', 1, 3)).toBeNull() + expect(rovingIndex('ArrowDown', 0, 0)).toBeNull() + }) +}) diff --git a/web/src/shared/lib/roving.ts b/web/src/shared/lib/roving.ts new file mode 100644 index 0000000..a8bc982 --- /dev/null +++ b/web/src/shared/lib/roving.ts @@ -0,0 +1,19 @@ +// 单选组里方向键要去的那一项,和原生的单选按钮一样:←/↑ 和 →/↓ 等价,到头之后从另一头接着走。不是这几个键时返回 null。 +export function rovingIndex(key: string, index: number, count: number): number | null { + if (!count) + return null + switch (key) { + case 'ArrowLeft': + case 'ArrowUp': + return (index - 1 + count) % count + case 'ArrowRight': + case 'ArrowDown': + return (index + 1) % count + case 'Home': + return 0 + case 'End': + return count - 1 + default: + return null + } +} diff --git a/web/src/shared/lib/session-history.test.ts b/web/src/shared/lib/session-history.test.ts index fd47d05..8670119 100644 --- a/web/src/shared/lib/session-history.test.ts +++ b/web/src/shared/lib/session-history.test.ts @@ -100,6 +100,12 @@ async function open(state: unknown = null) { get href() { return history.current.url }, + get pathname() { + return new URL(history.current.url, 'https://alisten.test').pathname + }, + get search() { + return new URL(history.current.url, 'https://alisten.test').search + }, }, addEventListener: (type: string, listener: EventListener) => history.events.addEventListener(type, listener), }) @@ -125,7 +131,7 @@ describe('打开页面', () => { const { firstOpen, history, currentEntry } = await open({ index: 3, title: '房间' }) expect(firstOpen).toBe(false) expect(history.state).toEqual({ index: 3, title: '房间' }) - expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间' }) + expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间', from: undefined }) }) it('刷新时弹窗已经关闭,标记记录改作页面的记录', async () => { @@ -152,10 +158,10 @@ describe('写入记录', () => { writeEntry('/a', false, '甲') writeEntry('/b', false) expect(history.urls).toEqual(['/start', '/a', '/b']) - expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined }) + expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined, from: '/a' }) history.press() - expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲' }) + expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲', from: '/start' }) expect(log).toEqual(['notify']) }) @@ -165,7 +171,7 @@ describe('写入记录', () => { expect(history.state).toEqual({ index: 1, title: '房间' }) writeEntry('/b', true, '搜索') expect(history.urls).toEqual(['/b']) - expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索' }) + expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索', from: undefined }) }) it('浏览器限制调用频率时地址暂时落后,序号不变,页面照常', async () => { @@ -173,7 +179,7 @@ describe('写入记录', () => { history.failure = new DOMException('Too many calls', 'SecurityError') expect(() => writeEntry('/a', false)).not.toThrow() expect(() => writeEntry('/b', true, '甲')).not.toThrow() - expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined }) + expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined, from: undefined }) history.failure = null writeEntry('/c', false) @@ -190,6 +196,28 @@ describe('写入记录', () => { expect(log).toEqual(['notify']) }) + it('新记录记下写入时的地址,也就是它前面那条记录的地址;替换时沿用,刷新之后还在', async () => { + const { writeEntry, currentEntry, history } = await open() + writeEntry('/settings', true) + writeEntry('/settings/appearance', false) + expect(currentEntry().from).toBe('/settings') + writeEntry('/admin/media?q=a', false) + writeEntry('/admin/media?q=ab', true) + expect(currentEntry().from).toBe('/settings/appearance') + expect(history.state).toEqual({ index: 2, from: '/settings/appearance' }) + + history.press() + expect(currentEntry().from).toBe('/settings') + }) + + it('刷新时沿用记录里的来处,别的脚本写入的来处不用', async () => { + expect((await open({ index: 2, from: '/settings' })).currentEntry().from).toBe('/settings') + const other = await open({ index: 2, from: 3 }) + expect(other.currentEntry().from).toBeUndefined() + expect(other.history.state).toEqual({ index: 2, from: 3 }) + expect((await open({ index: 2, from: '/settings', modal: true })).history.state).toEqual({ index: 2, from: '/settings' }) + }) + it('其他错误照常抛出', async () => { const { writeEntry, history } = await open() history.failure = new DOMException('bad', 'DataCloneError') @@ -275,7 +303,7 @@ describe('弹窗', () => { history.settle() expect(history.urls).toEqual(['/start', '/next']) - expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页' }) + expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页', from: '/start' }) }) it('弹窗开着时替换地址:先退到页面的记录再替换,然后压回标记', async () => { @@ -296,7 +324,7 @@ describe('弹窗', () => { registerModalHistory(() => {}) writeEntry('/room', false) expect(history.urls).toEqual(['/start', '/room']) - expect(history.state).toEqual({ index: 1 }) + expect(history.state).toEqual({ index: 1, from: '/start' }) expect(currentEntry().position).toEqual({ index: 1, last: 1 }) }) diff --git a/web/src/shared/lib/session-history.ts b/web/src/shared/lib/session-history.ts index b7baecc..4642f29 100644 --- a/web/src/shared/lib/session-history.ts +++ b/web/src/shared/lib/session-history.ts @@ -27,6 +27,8 @@ export function canGoForward(at: Position): boolean { interface EntryState { index: number title?: string + // 写入这条记录时所在的地址,也就是后退会回到的那条记录的地址。 + from?: string modal?: true } @@ -47,12 +49,14 @@ const queued: Write[] = [] function readEntry(state: unknown): EntryState | null { if (typeof state !== 'object' || state === null) return null - const { index, title, modal } = state as Record + const { index, title, from, modal } = state as Record if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return null const entry: EntryState = { index } if (typeof title === 'string') entry.title = title + if (typeof from === 'string') + entry.from = from if (modal === true) entry.modal = true return entry @@ -116,6 +120,8 @@ export function startHistory(onChange: () => void): boolean { current = { index: entry?.index ?? 0 } if (entry?.title !== undefined) current.title = entry.title + if (entry?.from !== undefined) + current.from = entry.from // 刷新时弹窗已经关闭,它留下的标记记录改作页面的记录。 if (!entry || entry.modal) window.history.replaceState(current, '') @@ -124,8 +130,8 @@ export function startHistory(onChange: () => void): boolean { return entry === null } -export function currentEntry(): { position: Position, title: string | undefined } { - return { position, title: current.title } +export function currentEntry(): { position: Position, title: string | undefined, from: string | undefined } { + return { position, title: current.title, from: current.from } } // 后退一个页面。弹窗的标记记录压在当前页面上(或者正在退掉它)时只退到页面的记录:落到那里时导航会收到通知,由它决定要不要接着后退。 @@ -153,6 +159,9 @@ export function writeEntry(url: string, replace: boolean, title?: string): void const entryTitle = replace ? title ?? current.title : title if (entryTitle !== undefined) entry.title = entryTitle + const from = replace ? current.from : window.location.pathname + window.location.search + if (from !== undefined) + entry.from = from // 在大厅的加入、创建房间弹窗里换页面时,弹窗随旧页面一起关闭,新页面的记录直接顶替标记记录。 const written = write(() => replace || current.modal ? window.history.replaceState(entry, '', url) diff --git a/web/src/shared/ui/index.ts b/web/src/shared/ui/index.ts index 7fb0db3..52697ed 100644 --- a/web/src/shared/ui/index.ts +++ b/web/src/shared/ui/index.ts @@ -1,19 +1,26 @@ import './select.css' import './ui.css' +import './section-layout.css' export { Artwork } from './artwork' export { Avatar } from './avatar' export { ConfirmDialog } from './confirm-dialog' export { EmptyState } from './empty-state' export { AsyncIconButton, IconButton } from './icon-button' +export { InlineTextField } from './inline-text-field' export { lazyPage } from './lazy-page' export { Modal } from './modal' export { notificationStore } from './notification-store' export { Notifications } from './notifications' +export { Pagination } from './pagination' export { PlayingBars } from './playing-bars' +export { SectionLayout } from './section-layout' +export type { SectionItem } from './section-layout' +export { SegmentedControl } from './segmented-control' export { Select } from './select' export type { SelectOption } from './select' export { Skeleton } from './skeleton' export { SongArtwork } from './song-artwork' export { SongList } from './song-list' export type { SongAction, SongItem } from './song-list' +export { Switch } from './switch' diff --git a/web/src/shared/ui/inline-text-field.tsx b/web/src/shared/ui/inline-text-field.tsx new file mode 100644 index 0000000..4d48adf --- /dev/null +++ b/web/src/shared/ui/inline-text-field.tsx @@ -0,0 +1,103 @@ +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' + +const savedNoticeMs = 1500 + +// onSave 失败时抛出 Error,它的 message 就地显示在输入框下面。 +export function InlineTextField({ label, value, onSave, placeholder, maxLength, type = 'text' }: { + label: string + value: string + onSave: (value: string) => Promise + placeholder?: string + maxLength?: number + 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) + return ( +
+
+ apply({ type: 'edit', value: event.target.value })} + onBlur={() => void submit()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void submit() + } + // 没有可恢复的内容时不拦截 Esc,外层的对话框照常关闭。 + else if (event.key === 'Escape' && (state.draft !== state.saved || state.error)) { + event.preventDefault() + apply({ type: 'revert' }) + } + }} + /> + + {state.status === 'saving' && '保存中…'} + {state.status === 'saved' && ( + <> + +
+ {state.error && } +
+ ) +} diff --git a/web/src/shared/ui/pagination.tsx b/web/src/shared/ui/pagination.tsx new file mode 100644 index 0000000..87563b4 --- /dev/null +++ b/web/src/shared/ui/pagination.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { pageItems } from '@/shared/lib/page-items' + +// 到头时用 aria-disabled 而不是 disabled:被禁用的按钮会丢掉焦点,连按“下一页”翻到最后一页的键盘用户就找不到自己在哪了。 +function StepButton({ label, enabled, onClick, children }: { label: string, enabled: boolean, onClick: () => void, children: ReactNode }) { + return ( + + ) +} + +export function Pagination({ page, totalPages, onPage, summary }: { + page: number + totalPages: number + onPage: (page: number) => void + summary?: string +}) { + if (totalPages <= 1 && !summary) + return null + return ( + + ) +} diff --git a/web/src/shared/ui/section-layout.css b/web/src/shared/ui/section-layout.css new file mode 100644 index 0000000..6948ddd --- /dev/null +++ b/web/src/shared/ui/section-layout.css @@ -0,0 +1,60 @@ +.section-layout { container: section-layout / inline-size; height: 100%; min-height: 0; background: var(--color-content); } +.section-layout-frame { display: grid; grid-template-columns: 240px minmax(0, 1fr); height: 100%; } +.section-layout-sidebar { display: flex; flex-direction: column; gap: 20px; min-height: 0; overflow-y: auto; scrollbar-width: none; padding: 28px 12px 16px; border-right: 1px solid var(--color-card-hover); } +.section-layout-heading { padding: 0 12px; } +.section-layout-eyebrow { margin-bottom: 2px; overflow-wrap: anywhere; color: var(--color-brand); font-size: var(--text-ui-sm); font-weight: 500; } +.section-layout-heading h1 { font-size: var(--text-ui-xl); line-height: 1.25; } +.section-nav { display: flex; flex-direction: column; gap: 2px; } +.section-nav-item { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: transparent; color: var(--color-foreground-subtle); text-align: left; } +.section-nav-item:hover { background: color-mix(in srgb, var(--color-card-hover) 45%, transparent); color: var(--color-foreground); } +.section-nav-item[aria-current='page'] { background: var(--color-card-hover); color: var(--color-foreground); font-weight: 500; } +.section-nav-item[aria-current='page'] > svg { color: var(--color-brand); } +.section-nav-item:focus-visible { outline-offset: 0; } +.section-nav-label, .section-list-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.section-badge { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--color-destructive); } +.section-layout-footer { margin-top: auto; padding-top: 12px; border-top: 1px solid var(--color-card-hover); } +.section-layout-content { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 32px 40px 48px; animation: fade-in 120ms ease both; } +.section-layout-main { max-width: 960px; } +.section-layout-main h2:focus { outline: none; } +.section-layout-list { display: flex; flex-direction: column; gap: 16px; height: 100%; overflow-y: auto; overscroll-behavior: contain; padding: 24px 16px 32px; animation: fade-in 120ms ease both; } +.section-layout-list > .section-layout-heading { padding: 0 4px; margin-bottom: 4px; } +.section-layout-list > .section-layout-footer { margin-top: 0; padding-top: 0; border-top: 0; } +.section-list-item { flex-wrap: nowrap; column-gap: 12px; color: var(--color-foreground); } +.section-list-item > svg:first-child { color: var(--color-foreground-subtle); } +.section-list-summary { max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); } +.section-list-chevron { margin-left: -6px; color: var(--color-foreground-subtlest); } +.section-layout-back { display: inline-flex; align-items: center; gap: 2px; min-height: 36px; margin: 0 0 8px -6px; padding: 4px 12px 4px 2px; border: 0; border-radius: 8px; background: transparent; color: var(--color-brand); font-weight: 500; } +.section-layout-back:hover { background: color-mix(in srgb, var(--color-brand) 10%, transparent); } +.section-layout-back:focus-visible { outline-offset: 0; } +.section-group-label { margin: 28px 4px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-caption); font-weight: 500; } +/* 浅色主题里卡片和输入框的底色相同,卡片里的输入框要换一种底色才分得开。 */ +.section-group { --color-input: var(--color-input-on-card); border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-card); } +.section-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; width: 100%; min-height: 56px; padding: 12px 16px; } +/* 分隔线不用 surface:浅色主题里它和 card 同色,线会看不见。card-hover 在深色主题里与 surface 同色。 */ +.section-row + .section-row { border-top: 1px solid var(--color-card-hover); } +.section-group > .section-row:first-child { border-top-left-radius: 13px; border-top-right-radius: 13px; } +.section-group > .section-row:last-child { border-bottom-left-radius: 13px; border-bottom-right-radius: 13px; } +.section-row-copy { display: flex; flex: 1 1 12em; flex-direction: column; gap: 2px; min-width: 0; } +.section-row-title { font-weight: 500; } +.section-row-description { overflow-wrap: anywhere; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); } +:is(button, a).section-row { border: 0; background: transparent; color: inherit; text-align: left; text-decoration: none; } +:is(button, a).section-row:hover { background: var(--color-card-hover); } +:is(button, a).section-row:active { transform: none; background: var(--color-hover); } +:is(button, a).section-row:focus-visible { outline-offset: -2px; } +@container section-layout (max-width: 860px) { + .section-layout-frame[data-layout='split'] { grid-template-columns: 208px minmax(0, 1fr); } + .section-layout-content { padding: 28px 24px 40px; } +} +/* 宽屏上主区域也可能很窄,比如平板竖屏又开着右侧栏:分类栏改为内容上方的一行。 */ +@container section-layout (max-width: 599px) { + .section-layout-frame[data-layout='split'] { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + .section-layout-sidebar { flex-direction: row; align-items: center; gap: 8px; padding: 8px 12px; overflow-x: auto; scrollbar-width: thin; border-right: 0; border-bottom: 1px solid var(--color-card-hover); } + .section-layout-sidebar > .section-layout-heading { display: none; } + .section-nav { flex-direction: row; } + .section-nav-item { width: auto; white-space: nowrap; } + .section-layout-sidebar > .section-layout-footer { flex-shrink: 0; margin: 0 0 0 auto; padding: 0; border: 0; } + .section-layout-content { padding: 24px 20px 32px; } +} +@media (max-width: 767px) { + .section-layout > .section-layout-content { height: 100%; padding: 12px 16px 32px; } +} diff --git a/web/src/shared/ui/section-layout.tsx b/web/src/shared/ui/section-layout.tsx new file mode 100644 index 0000000..6d547a0 --- /dev/null +++ b/web/src/shared/ui/section-layout.tsx @@ -0,0 +1,182 @@ +import type { LucideIcon } from 'lucide-react' +import type { KeyboardEvent, ReactNode } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useEffect, useRef, useSyncExternalStore } from 'react' + +export interface SectionItem { + id: T + label: string + icon: LucideIcon + badge?: boolean + // 手机端分类列表里显示在这一行右侧的当前值。 + summary?: string + // 手机端分类列表里不单独列出这一项,比如 header 已经是它的入口。 + hiddenInList?: boolean +} + +let wideQuery: MediaQueryList | undefined + +function wide(): MediaQueryList { + wideQuery ??= window.matchMedia('(min-width: 768px)') + return wideQuery +} + +function subscribeWide(notify: () => void): () => void { + wide().addEventListener('change', notify) + return () => wide().removeEventListener('change', notify) +} + +function focusHeading(container: HTMLElement | null): void { + const heading = container?.querySelector('h2') + if (!heading) + return + if (!heading.hasAttribute('tabindex')) + heading.tabIndex = -1 + heading.focus({ preventScroll: true }) +} + +const keySteps: Partial number>> = { + ArrowDown: (index, count) => (index + 1) % count, + ArrowUp: (index, count) => (index - 1 + count) % count, + Home: () => 0, + End: (_, count) => count - 1, +} + +function moveFocus(event: KeyboardEvent): void { + const step = keySteps[event.key] + const items = [...event.currentTarget.querySelectorAll('[data-section]')] + const index = items.indexOf(document.activeElement as HTMLElement) + if (!step || index === -1) + return + event.preventDefault() + items[step(index, items.length)].focus() +} + +// 宽屏:左侧分类栏加右侧内容,current 为 null 时按 defaultSection 高亮,children 也应当是它的内容。 +// 窄屏:current 为 null 时只显示分类列表,header 在列表上方;否则只显示 children,顶部的返回按钮调用 onSelect(null)。 +// children 里第一个 h2 是分类的标题:从分类栏切换过去之后,焦点落在它上面。 +export function SectionLayout({ title, eyebrow, sections, current, defaultSection, onSelect, header, footer, children }: { + title: string + eyebrow?: string + sections: SectionItem[] + current: T | null + defaultSection: T + onSelect: (id: T | null) => void + header?: ReactNode + footer?: ReactNode + children: ReactNode +}) { + const isWide = useSyncExternalStore(subscribeWide, () => wide().matches) + const navRef = useRef(null) + const contentRef = useRef(null) + // 地址改写之后 current 才会变,所以先记下切换完成后焦点要去哪里。 + const focusAfterRef = useRef<{ to: 'content' } | { to: 'item', id: T } | null>(null) + const shown = current ?? defaultSection + + useEffect(() => { + const target = focusAfterRef.current + focusAfterRef.current = null + if (target?.to === 'content') + focusHeading(contentRef.current) + else if (target?.to === 'item') + [...navRef.current?.querySelectorAll('[data-section]') ?? []].find(item => item.dataset.section === target.id)?.focus() + }, [current]) + + // 窄的主区域里分类栏是横向滚动的一行,当前项可能在视野之外。 + useEffect(() => { + navRef.current?.querySelector('[aria-current="page"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [shown]) + + // 宽屏上 current 为 null 时显示的就是 defaultSection:点它不改地址,免得多出一条看不出区别的记录。 + const select = (id: T) => { + if (id === (isWide ? shown : current)) { + focusHeading(contentRef.current) + return + } + focusAfterRef.current = { to: 'content' } + onSelect(id) + } + + const heading = ( +
+ {eyebrow &&

{eyebrow}

} +

{title}

+
+ ) + const badge = 需要处理 + + if (isWide) { + return ( +
+
+
+ {heading} + + {footer &&
{footer}
} +
+
+
{children}
+
+
+
+ ) + } + + if (current === null) { + return ( +
+
+ {heading} + {header} + + {footer &&
{footer}
} +
+
+ ) + } + + return ( +
+
+ +
{children}
+
+
+ ) +} diff --git a/web/src/shared/ui/segmented-control.tsx b/web/src/shared/ui/segmented-control.tsx new file mode 100644 index 0000000..bf04664 --- /dev/null +++ b/web/src/shared/ui/segmented-control.tsx @@ -0,0 +1,46 @@ +import type { CSSProperties, KeyboardEvent } from 'react' +import { useRef } from 'react' +import { rovingIndex } from '@/shared/lib/roving' + +export function SegmentedControl({ value, options, onChange, label }: { + value: T + options: { value: T, label: string }[] + onChange: (value: T) => void + label: string +}) { + const rootRef = useRef(null) + const selected = options.findIndex(option => option.value === value) + const onKeyDown = (event: KeyboardEvent) => { + const next = rovingIndex(event.key, Math.max(selected, 0), options.length) + if (next === null) + return + event.preventDefault() + rootRef.current?.querySelectorAll('[role="radio"]')[next]?.focus() + if (next !== selected) + onChange(options[next].value) + } + return ( +
+ {selected >= 0 &&
+ ) +} diff --git a/web/src/shared/ui/switch.tsx b/web/src/shared/ui/switch.tsx new file mode 100644 index 0000000..2c338e6 --- /dev/null +++ b/web/src/shared/ui/switch.tsx @@ -0,0 +1,23 @@ +// 可见的名字由