From 8cacfb68ff53cc67dd63258ba86905656eec8e81 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:21:31 +0800 Subject: [PATCH] feat: organize the admin page into sections with room search, paging and source status cards --- web/src/app/app.tsx | 20 +- web/src/features/admin/accounts.tsx | 155 +++++++++----- web/src/features/admin/admin-page.tsx | 133 +++++++----- web/src/features/admin/admin-store.test.ts | 56 +++++ web/src/features/admin/admin-store.ts | 36 +++- web/src/features/admin/admin.css | 102 +++++++-- web/src/features/admin/github-app.tsx | 139 ++++++------- web/src/features/admin/persistent-rooms.tsx | 195 +++++++++++------- web/src/features/admin/registration.tsx | 78 +++++++ web/src/features/admin/removal-focus.ts | 6 + web/src/features/admin/room-filter.test.ts | 46 +++++ web/src/features/admin/room-filter.ts | 14 ++ web/src/features/admin/section-head.tsx | 31 +++ web/src/features/admin/site-settings.tsx | 144 +++---------- .../admin/source-account-view.test.ts | 34 +-- web/src/features/admin/source-account-view.ts | 34 +-- web/src/features/admin/source-accounts.tsx | 92 +++------ web/src/features/admin/source-credentials.ts | 77 +++++++ web/src/features/admin/stored-media.tsx | 13 +- .../features/admin/youtube-cookies-view.ts | 7 +- .../features/admin/youtube-cookies.test.ts | 6 +- web/src/features/admin/youtube-cookies.tsx | 36 +++- web/src/features/history/history-page.tsx | 6 +- web/src/features/playlists/import-menu.tsx | 3 +- .../features/playlists/my-playlist-page.tsx | 17 +- web/src/features/playlists/playlist-menu.tsx | 5 +- web/src/features/playlists/playlists.css | 7 - web/src/shared/lib/removal-focus.test.ts | 44 ++++ web/src/shared/lib/removal-focus.ts | 17 ++ web/src/shared/lib/setting-save.test.ts | 47 +++++ web/src/shared/lib/setting-save.ts | 33 +++ web/src/shared/lib/use-setting-save.ts | 51 +++++ .../playlists => shared/ui}/action-menu.tsx | 4 +- web/src/shared/ui/confirm-dialog.tsx | 5 +- web/src/shared/ui/index.ts | 3 + web/src/shared/ui/modal.tsx | 11 +- web/src/shared/ui/save-status.tsx | 17 ++ web/src/shared/ui/ui.css | 15 ++ 38 files changed, 1201 insertions(+), 538 deletions(-) create mode 100644 web/src/features/admin/admin-store.test.ts create mode 100644 web/src/features/admin/registration.tsx create mode 100644 web/src/features/admin/removal-focus.ts create mode 100644 web/src/features/admin/room-filter.test.ts create mode 100644 web/src/features/admin/room-filter.ts create mode 100644 web/src/features/admin/section-head.tsx create mode 100644 web/src/features/admin/source-credentials.ts create mode 100644 web/src/shared/lib/removal-focus.test.ts create mode 100644 web/src/shared/lib/removal-focus.ts create mode 100644 web/src/shared/lib/setting-save.test.ts create mode 100644 web/src/shared/lib/setting-save.ts create mode 100644 web/src/shared/lib/use-setting-save.ts rename web/src/{features/playlists => shared/ui}/action-menu.tsx (93%) create mode 100644 web/src/shared/ui/save-status.tsx diff --git a/web/src/app/app.tsx b/web/src/app/app.tsx index ac934da..3300964 100644 --- a/web/src/app/app.tsx +++ b/web/src/app/app.tsx @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react' -import type { Page, Panel, RoomPage } from './route' +import type { Page, Panel, RoomPage, Route } from './route' import type { SearchQuery } from '@/features/search' import { ChevronLeft, ChevronRight, Headphones, History, House, Library, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react' import { Suspense } from 'react' @@ -47,6 +47,7 @@ const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [ ] const pageClasses: Partial> = { + 'admin': 'settings-view', 'queue': 'scrollbar-hidden', 'history': 'scrollbar-hidden', 'playlists': 'scrollbar-hidden', @@ -199,7 +200,22 @@ function RoutedAdmin({ onEntered }: { onEntered: (ref: string) => void }) { const route = useStore(navigationStore, s => s.route) if (route.name !== 'admin') return null - return + return ( + { + const next: Route = { name: 'admin', section, panel: route.panel, q, page, sort } + if (section === null) + navigationStore.up(next) + else + navigationStore.navigate(next) + }} + onEntered={onEntered} + /> + ) } function RoutedHistory() { diff --git a/web/src/features/admin/accounts.tsx b/web/src/features/admin/accounts.tsx index 6c313e4..8d5a5f9 100644 --- a/web/src/features/admin/accounts.tsx +++ b/web/src/features/admin/accounts.tsx @@ -1,80 +1,123 @@ import type { AdminAccount } from '@/core/realtime' -import { useState } from 'react' -import { api } from '@/core/api' -import { ConfirmDialog } from '@/shared/ui' +import { Search } from 'lucide-react' +import { useRef, useState } from 'react' +import { api, ApiError } from '@/core/api' +import { Avatar, ConfirmDialog, notificationStore, Skeleton } from '@/shared/ui' import { adminStore } from './admin-store' +import { LoadFailed, SectionHead } from './section-head' + +// 新密码只在重置的响应里出现一次,离开这个分类就不再显示。 +type Lookup + = | { phase: 'idle' } + | { phase: 'searching' } + | { phase: 'found', account: AdminAccount, password: string | null } + | { phase: 'missing' } + | { phase: 'failed', message: string } + +async function copyPassword(password: string): Promise { + try { + await navigator.clipboard.writeText(password) + notificationStore.success('已复制新密码') + } + catch { + // 非安全上下文没有剪贴板接口,用户也可能拒绝授权。 + notificationStore.info('没能写入剪贴板,请手动复制新密码。', 8000) + } +} + +function findAccount(username: string): Promise { + return adminStore.attempt(() => api.adminGetAccount(username).catch((error: unknown) => { + if (error instanceof ApiError && error.code === 'NOT_FOUND') + return null + throw error + })) +} + +function NewPassword({ password }: { password: string }) { + return ( +
+

新密码只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。

+
+ {password} + +
+
+ ) +} export function Accounts() { const [username, setUsername] = useState('') - const [found, setFound] = useState(null) - const [password, setPassword] = useState(null) + const [lookup, setLookup] = useState({ phase: 'idle' }) const [confirming, setConfirming] = useState(false) - const [busy, setBusy] = useState(false) + // 查找进行中又按了回车时,以最后一次为准:先发出的查找晚回来也不覆盖,结果和输入框里的用户名一致。 + const generationRef = useRef(0) const find = async () => { - setBusy(true) + if (username === '') + return + const generation = ++generationRef.current + setLookup({ phase: 'searching' }) + let next: Lookup try { - setFound(await adminStore.call(() => api.adminGetAccount(username), { NOT_FOUND: '没有这个用户名的账号。' })) - setPassword(null) + const account = await findAccount(username) + next = account ? { phase: 'found', account, password: null } : { phase: 'missing' } } - finally { - setBusy(false) + catch (error) { + next = { phase: 'failed', message: (error as Error).message } } - } - const reset = async (account: AdminAccount) => { - const result = await adminStore.call(() => api.adminResetAccountPassword(account.username)) - if (result !== null) - setPassword(result.password) - setConfirming(false) + if (generation === generationRef.current) + setLookup(next) } return ( -
-
-

账号

-
+
+
{ event.preventDefault() void find() }} > -
-
- - 按用户名查找。用户忘记密码时,可以在这里重置。 -
-
- setUsername(event.target.value.trim().toLowerCase())} /> - -
-
- {found && ( -
-
- {found.username} - {`昵称:${found.member.name}`} -
-
+ + + {lookup.phase === 'searching' && } + {lookup.phase === 'failed' && void find()} />} + {lookup.phase === 'missing' &&

没有这个用户名的账号。

} + {lookup.phase === 'found' && ( + <> +
+
+ +
+ {lookup.account.member.name} + {`@${lookup.account.username}`} +
- )} - {found && password && ( -
-
- - 新密码: - {password} - - 只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。 -
-
- )} - - {found && confirming && ( - reset(found)} onClose={() => setConfirming(false)}> - {`将为 ${found.username} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`} - + {lookup.password !== null && } + {confirming && ( + { + const { account } = lookup + const result = await adminStore.call(() => api.adminResetAccountPassword(account.username)) + if (result === null) + return + setLookup({ phase: 'found', account, password: result.password }) + setConfirming(false) + }} + onClose={() => setConfirming(false)} + > + {`将为 ${lookup.account.username} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`} + + )} + )}
) diff --git a/web/src/features/admin/admin-page.tsx b/web/src/features/admin/admin-page.tsx index 6e4b914..10f308e 100644 --- a/web/src/features/admin/admin-page.tsx +++ b/web/src/features/admin/admin-page.tsx @@ -1,69 +1,108 @@ import type { MediaSort } from '@/core/realtime' -import { LogOut } from 'lucide-react' +import type { SectionItem } from '@/shared/ui' +import { Database, DoorOpen, KeyRound, LogOut, RadioTower, Store, Users } from 'lucide-react' import { useState } from 'react' import { brandStore } from '@/core/brand' import { useStore } from '@/shared/lib/use-store' +import { SectionLayout } from '@/shared/ui' import { Accounts } from './accounts' import { adminStore } from './admin-store' -import { GithubApp } from './github-app' import { PersistentRooms } from './persistent-rooms' +import { Registration } from './registration' import { SiteSettings } from './site-settings' +import { needsSignIn } from './source-account-view' import { SourceAccounts } from './source-accounts' +import { useSourceCredentials } from './source-credentials' import { StoredMedia } from './stored-media' import './admin.css' export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media' -function SignIn() { - const [token, setToken] = useState('') - return ( -
{ - event.preventDefault() - adminStore.signIn(token) - }} - > - -

管理 token 只保存在这个标签页中,关闭标签页后要重新输入。

- -
- ) +// section 为 null 表示窄屏回到分类列表;没有给出的 q、page、sort 取默认值。 +interface AdminView { + section: AdminSection | null + q?: string + page?: number + sort?: MediaSort } -export function AdminPage({ onEntered }: { section: AdminSection | null, q: string, page: number, sort: MediaSort, onEntered: (ref: string) => void }) { - const signedIn = useStore(adminStore, s => s.signedIn) +interface AdminPageProps { + section: AdminSection | null + q: string + page: number + sort: MediaSort + onNavigate: (view: AdminView) => void + onEntered: (ref: string) => void +} + +const sections: SectionItem[] = [ + { id: 'site', label: '站点', icon: Store }, + { id: 'registration', label: '登录与注册', icon: KeyRound }, + { id: 'users', label: '用户', icon: Users }, + { id: 'rooms', label: '房间', icon: DoorOpen }, + { id: 'sources', label: '音源', icon: RadioTower }, + { id: 'media', label: '媒体', icon: Database }, +] + +function SignIn() { const brandName = useStore(brandStore, s => s.name) + const [token, setToken] = useState('') return ( -
-
-
- {brandName} -

后台

-

管理品牌名、注册、GitHub 登录、用户账号、常驻房间、音源账号和已存储的媒体。

+
+
{ + event.preventDefault() + adminStore.signIn(token) + }} + > +
+

{brandName}

+

后台

- {signedIn && ( - - )} -
- {signedIn - ? ( - <> - - - - - - - - ) - : } + +

管理 token 只保存在这个标签页中,关闭标签页后要重新输入。

+ +
) } + +function Sections({ section, q, page, onNavigate, onEntered }: AdminPageProps) { + const brandName = useStore(brandStore, s => s.name) + const credentials = useSourceCredentials() + const flagged = Object.values(credentials).some(credential => needsSignIn(credential.state)) + const shown = section ?? 'site' + return ( + item.id === 'sources' ? { ...item, badge: flagged } : item)} + current={section} + defaultSection="site" + onSelect={id => onNavigate({ section: id })} + footer={( + + )} + > + {shown === 'site' && } + {shown === 'registration' && } + {shown === 'users' && } + {shown === 'rooms' && onNavigate({ section: 'rooms', ...filter })} onEntered={onEntered} />} + {shown === 'sources' && } + {shown === 'media' && } + + ) +} + +export function AdminPage(props: AdminPageProps) { + const signedIn = useStore(adminStore, s => s.signedIn) + return signedIn ? : +} diff --git a/web/src/features/admin/admin-store.test.ts b/web/src/features/admin/admin-store.test.ts new file mode 100644 index 0000000..7b9c751 --- /dev/null +++ b/web/src/features/admin/admin-store.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/core/api' +import { notificationStore } from '@/shared/ui' +import { adminStore } from './admin-store' + +class FakeStorage { + readonly #items = new Map() + + getItem(key: string): string | null { + return this.#items.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.#items.set(key, value) + } + + removeItem(key: string): void { + this.#items.delete(key) + } +} + +const messages = () => notificationStore.state.notifications.map(n => n.message) + +beforeEach(() => { + vi.stubGlobal('sessionStorage', new FakeStorage()) + for (const { id } of notificationStore.state.notifications) + notificationStore.dismiss(id) + adminStore.signIn('token') +}) + +describe('adminStore.attempt', () => { + it('成功时返回结果', async () => { + await expect(adminStore.attempt(async () => 42)).resolves.toBe(42) + }) + + it('失败时抛出给用户看的原因,不弹通知', async () => { + const failing = adminStore.attempt(() => Promise.reject(new ApiError('brandName:长度超过 20', 'INVALID_ARGUMENT', 400)), { INVALID_ARGUMENT: '品牌名太长了。' }) + await expect(failing).rejects.toThrow('品牌名太长了。') + expect(messages()).toEqual([]) + expect(adminStore.state.signedIn).toBe(true) + }) + + it('管理 token 被拒绝时提示并退出登录', async () => { + const rejected = new ApiError('管理 token 无效', 'UNAUTHORIZED', 401) + await expect(adminStore.attempt(() => Promise.reject(rejected))).rejects.toThrow('管理 token 无效') + expect(messages()).toEqual(['管理 token 无效']) + expect(adminStore.state.signedIn).toBe(false) + expect(sessionStorage.getItem('alisten.admin-token')).toBeNull() + }) + + it('已经退出登录时不再重复提示', async () => { + adminStore.signOut() + await expect(adminStore.attempt(() => Promise.reject(new ApiError('管理 token 无效', 'UNAUTHORIZED', 401)))).rejects.toThrow() + expect(messages()).toEqual([]) + }) +}) diff --git a/web/src/features/admin/admin-store.ts b/web/src/features/admin/admin-store.ts index 6a51047..9df8c86 100644 --- a/web/src/features/admin/admin-store.ts +++ b/web/src/features/admin/admin-store.ts @@ -4,6 +4,11 @@ import { errorMessage } from '@/core/errors' import { Store } from '@/shared/lib/store' import { notificationStore } from '@/shared/ui' +// 管理 token 无效、后台没有启用或网址不被服务器接受。 +function rejectsToken(error: unknown): boolean { + return error instanceof ApiError && (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN') +} + class AdminStore extends Store<{ signedIn: boolean }> { constructor() { super({ signedIn: false }) @@ -23,22 +28,39 @@ class AdminStore extends Store<{ signedIn: boolean }> { this.setState({ signedIn: false }) } - // 失败时显示错误并返回 null;管理 token 无效、后台没有启用或网址不被服务器接受时退出登录。 + // 失败时显示错误并返回 null;管理 token 被拒绝时退出登录。 // 204 的接口成功时得到 undefined,调用方要与 null 比较,不能只看真假。 async call(request: () => Promise, texts?: ErrorTexts): Promise { try { return await request() } catch (error) { - // 同时进行的请求会一起失败,退出登录后不再重复提示。 - if (!this.state.signedIn) - return null - notificationStore.error(errorMessage(error, texts)) - if (error instanceof ApiError && (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN')) - this.signOut() + this.#report(error, texts) return null } } + + // 失败时抛出 Error,message 是给用户看的原因,由调用方就地显示,不弹通知。 + // 管理 token 被拒绝时例外:退出登录后这一页就不显示了,所以照常提示。 + async attempt(request: () => Promise, texts?: ErrorTexts): Promise { + try { + return await request() + } + catch (error) { + if (rejectsToken(error)) + this.#report(error, texts) + throw new Error(errorMessage(error, texts)) + } + } + + #report(error: unknown, texts?: ErrorTexts): void { + // 同时进行的请求会一起失败,退出登录后不再重复提示。 + if (!this.state.signedIn) + return + notificationStore.error(errorMessage(error, texts)) + if (rejectsToken(error)) + this.signOut() + } } export const adminStore = new AdminStore() diff --git a/web/src/features/admin/admin.css b/web/src/features/admin/admin.css index 4a36fa2..73e835a 100644 --- a/web/src/features/admin/admin.css +++ b/web/src/features/admin/admin.css @@ -1,36 +1,94 @@ -.admin-page { container: admin / inline-size; max-width: 1040px; } -.admin-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } -.admin-sign-in { max-width: 420px; } -.admin-sign-in > .button-primary { align-self: flex-start; } +.admin-sign-in { display: grid; place-items: center; height: 100%; overflow-y: auto; padding: 32px 16px; background: var(--color-content); } +.admin-sign-in-card { --color-input: var(--color-input-on-card); width: min(400px, 100%); padding: 28px; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-card); } +.admin-sign-in-card h1 { font-size: var(--text-ui-xl); line-height: 1.25; } +.admin-sign-in-card > .button-primary { width: 100%; } +.admin-eyebrow { margin-bottom: 2px; overflow-wrap: anywhere; color: var(--color-brand); font-size: var(--text-ui-sm); font-weight: 500; } + +.admin-section { container: admin / inline-size; } +.admin-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px 16px; margin-bottom: 20px; } +.admin-head-copy { flex: 1 1 14em; min-width: 0; } +.admin-head h2 { font-size: calc(var(--text-ui-lg) + 2px); font-weight: 700; line-height: 1.3; } +.admin-head-description { margin-top: 4px; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); } +.admin-head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } + +.admin-search { display: flex; flex: 0 1 auto; align-items: center; width: 240px; gap: 8px; min-width: 0; min-height: 36px; padding: 0 14px; border: 1px solid var(--color-input-border); border-radius: 999px; background: var(--color-input); color: var(--color-foreground-subtlest); } +.admin-search:focus-within { border-color: var(--color-input-border-focused); background: var(--color-input-focused); } +.admin-search > input { flex: 1; padding: 7px 0; border: 0; border-radius: 0; background: transparent; } +.admin-toolbar { display: flex; margin-bottom: 16px; } +.admin-toolbar > .admin-search { width: 360px; } + +.admin-failed { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 14px 16px; color: var(--color-foreground-subtle); } +.admin-failed > p { flex: 1 1 12em; min-width: 0; } +.admin-hint { padding: 4px; color: var(--color-foreground-subtle); } +.admin-row-error { color: var(--color-destructive); font-size: var(--text-ui-caption); } + +/* 中性底色不用 surface:浅色主题里它和 card 同色,放在卡片上会看不见。 */ +.admin-status { display: inline-flex; flex-shrink: 0; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--color-card-hover); color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-weight: 500; white-space: nowrap; } +.admin-status[data-tone='success'] { background: var(--color-accent); color: var(--color-success); } +.admin-status[data-tone='danger'] { background: color-mix(in srgb, var(--color-destructive) 14%, transparent); color: var(--color-destructive); } + +.admin-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; } +.admin-code { min-width: 0; padding: 6px 10px; border-radius: 8px; background: var(--color-card-hover); color: var(--color-foreground); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--text-ui-sm); overflow-wrap: anywhere; } +.admin-secret { user-select: all; } +.admin-input { width: 280px; max-width: 100%; } +.admin-form-actions { justify-content: flex-end; } +.button-secondary.admin-danger { color: var(--color-destructive); } + +.admin-user-avatar { width: 40px; } +.admin-notice { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-top: 12px; padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent); border-radius: 14px; background: var(--color-accent); } +.admin-notice > p { flex: 1 1 16em; min-width: 0; font-size: var(--text-ui-caption); } + +.admin-table > ul { margin: 0; padding: 0; list-style: none; } +.admin-table-head, .admin-room { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1.2fr) 96px 36px; align-items: center; gap: 12px; padding: 10px 8px 10px 16px; } +.admin-table-head { padding-block: 10px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-sm); } +.admin-room { border-top: 1px solid var(--color-card-hover); } +.admin-room-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.admin-room-name > strong { font-weight: 500; } +.admin-room-name > span, .admin-room-slug { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); } +.admin-room-access { justify-self: start; } +.admin-menu-button { color: var(--color-foreground-subtle); } +.admin-menu-button:hover:not(:disabled), .admin-menu-button[aria-expanded='true'] { color: var(--color-foreground); } + +.admin-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.admin-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 16px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-card); } +.admin-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.admin-card-top > h3 { min-width: 0; font-size: var(--text-ui-base); font-weight: 600; } +.admin-card-detail { overflow-wrap: anywhere; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); } +.admin-card-detail.error-text { margin: 0; color: var(--color-destructive); } +.admin-card-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 10px; } + .admin-field { display: flex; flex-direction: column; gap: 6px; overflow-wrap: anywhere; } .admin-field > .error-text { margin: 0; } -.admin-section + .admin-section { margin-top: 40px; } -.admin-list { list-style: none; margin: 0; padding: 0 20px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface); } -.admin-row { display: flex; align-items: center; gap: 8px; min-height: 68px; padding: 12px 0; } -.admin-row + .admin-row { border-top: 1px solid var(--color-border); } -.admin-row-copy { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; } -.admin-row-title { font-weight: 600; } -.admin-row-meta { display: flex; align-items: center; gap: 6px; min-width: 0; } -.admin-row-actions { display: flex; align-items: center; gap: 8px; } -.admin-brand-name, .admin-github-field { width: 240px; } -.admin-github-callback { overflow-wrap: anywhere; } -.admin-link { color: var(--color-foreground-subtle); } -.admin-link:hover { color: var(--color-brand); } -.admin-empty { padding: 8px 0; } .admin-steps { display: flex; flex-direction: column; gap: 6px; margin: 0; padding-left: 1.25em; list-style: decimal; } .admin-steps ul { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 0; padding-left: 1.25em; list-style: disc; } -.admin-failed { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .admin-qr { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; } .admin-qr-image { width: 200px; height: 200px; padding: 8px; border-radius: 8px; background: #fff; image-rendering: pixelated; } .admin-qr-switch { display: flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--color-input); } .admin-qr-switch > button { min-height: 32px; padding: 6px 16px; border: 0; border-radius: 999px; background: transparent; color: var(--color-foreground-subtle); } .admin-qr-switch > button:hover:not(:disabled) { color: var(--color-foreground); } .admin-qr-switch > button[aria-pressed='true'], .admin-qr-switch > button[aria-pressed='true']:hover:not(:disabled) { background: var(--color-foreground); color: var(--color-popover); } + +.admin-list { list-style: none; margin: 0; padding: 0 20px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface); } +.admin-row { display: flex; align-items: center; gap: 8px; min-height: 68px; padding: 12px 0; } +.admin-row + .admin-row { border-top: 1px solid var(--color-border); } +.admin-row-copy { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; } +.admin-link { color: var(--color-foreground-subtle); } +.admin-link:hover { color: var(--color-brand); } +.admin-empty { padding: 8px 0; } + @media (max-width: 767px) { .admin-list { padding: 0 16px; } } -@container admin (max-width: 600px) { - .admin-row { flex-wrap: wrap; } - .admin-row-actions { flex-basis: 100%; } - .admin-brand-name, .admin-github-field { flex: 1; width: auto; } +@container admin (max-width: 560px) { + .admin-head-actions { flex-basis: 100%; } + .admin-head-actions > .admin-search, .admin-toolbar > .admin-search { flex: 1 1 auto; width: auto; } + .admin-input { width: 100%; } + .admin-cards { grid-template-columns: minmax(0, 1fr); } + .admin-table-head { display: none; } + .admin-room { grid-template: 'name access menu' auto 'slug access menu' auto / minmax(0, 1fr) auto 36px; row-gap: 2px; } + .admin-room:first-child { border-top: 0; } + .admin-room-name { grid-area: name; } + .admin-room-slug { grid-area: slug; } + .admin-room-access { grid-area: access; } + .admin-room > .admin-menu-button { grid-area: menu; } } diff --git a/web/src/features/admin/github-app.tsx b/web/src/features/admin/github-app.tsx index 1625e70..9e35a23 100644 --- a/web/src/features/admin/github-app.tsx +++ b/web/src/features/admin/github-app.tsx @@ -4,6 +4,7 @@ import { api } from '@/core/api' import { ConfirmDialog, notificationStore, Skeleton } from '@/shared/ui' import { adminStore } from './admin-store' import { githubAppDraft } from './github-app-draft' +import { LoadFailed, StatusPill } from './section-head' async function copyCallbackUrl(url: string): Promise { try { @@ -17,8 +18,8 @@ async function copyCallbackUrl(url: string): Promise { } export function GithubApp() { - // undefined 表示正在获取,null 表示获取失败。 - const [app, setApp] = useState() + // undefined 表示正在获取,Error 表示获取失败。 + const [app, setApp] = useState() const [attempt, setAttempt] = useState(0) // null 表示沿用已保存的 Client ID。 const [clientId, setClientId] = useState(null) @@ -28,9 +29,12 @@ export function GithubApp() { const [closing, setClosing] = useState(false) useEffect(() => { let active = true - void adminStore.call(() => api.adminGetGithubCredential()).then((result) => { + adminStore.attempt(() => api.adminGetGithubCredential()).then((result) => { if (active) setApp(result) + }, (error: Error) => { + if (active) + setApp(error) }) return () => { active = false @@ -42,9 +46,13 @@ export function GithubApp() { setClientSecret('') setAttempt(n => n + 1) } - const draft = githubAppDraft(clientId ?? app?.clientId ?? '', clientSecret) + if (app instanceof Error) + return + if (app === undefined) + return + const draft = githubAppDraft(clientId ?? app.clientId ?? '', clientSecret) const save = async () => { - if (draft === null || !app) + if (draft === null) return setBusy(true) try { @@ -59,74 +67,56 @@ export function GithubApp() { } } return ( -
-
-

GitHub 登录

-
- {app === null - ? ( -
-

暂时无法获取 GitHub 登录的配置。

- -
- ) - : app === undefined - ? - : ( -
{ - event.preventDefault() - void save() - }} - > -
-
- {app.clientId === null ? '未开启' : '已开启'} - - {app.clientId === null - ? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。' - : '用户可以用 GitHub 登录、注册和绑定账号。'} - -
- {app.clientId !== null && ( -
- -
- )} -
-
-
- 回调地址 - {app.callbackUrl} - OAuth App 的 Authorization callback URL 填这个地址。 -
-
- -
-
-
-
- -
-
- setClientId(event.target.value)} /> -
-
-
-
- - - {app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'} - -
-
- setClientSecret(event.target.value)} /> - -
-
-
- )} + <> +
{ + event.preventDefault() + void save() + }} + > +
+
+ 状态 + + {app.clientId === null + ? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。' + : '用户可以用 GitHub 登录、注册和绑定账号。'} + +
+ {app.clientId === null ? '未开启' : '已开启'} +
+
+
+ 回调地址 + OAuth App 的 Authorization callback URL 填这个地址。 +
+
+ {app.callbackUrl} + +
+
+
+
+ +
+ setClientId(event.target.value)} /> +
+
+
+ + + {app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'} + +
+ setClientSecret(event.target.value)} /> +
+
+ {app.clientId !== null && } + +
+
+ {/* 关闭之后重新读取配置,整个表单换成骨架屏:焦点落到分类标题。 */} {closing && ( setClosing(false)} + returnFocus={() => document.getElementById('admin-registration-title')} > 关闭后,不能再用 GitHub 登录、注册或绑定,已经绑定的 GitHub 保持绑定。只绑定了 GitHub 的账号,关闭之后就无法用 GitHub 登录,重新开启之前也无法在其他设备上登录。 )} -
+ ) } diff --git a/web/src/features/admin/persistent-rooms.tsx b/web/src/features/admin/persistent-rooms.tsx index 28b583d..449c1c1 100644 --- a/web/src/features/admin/persistent-rooms.tsx +++ b/web/src/features/admin/persistent-rooms.tsx @@ -1,101 +1,151 @@ import type { RoomListing } from '@/core/realtime' -import { KeyRound, LockKeyhole, LogIn, Pencil, Plus, Share2, Trash2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { DoorOpen, Ellipsis, KeyRound, LockKeyhole, LogIn, Pencil, Plus, Search, SearchX, Share2, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' import { api } from '@/core/api' import { copyInviteLink, roomEntryStore } from '@/features/rooms' -import { ConfirmDialog, IconButton, Skeleton } from '@/shared/ui' +import { rowAfterRemoval } from '@/shared/lib/removal-focus' +import { ActionMenu, ConfirmDialog, EmptyState, Pagination, Skeleton } from '@/shared/ui' import { adminStore } from './admin-store' +import { rowOrHeading } from './removal-focus' import { RoomApiKeyDialog } from './room-api-key-dialog' +import { filterRooms } from './room-filter' import { RoomFormDialog } from './room-form-dialog' +import { LoadFailed, SectionHead, StatusPill } from './section-head' -export function PersistentRooms({ onEntered }: { onEntered: (ref: string) => void }) { +function enterRoom(room: RoomListing, onEntered: (ref: string) => void): void { + const ref = room.slug ?? room.id + const entry = roomEntryStore.state + if (entry.phase !== 'joined' || entry.room?.id !== room.id) + void roomEntryStore.enter(ref, room) + onEntered(ref) +} + +// q 和 page 来自地址:改搜索词时回到第 1 页。 +export function PersistentRooms({ q, page, onFilter, onEntered }: { + q: string + page: number + onFilter: (filter: { q: string, page?: number }) => void + onEntered: (ref: string) => void +}) { const [rooms, setRooms] = useState(null) - const [failed, setFailed] = useState(false) + const [failure, setFailure] = useState(null) const [reload, setReload] = useState(0) const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) const [keyOf, setKeyOf] = useState(null) + // 删掉的房间之后焦点落到哪一行:null 表示落到标题。 + const nextFocusRef = useRef(null) useEffect(() => { let active = true - void adminStore.call(() => api.adminListRooms()).then((result) => { - if (!active) - return - if (result) + adminStore.attempt(() => api.adminListRooms()).then((result) => { + if (active) setRooms(result.items) - else - setFailed(true) + }, (error: Error) => { + if (active) + setFailure(error.message) }) return () => { active = false } }, [reload]) const refresh = () => { - setFailed(false) + setFailure(null) setReload(n => n + 1) } const retry = () => { setRooms(null) refresh() } + const shown = rooms && filterRooms(rooms, q, page) + // 删掉房间或者打开旧链接时,地址里的页码可能超出范围,改成实际显示的那一页。 + const shownPage = shown?.page + useEffect(() => { + if (shownPage !== undefined && shownPage !== page) + onFilter({ q, page: shownPage }) + }, [shownPage, page, q, onFilter]) return (
-
-

常驻房间

- -
- {failed - ? ( -
-

暂时无法获取常驻房间。

- -
- ) - : !rooms + + {failure !== null + ? + : !shown ? - : !rooms.length - ?

还没有常驻房间。

+ : !shown.items.length + ? q.trim() + ? ( + onFilter({ q: '' })}>清除搜索}> + 换个名称或别名再找找。 + + ) + : ( + setEditing('new')}>新建房间} /> + ) : ( -
    - {rooms.map(room => ( -
  • -
    - {room.name} - - {room.slug ? `/r/${room.slug}` : '没有别名'} - {room.hasPassword && ( - <> - - -
    -
    - { - const ref = room.slug ?? room.id - const entry = roomEntryStore.state - if (entry.phase !== 'joined' || entry.room?.id !== room.id) - void roomEntryStore.enter(ref, room) - onEntered(ref) - }} - /> - void copyInviteLink(room)} /> - setKeyOf(room)} /> - setEditing(room)} /> - setDeleting(room)} /> -
    -
  • - ))} -
+ <> +
+ +
    + {shown.items.map(room => ( +
  • +
    + {room.name} + {room.onlineCount ? `${room.onlineCount} 人在线` : '无人在线'} +
    + + {room.slug + ? `/r/${room.slug}` + : ( + <> + + 没有别名 + + )} + + + {room.hasPassword + ? ( + + + ) + : 公开} + + enterRoom(room, onEntered) }, + { icon: Share2, label: '复制邀请链接', run: () => void copyInviteLink(room) }, + { icon: Pencil, label: '编辑', run: () => setEditing(room) }, + { icon: KeyRound, label: 'API Key', run: () => setKeyOf(room) }, + { icon: Trash2, label: '删除', danger: true, run: () => setDeleting(room) }, + ]} + menuLabel={`「${room.name}」的操作`} + className="icon-button admin-menu-button" + label={`「${room.name}」的更多操作`} + title="更多操作" + > + +
  • + ))} +
+
+ onFilter({ q, page: next })} /> + )} {editing && ( voi confirmLabel="删除房间" danger onConfirm={async () => { - if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) !== null) { - setDeleting(null) - refresh() - } + if (await adminStore.call(() => api.adminDeleteRoom(deleting.id)) === null) + return + // 先在本地去掉这个房间,对话框关闭时那一行已经不在了,焦点才能落到下一行;之后再重新读取。 + const remaining = rooms?.filter(room => room.id !== deleting.id) ?? [] + const next = filterRooms(remaining, q, page) + // 删空了这一页时显示的是上一页,那里没有“下一行”。 + nextFocusRef.current = rowAfterRemoval(shown?.items.map(room => room.id) ?? [], next.page === shown?.page ? next.items.map(room => room.id) : [], [deleting.id]) + setRooms(remaining) + setDeleting(null) + refresh() }} onClose={() => setDeleting(null)} + returnFocus={() => rowOrHeading('admin-rooms-title', nextFocusRef.current)} > {`删除「${deleting.name}」后,房间里的所有人都会断开连接。`} diff --git a/web/src/features/admin/registration.tsx b/web/src/features/admin/registration.tsx new file mode 100644 index 0000000..669943d --- /dev/null +++ b/web/src/features/admin/registration.tsx @@ -0,0 +1,78 @@ +import { useEffect, useId, useState } from 'react' +import { api } from '@/core/api' +import { useSettingSave } from '@/shared/lib/use-setting-save' +import { SaveStatus, Skeleton, Switch } from '@/shared/ui' +import { adminStore } from './admin-store' +import { GithubApp } from './github-app' +import { LoadFailed, SectionHead } from './section-head' + +// 保存期间不禁用开关:禁用会让它失去焦点,键盘用户就找不到它了。 +function OpenRegistrationRow({ open, onSaved }: { open: boolean, onSaved: (open: boolean) => void }) { + const switchId = useId() + const descriptionId = useId() + const errorId = useId() + const setting = useSettingSave(open, async (next: boolean) => { + await adminStore.attempt(() => next ? api.adminOpenRegistration() : api.adminCloseRegistration()) + onSaved(next) + }) + const { error } = setting.state + return ( +
+
+
+ + 关闭后不能注册新账号,已有账号照常登录,游客照常使用。 + {error && {error}} +
+
+ + +
+
+
+ ) +} + +function OpenRegistration() { + // undefined 表示正在获取,Error 表示获取失败。 + const [open, setOpen] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let active = true + adminStore.attempt(() => api.getSite()).then((site) => { + if (active) + setOpen(site.registrationOpen) + }, (error: Error) => { + if (active) + setOpen(error) + }) + return () => { + active = false + } + }, [attempt]) + if (open instanceof Error) { + return ( + { + setOpen(undefined) + setAttempt(n => n + 1) + }} + /> + ) + } + if (open === undefined) + return + return +} + +export function Registration() { + return ( +
+ + +

GitHub 登录

+ +
+ ) +} diff --git a/web/src/features/admin/removal-focus.ts b/web/src/features/admin/removal-focus.ts new file mode 100644 index 0000000..ad887e9 --- /dev/null +++ b/web/src/features/admin/removal-focus.ts @@ -0,0 +1,6 @@ +// 行是带 data-key 的 li,焦点落到它的第一个控件上;没有这一行时落到分类标题。 +export function rowOrHeading(headingId: string, key: string | null): HTMLElement | null { + const heading = document.getElementById(headingId) + const row = key === null ? null : heading?.closest('section')?.querySelector(`li[data-key="${CSS.escape(key)}"] :is(input, button)`) + return row ?? heading +} diff --git a/web/src/features/admin/room-filter.test.ts b/web/src/features/admin/room-filter.test.ts new file mode 100644 index 0000000..534e745 --- /dev/null +++ b/web/src/features/admin/room-filter.test.ts @@ -0,0 +1,46 @@ +import type { RoomListing } from '@/core/realtime' +import { describe, expect, it } from 'vitest' +import { filterRooms } from './room-filter' + +function room(id: string, name: string, slug: string | null = null): RoomListing { + return { id, name, slug, description: '', hasPassword: false, kind: 'persistent', onlineCount: 0 } +} + +function rooms(count: number): RoomListing[] { + return Array.from({ length: count }, (_, index) => room(String(index + 1), `房间 ${index + 1}`)) +} + +const ids = (list: RoomListing[]) => list.map(item => item.id) + +describe('filterRooms', () => { + const sample = [room('a', '大猫听歌房', 'livehouse'), room('b', 'MyTestRoom'), room('c', '深夜电台', 'Night')] + + it('空查询返回全部', () => { + expect(filterRooms(sample, '', 1)).toEqual({ items: sample, totalPages: 1, page: 1 }) + expect(ids(filterRooms(sample, ' ', 1).items)).toEqual(['a', 'b', 'c']) + }) + + it('按名称或别名匹配,不区分大小写', () => { + expect(ids(filterRooms(sample, 'mytest', 1).items)).toEqual(['b']) + expect(ids(filterRooms(sample, 'LIVE', 1).items)).toEqual(['a']) + expect(ids(filterRooms(sample, 'night', 1).items)).toEqual(['c']) + expect(ids(filterRooms(sample, '电台', 1).items)).toEqual(['c']) + expect(filterRooms(sample, 'jazz', 1)).toEqual({ items: [], totalPages: 1, page: 1 }) + }) + + it('每页 20 条', () => { + const all = rooms(45) + const first = filterRooms(all, '', 1) + expect(first.totalPages).toBe(3) + expect(ids(first.items)).toEqual(ids(all.slice(0, 20))) + expect(ids(filterRooms(all, '', 3).items)).toEqual(['41', '42', '43', '44', '45']) + }) + + it('页码超出时退到最后一页', () => { + const result = filterRooms(rooms(45), '', 9) + expect(result.page).toBe(3) + expect(result.items).toHaveLength(5) + expect(filterRooms(rooms(45), '房间 4', 2)).toMatchObject({ totalPages: 1, page: 1 }) + expect(filterRooms([], '', 2)).toEqual({ items: [], totalPages: 1, page: 1 }) + }) +}) diff --git a/web/src/features/admin/room-filter.ts b/web/src/features/admin/room-filter.ts new file mode 100644 index 0000000..b0b6a30 --- /dev/null +++ b/web/src/features/admin/room-filter.ts @@ -0,0 +1,14 @@ +import type { RoomListing } from '@/core/realtime' + +const roomsPerPage = 20 + +// 常驻房间的数量有限,一次取回全部,在前端搜索和分页。 +export function filterRooms(rooms: RoomListing[], q: string, page: number): { items: RoomListing[], totalPages: number, page: number } { + const needle = q.trim().toLowerCase() + const matched = needle + ? rooms.filter(room => room.name.toLowerCase().includes(needle) || room.slug?.toLowerCase().includes(needle)) + : rooms + const totalPages = Math.max(1, Math.ceil(matched.length / roomsPerPage)) + const current = Math.min(page, totalPages) + return { items: matched.slice((current - 1) * roomsPerPage, current * roomsPerPage), totalPages, page: current } +} diff --git a/web/src/features/admin/section-head.tsx b/web/src/features/admin/section-head.tsx new file mode 100644 index 0000000..e15ac12 --- /dev/null +++ b/web/src/features/admin/section-head.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' +import { CloudOff } from 'lucide-react' + +// 分类的标题要和内容一起立即渲染,加载中也不例外:从分类栏切换过来之后,焦点要落在它上面。 +export function SectionHead({ id, title, description, children }: { id: string, title: string, description?: string, children?: ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {children &&
{children}
} +
+ ) +} + +export function LoadFailed({ message, onRetry }: { message: string, onRetry: () => void }) { + return ( +
+
+ ) +} + +export type StatusTone = 'success' | 'danger' | 'neutral' + +export function StatusPill({ tone, children }: { tone: StatusTone, children: ReactNode }) { + return {children} +} diff --git a/web/src/features/admin/site-settings.tsx b/web/src/features/admin/site-settings.tsx index 4890b64..722eb5c 100644 --- a/web/src/features/admin/site-settings.tsx +++ b/web/src/features/admin/site-settings.tsx @@ -1,150 +1,58 @@ import { useEffect, useState } from 'react' import { api } from '@/core/api' import { brandStore } from '@/core/brand' +import { errorMessage } from '@/core/errors' import { useStore } from '@/shared/lib/use-store' -import { notificationStore, Skeleton } from '@/shared/ui' +import { InlineTextField, Skeleton } from '@/shared/ui' import { adminStore } from './admin-store' +import { LoadFailed, SectionHead } from './section-head' + +async function saveBrandName(name: string): Promise { + const saved = await adminStore.attempt(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' }) + // 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。 + brandStore.setName(saved.brandName) +} export function SiteSettings() { const brandName = useStore(brandStore, s => s.name) - const [status, setStatus] = useState<'loading' | 'failed' | 'loaded'>('loading') + // undefined 表示正在获取,Error 表示获取失败。 + const [status, setStatus] = useState<'loaded' | Error>() const [attempt, setAttempt] = useState(0) - const [draft, setDraft] = useState(null) - const [busy, setBusy] = useState(false) useEffect(() => { let active = true brandStore.load().then(() => { if (active) setStatus('loaded') - }, () => { + }, (error: unknown) => { if (active) - setStatus('failed') + setStatus(new Error(errorMessage(error))) }) return () => { active = false } }, [attempt]) const retry = () => { - setStatus('loading') + setStatus(undefined) setAttempt(n => n + 1) } - const name = draft ?? brandName - const save = async () => { - setBusy(true) - try { - const saved = await adminStore.call(() => api.adminUpdateSite({ brandName: name }), { INVALID_ARGUMENT: '品牌名要有 1 到 20 个字,不能含换行、制表符等特殊字符。' }) - if (saved === null) - return - // 显示服务端存下的名字:浏览器和服务端去掉首尾空白的规则不完全相同。 - brandStore.setName(saved.brandName) - setDraft(null) - notificationStore.success('已保存品牌名') - } - finally { - setBusy(false) - } - } return (
-
-

站点

-
- {status === 'failed' - ? ( -
-

暂时无法获取品牌名。

- -
- ) - : status === 'loading' - ? + + {status === undefined + ? + : status instanceof Error + ? : ( -
{ - event.preventDefault() - void save() - }} - > -
-
- - 显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。 -
-
- setDraft(event.target.value)} /> - +
+
+
+ 品牌名 + 显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。
+
- - +
)}
) } - -function RegistrationSetting() { - const [open, setOpen] = useState(null) - const [failed, setFailed] = useState(false) - const [attempt, setAttempt] = useState(0) - const [busy, setBusy] = useState(false) - useEffect(() => { - let active = true - api.getSite().then((site) => { - if (active) - setOpen(site.registrationOpen) - }, () => { - if (active) - setFailed(true) - }) - return () => { - active = false - } - }, [attempt]) - const toggle = async () => { - if (open === null) - return - setBusy(true) - try { - const done = await adminStore.call(() => open ? api.adminCloseRegistration() : api.adminOpenRegistration()) - if (done === null) - return - setOpen(!open) - notificationStore.success(open ? '已关闭注册' : '已开放注册') - } - finally { - setBusy(false) - } - } - const description = failed - ? '暂时无法获取注册状态。' - : open === null - ? '正在获取注册状态…' - : open - ? '现在任何人都可以注册账号。关闭后不能再注册新账号,已有账号照常登录,游客照常使用。' - : '现在不能注册新账号。已有账号照常登录,游客照常使用。' - return ( -
-
- 注册 - {description} -
-
- {failed - ? ( - - ) - : } -
-
- ) -} diff --git a/web/src/features/admin/source-account-view.test.ts b/web/src/features/admin/source-account-view.test.ts index 56e9bc0..b69bfd3 100644 --- a/web/src/features/admin/source-account-view.test.ts +++ b/web/src/features/admin/source-account-view.test.ts @@ -1,28 +1,29 @@ -import type { AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api' +import type { CredentialState } from './source-account-view' import { describe, expect, it } from 'vitest' -import { sourceAccountView } from './source-account-view' +import { needsSignIn, sourceAccountView } from './source-account-view' -type State = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | null | undefined - -const cases: { name: string, state: State, want: ReturnType }[] = [ - { name: '正在查询', state: undefined, want: { status: '正在查询…', details: [] } }, - { name: '查询失败', state: null, want: { status: '查询失败', details: [] } }, - { name: '没有保存凭据', state: { set: false, account: null }, want: { status: '未登录', details: [] } }, - { name: '保存凭据但没有账号资料', state: { set: true, account: null }, want: { status: '已保存登录信息,未识别到账号', details: [] } }, +const cases: { name: string, state: CredentialState, want: ReturnType, attention: boolean }[] = [ + { name: '正在查询', state: undefined, want: { status: '正在查询', tone: 'neutral', detail: '' }, attention: false }, + { name: '查询失败时说明原因', state: new Error('网络不通'), want: { status: '查询失败', tone: 'neutral', detail: '网络不通' }, attention: false }, + { name: '没有保存凭据', state: { set: false, account: null }, want: { status: '未登录', tone: 'danger', detail: '' }, attention: true }, + { name: '保存凭据但没有账号资料', state: { set: true, account: null }, want: { status: '未识别账号', tone: 'danger', detail: '已保存登录信息,但没有识别出账号。' }, attention: true }, { name: '上游已验证的会员账号', state: { set: true, account: { id: '123', name: '测试账号', verified: true, vip: true } }, - want: { status: '已登录', details: ['昵称:测试账号', '账号 ID:123', '会员状态:会员'] }, + want: { status: '已登录', tone: 'success', detail: '测试账号 · 会员' }, + attention: false, }, { name: '非会员和未知昵称', state: { set: true, account: { id: '123', name: null, verified: true, vip: false } }, - want: { status: '已登录', details: ['账号 ID:123', '会员状态:非会员'] }, + want: { status: '已登录', tone: 'success', detail: 'ID 123 · 非会员' }, + attention: false, }, { name: '仅从本地凭据识别的账号', state: { set: true, account: { id: '456', name: null, verified: false, vip: null } }, - want: { status: '已保存登录信息,未经 QQ 音乐核实', details: ['账号 ID:456'] }, + want: { status: '未经核实', tone: 'danger', detail: 'ID 456 · 已保存登录信息,未经 QQ 音乐核实' }, + attention: true, }, ] @@ -33,3 +34,12 @@ describe('sourceAccountView', () => { }) } }) + +// 后台分类栏的“音源”是否显示红点:查询中和查询失败时不知道状态,不显示。 +describe('needsSignIn', () => { + for (const tc of cases) { + it(tc.name, () => { + expect(needsSignIn(tc.state)).toBe(tc.attention) + }) + } +}) diff --git a/web/src/features/admin/source-account-view.ts b/web/src/features/admin/source-account-view.ts index 267b40d..30edf45 100644 --- a/web/src/features/admin/source-account-view.ts +++ b/web/src/features/admin/source-account-view.ts @@ -1,31 +1,33 @@ +import type { StatusTone } from './section-head' import type { AdminGetBilibiliCredentialResponse, AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api' -export type CredentialState = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | AdminGetBilibiliCredentialResponse | null | undefined +// undefined 表示正在查询,Error 表示查询失败,message 是给用户看的原因。 +export type CredentialState = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | AdminGetBilibiliCredentialResponse | Error | undefined // 接在中文后面的平台名以英文开头时,前面留一个空格,如「已登录 QQ 音乐」。 export function afterChinese(platform: string): string { return /^[a-z0-9]/i.test(platform) ? ` ${platform}` : platform } -export function sourceAccountView(state: CredentialState, platform: string): { status: string, details: string[] } { +export function needsSignIn(state: CredentialState): boolean { + return state !== undefined && !(state instanceof Error) && !state.account?.verified +} + +export function sourceAccountView(state: CredentialState, platform: string): { status: string, tone: StatusTone, detail: string } { if (state === undefined) - return { status: '正在查询…', details: [] } - if (state === null) - return { status: '查询失败', details: [] } + return { status: '正在查询', tone: 'neutral', detail: '' } + if (state instanceof Error) + return { status: '查询失败', tone: 'neutral', detail: state.message } if (!state.set) - return { status: '未登录', details: [] } + return { status: '未登录', tone: 'danger', detail: '' } if (state.account === null) - return { status: '已保存登录信息,未识别到账号', details: [] } + return { status: '未识别账号', tone: 'danger', detail: '已保存登录信息,但没有识别出账号。' } const { account } = state - const details: string[] = [] - if (account.name !== null) - details.push(`昵称:${account.name}`) - details.push(`账号 ID:${account.id}`) + const details = [account.name ?? `ID ${account.id}`] if (account.vip !== null) - details.push(`会员状态:${account.vip ? '会员' : '非会员'}`) - return { - status: account.verified ? '已登录' : `已保存登录信息,未经${afterChinese(platform)}核实`, - details, - } + details.push(account.vip ? '会员' : '非会员') + if (!account.verified) + details.push(`已保存登录信息,未经${afterChinese(platform)}核实`) + return { status: account.verified ? '已登录' : '未经核实', tone: account.verified ? 'success' : 'danger', detail: details.join(' · ') } } diff --git a/web/src/features/admin/source-accounts.tsx b/web/src/features/admin/source-accounts.tsx index e32a7b0..e8770bc 100644 --- a/web/src/features/admin/source-accounts.tsx +++ b/web/src/features/admin/source-accounts.tsx @@ -1,68 +1,27 @@ -import type { QrLoginTarget } from './qr-login-dialog' -import type { CredentialState } from './source-account-view' -import { useEffect, useState } from 'react' -import { api } from '@/core/api' +import type { Credential, Source, SourceId } from './source-credentials' +import { useId, useState } from 'react' import { notificationStore } from '@/shared/ui' -import { adminStore } from './admin-store' import { QrLoginDialog } from './qr-login-dialog' +import { SectionHead, StatusPill } from './section-head' import { afterChinese, sourceAccountView } from './source-account-view' +import { sources } from './source-credentials' import { YouTubeCookies } from './youtube-cookies' -interface Source { - name: string - credential: () => Promise> - targets: QrLoginTarget[] -} - -const sources: Source[] = [ - { - name: 'QQ 音乐', - credential: () => api.adminGetQqCredential(), - targets: [ - { label: 'QQ 扫码', start: () => api.adminStartQqLogin({ type: 'qq' }), check: key => api.adminCheckQqLogin({ type: 'qq', key }) }, - { label: '微信扫码', start: () => api.adminStartQqLogin({ type: 'wx' }), check: key => api.adminCheckQqLogin({ type: 'wx', key }) }, - ], - }, - { - name: '网易云音乐', - credential: () => api.adminGetNeteaseCredential(), - targets: [ - { label: '网易云音乐扫码', start: () => api.adminStartNeteaseLogin(), check: key => api.adminCheckNeteaseLogin({ key }) }, - ], - }, - { - name: '哔哩哔哩', - credential: () => api.adminGetBilibiliCredential(), - targets: [ - { label: '哔哩哔哩扫码', start: () => api.adminStartBilibiliLogin(), check: key => api.adminCheckBilibiliLogin({ key }) }, - ], - }, -] - -function SourceAccount({ source }: { source: Source }) { - const [credential, setCredential] = useState() - const [attempt, setAttempt] = useState(0) +function SourceCard({ source, credential: { state, reload } }: { source: Source, credential: Credential }) { + const titleId = useId() const [signingIn, setSigningIn] = useState(false) - useEffect(() => { - let active = true - void adminStore.call(source.credential).then((result) => { - if (active) - setCredential(result) - }) - return () => { - active = false - } - }, [source, attempt]) - const view = sourceAccountView(credential, source.name) + const view = sourceAccountView(state, source.name) return ( -
  • -
    - {source.name} - {view.status} - {view.details.map(detail => {detail})} +
    +
    +

    {source.name}

    + {view.status}
    -
    - + {view.detail &&

    {view.detail}

    } +
    + {state instanceof Error + ? + : }
    {signingIn && ( { setSigningIn(false) notificationStore.success(`已登录${afterChinese(source.name)}`) - setCredential(undefined) - setAttempt(n => n + 1) + reload() }} onClose={() => setSigningIn(false)} /> )} -
  • + ) } -export function SourceAccounts() { +export function SourceAccounts({ credentials }: { credentials: Record }) { return ( -
    -
    -

    音源账号

    -
    -
      - {sources.map(source => )} +
      + +
      + {(Object.keys(sources) as SourceId[]).map(id => )} -
    +
    ) } diff --git a/web/src/features/admin/source-credentials.ts b/web/src/features/admin/source-credentials.ts new file mode 100644 index 0000000..c8c92bd --- /dev/null +++ b/web/src/features/admin/source-credentials.ts @@ -0,0 +1,77 @@ +import type { QrLoginTarget } from './qr-login-dialog' +import type { CredentialState } from './source-account-view' +import { useEffect, useState } from 'react' +import { api } from '@/core/api' +import { adminStore } from './admin-store' + +export interface Source { + name: string + credential: () => Promise> + targets: QrLoginTarget[] +} + +export type SourceId = 'netease' | 'qq' | 'bilibili' + +export const sources: Record = { + netease: { + name: '网易云音乐', + credential: () => api.adminGetNeteaseCredential(), + targets: [ + { label: '网易云音乐扫码', start: () => api.adminStartNeteaseLogin(), check: key => api.adminCheckNeteaseLogin({ key }) }, + ], + }, + qq: { + name: 'QQ 音乐', + credential: () => api.adminGetQqCredential(), + targets: [ + { label: 'QQ 扫码', start: () => api.adminStartQqLogin({ type: 'qq' }), check: key => api.adminCheckQqLogin({ type: 'qq', key }) }, + { label: '微信扫码', start: () => api.adminStartQqLogin({ type: 'wx' }), check: key => api.adminCheckQqLogin({ type: 'wx', key }) }, + ], + }, + bilibili: { + name: '哔哩哔哩', + credential: () => api.adminGetBilibiliCredential(), + targets: [ + { label: '哔哩哔哩扫码', start: () => api.adminStartBilibiliLogin(), check: key => api.adminCheckBilibiliLogin({ key }) }, + ], + }, +} + +export interface Credential { + state: CredentialState + reload: () => void +} + +function useCredential(source: Source): Credential { + const [state, setState] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let active = true + adminStore.attempt(source.credential).then((result) => { + if (active) + setState(result) + }, (error: Error) => { + if (active) + setState(error) + }) + return () => { + active = false + } + }, [source, attempt]) + return { + state, + reload: () => { + setState(undefined) + setAttempt(n => n + 1) + }, + } +} + +// 分类栏要根据登录状态给“音源”加红点,所以后台页一打开就查询,不等打开这个分类。 +export function useSourceCredentials(): Record { + return { + netease: useCredential(sources.netease), + qq: useCredential(sources.qq), + bilibili: useCredential(sources.bilibili), + } +} diff --git a/web/src/features/admin/stored-media.tsx b/web/src/features/admin/stored-media.tsx index 666d17d..4ffd102 100644 --- a/web/src/features/admin/stored-media.tsx +++ b/web/src/features/admin/stored-media.tsx @@ -7,6 +7,7 @@ import { bookTitle } from '@/shared/lib/book-title' import { formatDateTime } from '@/shared/lib/time' import { ConfirmDialog, IconButton, notificationStore, Skeleton } from '@/shared/ui' import { adminStore } from './admin-store' +import { LoadFailed, SectionHead } from './section-head' function formatSize(bytes: number): string { return `${(bytes / 1024 / 1024).toFixed(1)} MiB` @@ -38,17 +39,11 @@ export function StoredMedia() { } return (
    -
    -

    已存储的媒体

    + {media && {media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}} -
    + {failed - ? ( -
    -

    暂时无法获取已存储的媒体。

    - -
    - ) + ? : !media ? : !media.items.length diff --git a/web/src/features/admin/youtube-cookies-view.ts b/web/src/features/admin/youtube-cookies-view.ts index ee7e9b2..fbb6af3 100644 --- a/web/src/features/admin/youtube-cookies-view.ts +++ b/web/src/features/admin/youtube-cookies-view.ts @@ -1,6 +1,7 @@ import type { AdminGetYoutubeCredentialResponse } from '@/core/api' -export type YoutubeCredentialState = AdminGetYoutubeCredentialResponse | null | undefined +// undefined 表示正在查询,Error 表示查询失败,message 是给用户看的原因。 +export type YoutubeCredentialState = AdminGetYoutubeCredentialResponse | Error | undefined export const youtubeCookiesText = { plaintextWarning: '当前连接不是 HTTPS,填写的 cookies 会以明文在网络上传输,只在可信网络中填写。', @@ -28,8 +29,8 @@ export function shouldWarnAboutPlaintext(location: Pick { }) describe('youtubeCookiesView', () => { - const cases: { name: string, state: AdminGetYoutubeCredentialResponse | null | undefined, want: ReturnType }[] = [ - { name: '正在查询', state: undefined, want: { status: '正在查询…', updatedAtMs: null } }, - { name: '查询失败', state: null, want: { status: '查询失败', updatedAtMs: null } }, + const cases: { name: string, state: AdminGetYoutubeCredentialResponse | Error | undefined, want: ReturnType }[] = [ + { name: '正在查询', state: undefined, want: { status: '正在查询', updatedAtMs: null } }, + { name: '查询失败', state: new Error('网络不通'), want: { status: '查询失败', updatedAtMs: null } }, { name: '未配置', state: { set: false, updatedAtMs: 0 }, want: { status: '未配置', updatedAtMs: null } }, { name: '已配置并显示更新时间', state: { set: true, updatedAtMs: 1_793_184_000_000 }, want: { status: '已配置', updatedAtMs: 1_793_184_000_000 } }, ] diff --git a/web/src/features/admin/youtube-cookies.tsx b/web/src/features/admin/youtube-cookies.tsx index a3cc925..4ed046a 100644 --- a/web/src/features/admin/youtube-cookies.tsx +++ b/web/src/features/admin/youtube-cookies.tsx @@ -4,6 +4,7 @@ import { api } from '@/core/api' import { formatDateTime } from '@/shared/lib/time' import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui' import { adminStore } from './admin-store' +import { StatusPill } from './section-head' import { canSubmitYoutubeCookies, shouldWarnAboutPlaintext, youtubeCookiesText, youtubeCookiesTooLarge, youtubeCookiesView } from './youtube-cookies-view' function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClose: () => void }) { @@ -65,15 +66,19 @@ function YouTubeCookiesDialog({ onSaved, onClose }: { onSaved: () => void, onClo } export function YouTubeCookies() { + const titleId = useId() const [credential, setCredential] = useState() const [attempt, setAttempt] = useState(0) const [editing, setEditing] = useState(false) const [clearing, setClearing] = useState(false) useEffect(() => { let active = true - void adminStore.call(() => api.adminGetYoutubeCredential()).then((result) => { + adminStore.attempt(() => api.adminGetYoutubeCredential()).then((result) => { if (active) setCredential(result) + }, (error: Error) => { + if (active) + setCredential(error) }) return () => { active = false @@ -85,16 +90,23 @@ export function YouTubeCookies() { setAttempt(n => n + 1) } return ( -
  • -
    - YouTube - {view.status} - {view.updatedAtMs !== null && {`最近填写于 ${formatDateTime(view.updatedAtMs)}`}} - {shouldWarnAboutPlaintext(window.location) && {youtubeCookiesText.plaintextWarning}} +
    +
    +

    YouTube

    + {view.status}
    -
    - - {credential?.set && } + {credential instanceof Error &&

    {credential.message}

    } + {view.updatedAtMs !== null &&

    {`${formatDateTime(view.updatedAtMs)} 更新`}

    } + {shouldWarnAboutPlaintext(window.location) &&

    {youtubeCookiesText.plaintextWarning}

    } +
    + {credential instanceof Error + ? + : ( + <> + + {credential?.set && } + + )}
    {editing && ( setEditing(false)} /> )} + {/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点:焦点落到分类标题。 */} {clearing && ( setClearing(false)} + returnFocus={() => document.getElementById('admin-sources-title')} > 清除后,下载 YouTube 链接时不再携带 cookies。 )} -
  • + ) } diff --git a/web/src/features/history/history-page.tsx b/web/src/features/history/history-page.tsx index 98fecde..1d42f3b 100644 --- a/web/src/features/history/history-page.tsx +++ b/web/src/features/history/history-page.tsx @@ -1,6 +1,6 @@ import type { MyListen } from '@/core/realtime' import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { roomStore, sameTrack } from '@/core/room' import { SaveToPlaylistButton } from '@/features/playlists' import { enqueue, sourceName } from '@/features/queue' @@ -68,6 +68,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track) const [limit, setLimit] = useState(batchSize) const [confirming, setConfirming] = useState(false) + const titleRef = useRef(null) useEffect(() => historyStore.open(), []) const all = listens ?? [] const visible = all.filter(listen => matches(listen, filter)) @@ -109,7 +110,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac {!compact && ( <>
    -

    播放历史

    +

    播放历史

    重温那些一起听过的旋律。

    @@ -149,6 +150,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac setConfirming(false) }} onClose={() => setConfirming(false)} + returnFocus={() => titleRef.current} > 将清空你的全部播放记录,登录同一个账号的其他设备上也会一起清空。 diff --git a/web/src/features/playlists/import-menu.tsx b/web/src/features/playlists/import-menu.tsx index fd4b246..88b05e4 100644 --- a/web/src/features/playlists/import-menu.tsx +++ b/web/src/features/playlists/import-menu.tsx @@ -1,8 +1,7 @@ import type { MyPlaylist } from '@/core/realtime' import { FileUp, Import, Link } from 'lucide-react' import { useRef, useState } from 'react' -import { Modal, notificationStore } from '@/shared/ui' -import { ActionMenu } from './action-menu' +import { ActionMenu, Modal, notificationStore } from '@/shared/ui' import { importLimit, parsePlaylistFile } from './playlist-file' import { playlistsStore } from './playlists-store' import { defaultImportName } from './save-platform-playlist' diff --git a/web/src/features/playlists/my-playlist-page.tsx b/web/src/features/playlists/my-playlist-page.tsx index d683ca0..eb7ccd7 100644 --- a/web/src/features/playlists/my-playlist-page.tsx +++ b/web/src/features/playlists/my-playlist-page.tsx @@ -2,12 +2,13 @@ import type { MovePlan } from './track-order' import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime' import type { SongAction } from '@/shared/ui' import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api } from '@/core/api' import { errorMessage } from '@/core/errors' import { roomStore } from '@/core/room' import { enqueue } from '@/features/queue' import { bookTitle } from '@/shared/lib/book-title' +import { rowAfterRemoval } from '@/shared/lib/removal-focus' import { useStore } from '@/shared/lib/use-store' import { ConfirmDialog, EmptyState, notificationStore, Skeleton } from '@/shared/ui' import { FavoritesMark, PlaylistCover } from './playlist-cover' @@ -60,6 +61,9 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null) const [savingSelected, setSavingSelected] = useState(false) const [confirmingRemoval, setConfirmingRemoval] = useState(false) + // 移出之后焦点要去的那一首(null 表示歌单名):移出的行、随选择清空而消失的选择栏和确认框都带走了焦点。每次换一个新对象,同一个目标也能再次触发。 + const [refocus, setRefocus] = useState<{ key: string | null } | null>(null) + const titleRef = useRef(null) const [edits] = useState(() => new TrackEdits(() => setAttempt(n => n + 1))) // 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。 useEffect(() => { @@ -85,6 +89,14 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void return () => window.removeEventListener('focus', refresh) }, []) const selecting = picking || selection.keys.size > 0 + // 多选时落到勾选框上,平时落到行上,和列表里的方向键一样。 + useEffect(() => { + if (!refocus) + return + const row = refocus.key === null ? null : document.querySelector(`.my-playlist-page [data-key="${CSS.escape(refocus.key)}"]`) + const target = row?.querySelector('.playlist-track-check') ?? row ?? titleRef.current + target?.focus() + }, [refocus]) // 对话框和菜单自己处理 Esc。 useEffect(() => { if (!selecting) @@ -124,6 +136,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void const previousSelection = selection setItems(rest) setSelection(retain(selection, keysOf(rest))) + setRefocus({ key: rowAfterRemoval(keysOf(current), keysOf(rest), [...gone]) }) await edits.run(() => playlistsStore.removeItems(target, tracks), () => { setItems(current) setSelection(previousSelection) @@ -227,7 +240,7 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
    -

    +

    {playlist || listed ? {playlist?.name ?? '歌单'} : } {playlist?.favorites && }

    diff --git a/web/src/features/playlists/playlist-menu.tsx b/web/src/features/playlists/playlist-menu.tsx index 19dd867..df65520 100644 --- a/web/src/features/playlists/playlist-menu.tsx +++ b/web/src/features/playlists/playlist-menu.tsx @@ -1,9 +1,8 @@ -import type { MenuAction } from './action-menu' import type { MyPlaylist } from '@/core/realtime' +import type { MenuAction } from '@/shared/ui' import { Copy, Ellipsis, FileDown, Link2Off, Pencil, RefreshCw, Share2, Trash2 } from 'lucide-react' import { useState } from 'react' -import { ConfirmDialog, Modal, notificationStore } from '@/shared/ui' -import { ActionMenu } from './action-menu' +import { ActionMenu, ConfirmDialog, Modal, notificationStore } from '@/shared/ui' import { playlistsStore } from './playlists-store' async function copyShareLink(shareId: string, copied: string): Promise { diff --git a/web/src/features/playlists/playlists.css b/web/src/features/playlists/playlists.css index d41e87d..65f7773 100644 --- a/web/src/features/playlists/playlists.css +++ b/web/src/features/playlists/playlists.css @@ -31,15 +31,8 @@ .playlist-cover-lg > .playlist-cover-badge > svg { width: 17px; height: 17px; } .playlist-favorites-mark { flex-shrink: 0; color: var(--color-brand); } -.playlist-menu { inset: auto; margin: 0; min-width: 148px; padding: 6px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 12px 32px var(--color-shadow); transform-origin: top center; } -.playlist-menu:popover-open { display: flex; flex-direction: column; gap: 2px; animation: popover-in 160ms cubic-bezier(.2, .8, .2, 1) both; } -.playlist-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; white-space: nowrap; } -.playlist-menu-item:hover, .playlist-menu-item:focus-visible { outline: none; background: var(--color-hover); } -.playlist-menu-item > svg { color: var(--color-foreground-subtle); } -.playlist-menu-danger, .playlist-menu-danger > svg { color: var(--color-destructive); } .playlist-menu-button { color: var(--color-foreground-subtle); } .playlist-menu-button:hover:not(:disabled), .playlist-menu-button[aria-expanded='true'] { color: var(--color-foreground); } -:root[data-performance='low'] .playlist-menu:popover-open { animation: fade-in 160ms ease both; } .playlists-page { container: playlists / inline-size; } .playlists-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: 'heading controls create'; align-items: end; gap: 12px; margin-bottom: 28px; } diff --git a/web/src/shared/lib/removal-focus.test.ts b/web/src/shared/lib/removal-focus.test.ts new file mode 100644 index 0000000..fec8c33 --- /dev/null +++ b/web/src/shared/lib/removal-focus.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { rowAfterRemoval } from './removal-focus' + +describe('rowAfterRemoval', () => { + it('删掉一行之后落到原来的下一行', () => { + expect(rowAfterRemoval(['a', 'b', 'c', 'd'], ['a', 'c', 'd'], ['b'])).toBe('c') + }) + + it('删掉第一行之后落到新的第一行', () => { + expect(rowAfterRemoval(['a', 'b', 'c'], ['b', 'c'], ['a'])).toBe('b') + }) + + it('删掉这一页的最后一行、下一页的行补上来时,落到补上来的那一行', () => { + expect(rowAfterRemoval(['a', 'b', 'c'], ['a', 'b', 'x'], ['c'])).toBe('x') + }) + + it('删掉的是最后一行、后面没有行了,返回 null', () => { + expect(rowAfterRemoval(['a', 'b', 'c'], ['a', 'b'], ['c'])).toBeNull() + }) + + it('这一页全删空了,返回 null', () => { + expect(rowAfterRemoval(['a', 'b'], [], ['a', 'b'])).toBeNull() + }) + + it('一次删掉不相邻的几行,落到删掉的第一行原来的下一行', () => { + expect(rowAfterRemoval(['a', 'b', 'c', 'd', 'e'], ['a', 'c', 'e'], ['b', 'd'])).toBe('c') + }) + + it('一次删掉相邻的几行,落到它们后面的那一行', () => { + expect(rowAfterRemoval(['a', 'b', 'c', 'd'], ['a', 'd'], ['b', 'c'])).toBe('d') + }) + + it('要删的行有的没删掉(比如正在播放),从真正删掉的第一行算起', () => { + expect(rowAfterRemoval(['a', 'b', 'c', 'd'], ['a', 'b', 'd'], ['b', 'c'])).toBe('d') + }) + + it('一行也没删掉,返回 null', () => { + expect(rowAfterRemoval(['a', 'b'], ['a', 'b'], ['b'])).toBeNull() + }) + + it('前面插进了新的行也不影响', () => { + expect(rowAfterRemoval(['a', 'b', 'c'], ['n', 'a', 'c'], ['b'])).toBe('c') + }) +}) diff --git a/web/src/shared/lib/removal-focus.ts b/web/src/shared/lib/removal-focus.ts new file mode 100644 index 0000000..3607c8a --- /dev/null +++ b/web/src/shared/lib/removal-focus.ts @@ -0,0 +1,17 @@ +// 删掉列表里的若干行之后焦点落到哪一行:真正删掉的第一行原来的位置上,现在排着的那一行(后面的行补上来,翻页补进来的也算)。 +// before 和 after 是删除前后这一页各行的 key。那个位置上已经没有行时返回 null。 +export function rowAfterRemoval(before: readonly string[], after: readonly string[], removed: readonly string[]): string | null { + const remaining = new Set(after) + const requested = new Set(removed) + const first = before.findIndex(key => requested.has(key) && !remaining.has(key)) + if (first === -1) + return null + let index = 0 + for (let i = first - 1; i >= 0; i--) { + if (remaining.has(before[i]!)) { + index = after.indexOf(before[i]!) + 1 + break + } + } + return after[index] ?? null +} diff --git a/web/src/shared/lib/setting-save.test.ts b/web/src/shared/lib/setting-save.test.ts new file mode 100644 index 0000000..ddaf4a3 --- /dev/null +++ b/web/src/shared/lib/setting-save.test.ts @@ -0,0 +1,47 @@ +import type { SettingSave } from './setting-save' +import { describe, expect, it } from 'vitest' +import { idleSave, settingSave, shownValue } from './setting-save' + +const saving: SettingSave = { pending: { value: true }, status: 'saving', error: null } + +describe('settingSave', () => { + it('开始保存时控件先显示新值', () => { + const state = settingSave(idleSave, { type: 'start', value: true }) + expect(state).toEqual(saving) + expect(shownValue(state, false)).toBe(true) + }) + + it('保存中不接受新的修改', () => { + expect(settingSave(saving, { type: 'start', value: false })).toBe(saving) + }) + + it('保存成功后控件跟随已保存的值', () => { + const state = settingSave(saving, { type: 'succeeded' }) + expect(state).toEqual({ pending: null, status: 'saved', error: null }) + expect(shownValue(state, true)).toBe(true) + }) + + it('保存失败时控件回到原值,保留原因', () => { + const state = settingSave(saving, { type: 'failed', error: '暂时不可用' }) + expect(state).toEqual({ pending: null, status: 'failed', error: '暂时不可用' }) + expect(shownValue(state, false)).toBe(false) + }) + + it('再次修改时清掉上次的原因', () => { + const failed = settingSave(saving, { type: 'failed', error: '暂时不可用' }) + expect(settingSave(failed, { type: 'start', value: true })).toEqual(saving) + }) + + it('“已保存”到时消失,不影响保存中和失败', () => { + const saved = settingSave(saving, { type: 'succeeded' }) + expect(settingSave(saved, { type: 'expired' })).toEqual(idleSave) + expect(settingSave(saving, { type: 'expired' })).toBe(saving) + const failed = settingSave(saving, { type: 'failed', error: '暂时不可用' }) + expect(settingSave(failed, { type: 'expired' })).toBe(failed) + }) + + it('不在保存中时忽略保存的结果', () => { + expect(settingSave(idleSave, { type: 'succeeded' })).toBe(idleSave) + expect(settingSave(idleSave, { type: 'failed', error: '暂时不可用' })).toBe(idleSave) + }) +}) diff --git a/web/src/shared/lib/setting-save.ts b/web/src/shared/lib/setting-save.ts new file mode 100644 index 0000000..a0a3799 --- /dev/null +++ b/web/src/shared/lib/setting-save.ts @@ -0,0 +1,33 @@ +// 改完即生效的控件(开关、分段选择)的保存状态。pending 是正在保存的新值,控件先显示它;保存失败时丢掉它,控件回到已保存的值。 +export interface SettingSave { + pending: { value: T } | null + status: 'idle' | 'saving' | 'saved' | 'failed' + error: string | null +} + +// expired:“已保存”的提示到时消失。 +export type SettingSaveEvent + = | { type: 'start', value: T } + | { type: 'succeeded' } + | { type: 'failed', error: string } + | { type: 'expired' } + +export const idleSave: SettingSave = { pending: null, status: 'idle', error: null } + +export function settingSave(state: SettingSave, event: SettingSaveEvent): SettingSave { + switch (event.type) { + case 'start': + // 一次只保存一个值:保存中再改,结果回来时分不清是哪一次的。 + return state.status === 'saving' ? state : { pending: { value: event.value }, status: 'saving', error: null } + case 'succeeded': + return state.status === 'saving' ? { pending: null, status: 'saved', error: null } : state + case 'failed': + return state.status === 'saving' ? { pending: null, status: 'failed', error: event.error } : state + case 'expired': + return state.status === 'saved' ? idleSave : state + } +} + +export function shownValue(state: SettingSave, saved: T): T { + return state.pending ? state.pending.value : saved +} diff --git a/web/src/shared/lib/use-setting-save.ts b/web/src/shared/lib/use-setting-save.ts new file mode 100644 index 0000000..1e85a26 --- /dev/null +++ b/web/src/shared/lib/use-setting-save.ts @@ -0,0 +1,51 @@ +import { useEffect, useReducer, useRef, useState } from 'react' +import { idleSave, settingSave, shownValue } from './setting-save' + +// “已保存”显示 1.5 秒,再用 0.4 秒淡出,和 ui.css 里 .save-status-saved 的动画一致。 +export const savedNoticeMs = 1900 + +// 改完立即保存。save 同步时写的是这台设备上的 store,抛出的是浏览器的存储错误;异步时写服务端,失败时抛出 Error,message 是给用户看的原因。 +export function useSettingSave(saved: T, save: (value: T) => void | Promise) { + const [state, dispatch] = useReducer(settingSave, idleSave) + const [savedVersion, setSavedVersion] = useState(0) + // state 要到下一次渲染才更新,连续点击时由这个 ref 阻止同时保存两次。 + const savingRef = useRef(false) + + useEffect(() => { + if (state.status !== 'saved') + return + const timer = setTimeout(dispatch, savedNoticeMs, { type: 'expired' }) + return () => clearTimeout(timer) + }, [state]) + + const change = (value: T) => { + if (savingRef.current || value === shownValue(state, saved)) + return + dispatch({ type: 'start', value }) + let result: void | Promise + try { + result = save(value) + } + catch { + dispatch({ type: 'failed', error: '没有保存,浏览器不允许在这台设备上保存设置。' }) + return + } + const succeeded = () => { + dispatch({ type: 'succeeded' }) + setSavedVersion(version => version + 1) + } + if (!(result instanceof Promise)) { + succeeded() + return + } + savingRef.current = true + result.then( + succeeded, + (error: unknown) => dispatch({ type: 'failed', error: error instanceof Error && error.message ? error.message : '没有保存,再试一次。' }), + ).finally(() => { + savingRef.current = false + }) + } + + return { value: shownValue(state, saved), state, savedVersion, change } +} diff --git a/web/src/features/playlists/action-menu.tsx b/web/src/shared/ui/action-menu.tsx similarity index 93% rename from web/src/features/playlists/action-menu.tsx rename to web/src/shared/ui/action-menu.tsx index 24cf725..07faded 100644 --- a/web/src/features/playlists/action-menu.tsx +++ b/web/src/shared/ui/action-menu.tsx @@ -106,14 +106,14 @@ export function ActionMenu({ actions, menuLabel, align = 'end', className, label > {children} -