feat: organize the admin page into sections with room search, paging and source status cards

This commit is contained in:
DiheChen committed 2026-11-11 00:21:31 +08:00
1 parent c7a5c44b1c
commit 8cacfb68ff
38 files changed
+1201 -538

No files matched your search

+18 -2
View File
@@ -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<Record<Page, string>> = {
'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 <AdminPage section={route.section} q={route.q} page={route.page} sort={route.sort} onEntered={onEntered} />
return (
<AdminPage
section={route.section}
q={route.q}
page={route.page}
sort={route.sort}
onNavigate={({ section, q = '', page = 1, sort = 'stored' }) => {
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() {
+99 -56
View File
@@ -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<void> {
try {
await navigator.clipboard.writeText(password)
notificationStore.success('已复制新密码')
}
catch {
// 非安全上下文没有剪贴板接口,用户也可能拒绝授权。
notificationStore.info('没能写入剪贴板,请手动复制新密码。', 8000)
}
}
function findAccount(username: string): Promise<AdminAccount | null> {
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 (
<div className="admin-notice">
<p>新密码只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。</p>
<div className="admin-row-actions">
<code className="admin-code admin-secret">{password}</code>
<button type="button" className="button-secondary" onClick={() => void copyPassword(password)}>复制</button>
</div>
</div>
)
}
export function Accounts() {
const [username, setUsername] = useState('')
const [found, setFound] = useState<AdminAccount | null>(null)
const [password, setPassword] = useState<string | null>(null)
const [lookup, setLookup] = useState<Lookup>({ 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 (
<section className="admin-section" aria-labelledby="admin-accounts-title">
<div className="section-heading">
<h2 id="admin-accounts-title" className="text-ui-lg">账号</h2>
</div>
<section className="admin-section" aria-labelledby="admin-users-title">
<SectionHead id="admin-users-title" title="用户" description="用户忘记密码时,在这里按用户名找到账号,然后重置密码。" />
<form
className="admin-list"
className="admin-toolbar"
role="search"
onSubmit={(event) => {
event.preventDefault()
void find()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-account-username" className="admin-row-title text-ui-base">查找账号</label>
<span id="admin-account-username-description" className="text-ui-sm muted">按用户名查找。用户忘记密码时,可以在这里重置。</span>
</div>
<div className="admin-row-actions">
<input id="admin-account-username" aria-describedby="admin-account-username-description" autoComplete="off" value={username} onChange={event => setUsername(event.target.value.trim().toLowerCase())} />
<button type="submit" className="button-secondary" disabled={busy || username === ''}>查找</button>
</div>
</div>
{found && (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">{found.username}</span>
<span className="text-ui-sm muted">{`昵称:${found.member.name}`}</span>
</div>
<div className="admin-row-actions">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input type="search" enterKeyHint="search" aria-label="用户名" placeholder="输入用户名,按回车查找" autoComplete="off" spellCheck={false} value={username} onChange={event => setUsername(event.target.value.trim().toLowerCase())} />
</label>
</form>
{lookup.phase === 'searching' && <Skeleton rows={1} />}
{lookup.phase === 'failed' && <LoadFailed message={lookup.message} onRetry={() => void find()} />}
{lookup.phase === 'missing' && <p className="admin-hint" role="status">没有这个用户名的账号。</p>}
{lookup.phase === 'found' && (
<>
<div className="section-group">
<div className="section-row">
<Avatar hash={lookup.account.member.avatarHash} name={lookup.account.member.name} className="admin-user-avatar text-ui-base" />
<div className="section-row-copy">
<span className="section-row-title truncate">{lookup.account.member.name}</span>
<span className="section-row-description">{`@${lookup.account.username}`}</span>
</div>
<button type="button" className="button-secondary" onClick={() => setConfirming(true)}>重置密码</button>
</div>
</div>
)}
{found && password && (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">
新密码:
<code>{password}</code>
</span>
<span className="text-ui-sm muted">只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。</span>
</div>
</div>
)}
</form>
{found && confirming && (
<ConfirmDialog title="重置密码" confirmLabel="重置" danger onConfirm={() => reset(found)} onClose={() => setConfirming(false)}>
{`将为 ${found.username} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`}
</ConfirmDialog>
{lookup.password !== null && <NewPassword password={lookup.password} />}
{confirming && (
<ConfirmDialog
title="重置密码"
confirmLabel="重置"
danger
onConfirm={async () => {
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} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`}
</ConfirmDialog>
)}
</>
)}
</section>
)
+86 -47
View File
@@ -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 (
<form
className="form-stack admin-sign-in"
onSubmit={(event) => {
event.preventDefault()
adminStore.signIn(token)
}}
>
<label>
管理 token
<input type="password" autoComplete="off" autoFocus required value={token} onChange={event => setToken(event.target.value)} />
</label>
<p className="text-ui-caption muted">管理 token 只保存在这个标签页中,关闭标签页后要重新输入。</p>
<button type="submit" className="button-primary" disabled={!token}>进入后台</button>
</form>
)
// 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<AdminSection>[] = [
{ 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 (
<div className="page-content admin-page">
<div className="admin-heading">
<div className="page-title">
<span className="text-ui-sm accent">{brandName}</span>
<h1 className="text-ui-xl">后台</h1>
<p className="text-ui-base muted">管理品牌名、注册、GitHub 登录、用户账号、常驻房间、音源账号和已存储的媒体。</p>
<div className="admin-sign-in">
<form
className="admin-sign-in-card form-stack"
aria-labelledby="admin-sign-in-title"
onSubmit={(event) => {
event.preventDefault()
adminStore.signIn(token)
}}
>
<div>
<p className="admin-eyebrow">{brandName}</p>
<h1 id="admin-sign-in-title">后台</h1>
</div>
{signedIn && (
<button type="button" className="button-ghost" onClick={() => adminStore.signOut()}>
<LogOut size={16} aria-hidden="true" />
退出后台
</button>
)}
</div>
{signedIn
? (
<>
<SiteSettings />
<GithubApp />
<Accounts />
<PersistentRooms onEntered={onEntered} />
<SourceAccounts />
<StoredMedia />
</>
)
: <SignIn />}
<label>
管理 token
<input type="password" autoComplete="off" autoFocus required value={token} onChange={event => setToken(event.target.value)} />
</label>
<p className="text-ui-caption muted">管理 token 只保存在这个标签页中,关闭标签页后要重新输入。</p>
<button type="submit" className="button-primary" disabled={!token}>进入后台</button>
</form>
</div>
)
}
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 (
<SectionLayout
title="后台"
eyebrow={brandName}
sections={sections.map(item => item.id === 'sources' ? { ...item, badge: flagged } : item)}
current={section}
defaultSection="site"
onSelect={id => onNavigate({ section: id })}
footer={(
<button type="button" className="section-nav-item" onClick={() => adminStore.signOut()}>
<LogOut size={18} strokeWidth={1.8} aria-hidden="true" />
退出后台
</button>
)}
>
{shown === 'site' && <SiteSettings />}
{shown === 'registration' && <Registration />}
{shown === 'users' && <Accounts />}
{shown === 'rooms' && <PersistentRooms q={q} page={page} onFilter={filter => onNavigate({ section: 'rooms', ...filter })} onEntered={onEntered} />}
{shown === 'sources' && <SourceAccounts credentials={credentials} />}
{shown === 'media' && <StoredMedia />}
</SectionLayout>
)
}
export function AdminPage(props: AdminPageProps) {
const signedIn = useStore(adminStore, s => s.signedIn)
return signedIn ? <Sections {...props} /> : <SignIn />
}
@@ -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<string, string>()
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([])
})
})
+29 -7
View File
@@ -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<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T | null> {
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<T>(request: () => Promise<T>, texts?: ErrorTexts): Promise<T> {
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()
+80 -22
View File
@@ -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; }
}
+65 -74
View File
@@ -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<void> {
try {
@@ -17,8 +18,8 @@ async function copyCallbackUrl(url: string): Promise<void> {
}
export function GithubApp() {
// undefined 表示正在获取,null 表示获取失败。
const [app, setApp] = useState<AdminGetGithubCredentialResponse | null>()
// undefined 表示正在获取,Error 表示获取失败。
const [app, setApp] = useState<AdminGetGithubCredentialResponse | Error>()
const [attempt, setAttempt] = useState(0)
// null 表示沿用已保存的 Client ID。
const [clientId, setClientId] = useState<string | null>(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 <LoadFailed message={`暂时无法获取 GitHub 登录的配置。${app.message}`} onRetry={refetch} />
if (app === undefined)
return <Skeleton rows={3} />
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 (
<section className="admin-section" aria-labelledby="admin-github-title">
<div className="section-heading">
<h2 id="admin-github-title" className="text-ui-lg">GitHub 登录</h2>
</div>
{app === null
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取 GitHub 登录的配置。</p>
<button type="button" className="button-secondary" onClick={refetch}>重试</button>
</div>
)
: app === undefined
? <Skeleton rows={3} />
: (
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">{app.clientId === null ? '未开启' : '已开启'}</span>
<span className="text-ui-sm muted">
{app.clientId === null
? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。'
: '用户可以用 GitHub 登录、注册和绑定账号。'}
</span>
</div>
{app.clientId !== null && (
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => setClosing(true)}>关闭 GitHub 登录</button>
</div>
)}
</div>
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">回调地址</span>
<code className="admin-github-callback text-ui-sm">{app.callbackUrl}</code>
<span className="text-ui-sm muted">OAuth App 的 Authorization callback URL 填这个地址。</span>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => void copyCallbackUrl(app.callbackUrl)}>复制</button>
</div>
</div>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-github-client-id" className="admin-row-title text-ui-base">Client ID</label>
</div>
<div className="admin-row-actions">
<input id="admin-github-client-id" className="admin-github-field" autoComplete="off" spellCheck={false} value={clientId ?? app.clientId ?? ''} onChange={event => setClientId(event.target.value)} />
</div>
</div>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-github-client-secret" className="admin-row-title text-ui-base">Client Secret</label>
<span id="admin-github-client-secret-description" className="text-ui-sm muted">
{app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'}
</span>
</div>
<div className="admin-row-actions">
<input id="admin-github-client-secret" className="admin-github-field" type="password" autoComplete="off" spellCheck={false} aria-describedby="admin-github-client-secret-description" placeholder={app.clientSecretSet ? '已设置' : undefined} value={clientSecret} onChange={event => setClientSecret(event.target.value)} />
<button type="submit" className="button-secondary" disabled={busy || draft === null}>{busy ? '正在保存…' : app.clientId === null ? '开启' : '保存'}</button>
</div>
</div>
</form>
)}
<>
<form
className="section-group"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">状态</span>
<span className="section-row-description">
{app.clientId === null
? '在 GitHub 的 Settings → Developer settings → OAuth Apps 新建一个 OAuth App,填好下面的 Client ID 和 Client Secret 就会开启。'
: '用户可以用 GitHub 登录、注册和绑定账号。'}
</span>
</div>
<StatusPill tone={app.clientId === null ? 'neutral' : 'success'}>{app.clientId === null ? '未开启' : '已开启'}</StatusPill>
</div>
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">回调地址</span>
<span className="section-row-description">OAuth App 的 Authorization callback URL 填这个地址。</span>
</div>
<div className="admin-row-actions">
<code className="admin-code">{app.callbackUrl}</code>
<button type="button" className="button-secondary" onClick={() => void copyCallbackUrl(app.callbackUrl)}>复制</button>
</div>
</div>
<div className="section-row">
<div className="section-row-copy">
<label htmlFor="admin-github-client-id" className="section-row-title">Client ID</label>
</div>
<input id="admin-github-client-id" className="admin-input" autoComplete="off" spellCheck={false} value={clientId ?? app.clientId ?? ''} onChange={event => setClientId(event.target.value)} />
</div>
<div className="section-row">
<div className="section-row-copy">
<label htmlFor="admin-github-client-secret" className="section-row-title">Client Secret</label>
<span id="admin-github-client-secret-description" className="section-row-description">
{app.clientSecretSet ? '已设置。已保存的不会显示,保存时要和 Client ID 一起重新填写。' : '保存后不会再显示。'}
</span>
</div>
<input id="admin-github-client-secret" className="admin-input" type="password" autoComplete="off" spellCheck={false} aria-describedby="admin-github-client-secret-description" placeholder={app.clientSecretSet ? '已设置' : undefined} value={clientSecret} onChange={event => setClientSecret(event.target.value)} />
</div>
<div className="section-row admin-form-actions">
{app.clientId !== null && <button type="button" className="button-secondary admin-danger" onClick={() => setClosing(true)}>关闭 GitHub 登录</button>}
<button type="submit" className="button-primary" disabled={busy || draft === null}>{busy ? '正在保存…' : app.clientId === null ? '开启' : '保存'}</button>
</div>
</form>
{/* 关闭之后重新读取配置,整个表单换成骨架屏:焦点落到分类标题。 */}
{closing && (
<ConfirmDialog
title="关闭 GitHub 登录"
@@ -140,10 +130,11 @@ export function GithubApp() {
notificationStore.success('已关闭 GitHub 登录')
}}
onClose={() => setClosing(false)}
returnFocus={() => document.getElementById('admin-registration-title')}
>
关闭后,不能再用 GitHub 登录、注册或绑定,已经绑定的 GitHub 保持绑定。只绑定了 GitHub 的账号,关闭之后就无法用 GitHub 登录,重新开启之前也无法在其他设备上登录。
</ConfirmDialog>
)}
</section>
</>
)
}
+126 -69
View File
@@ -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<RoomListing[] | null>(null)
const [failed, setFailed] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
const [reload, setReload] = useState(0)
const [editing, setEditing] = useState<RoomListing | 'new' | null>(null)
const [deleting, setDeleting] = useState<RoomListing | null>(null)
const [keyOf, setKeyOf] = useState<RoomListing | null>(null)
// 删掉的房间之后焦点落到哪一行:null 表示落到标题。
const nextFocusRef = useRef<string | null>(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 (
<section className="admin-section" aria-labelledby="admin-rooms-title">
<div className="section-heading">
<h2 id="admin-rooms-title" className="text-ui-lg">常驻房间</h2>
<button type="button" className="button-secondary" onClick={() => setEditing('new')}>
<SectionHead id="admin-rooms-title" title="常驻房间" description="常驻房间不会因为没人在线而关闭。">
<label className="admin-search">
<Search size={16} aria-hidden="true" />
<input type="search" aria-label="搜索房间" placeholder="搜索房间" value={q} onChange={event => onFilter({ q: event.target.value })} />
</label>
<button type="button" className="button-primary" onClick={() => setEditing('new')}>
<Plus size={16} aria-hidden="true" />
新建常驻房间
新建房间
</button>
</div>
{failed
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取常驻房间。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: !rooms
</SectionHead>
{failure !== null
? <LoadFailed message={`暂时无法获取常驻房间。${failure}`} onRetry={retry} />
: !shown
? <Skeleton rows={3} />
: !rooms.length
? <p className="admin-empty text-ui-caption muted">还没有常驻房间。</p>
: !shown.items.length
? q.trim()
? (
<EmptyState icon={SearchX} title="没有找到房间" action={<button type="button" className="button-secondary" onClick={() => onFilter({ q: '' })}>清除搜索</button>}>
换个名称或别名再找找。
</EmptyState>
)
: (
<EmptyState icon={DoorOpen} title="还没有常驻房间" action={<button type="button" className="button-secondary" onClick={() => setEditing('new')}>新建房间</button>} />
)
: (
<ul className="admin-list">
{rooms.map(room => (
<li className="admin-row" key={room.id}>
<div className="admin-row-copy">
<strong className="text-ui-base truncate">{room.name}</strong>
<span className="admin-row-meta text-ui-sm muted">
<span className="truncate">{room.slug ? `/r/${room.slug}` : '没有别名'}</span>
{room.hasPassword && (
<>
<span aria-hidden="true">·</span>
<LockKeyhole size={12} aria-hidden="true" />
有密码
</>
)}
<span aria-hidden="true">·</span>
{`${room.onlineCount} 人在线`}
</span>
</div>
<div className="admin-row-actions">
<IconButton
icon={LogIn}
label={`打开「${room.name}」`}
onClick={() => {
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)
}}
/>
<IconButton icon={Share2} label={`复制「${room.name}」的邀请链接`} onClick={() => void copyInviteLink(room)} />
<IconButton icon={KeyRound} label={`「${room.name}」的 API Key`} onClick={() => setKeyOf(room)} />
<IconButton icon={Pencil} label={`编辑「${room.name}」`} onClick={() => setEditing(room)} />
<IconButton icon={Trash2} label={`删除「${room.name}」`} onClick={() => setDeleting(room)} />
</div>
</li>
))}
</ul>
<>
<div className="section-group admin-table">
<div className="admin-table-head" aria-hidden="true">
<span>房间</span>
<span>别名</span>
<span>访问</span>
</div>
<ul>
{shown.items.map(room => (
<li className="admin-room" key={room.id} data-key={room.id}>
<div className="admin-room-name">
<strong className="truncate">{room.name}</strong>
<span>{room.onlineCount ? `${room.onlineCount} 人在线` : '无人在线'}</span>
</div>
<span className="admin-room-slug truncate">
{room.slug
? `/r/${room.slug}`
: (
<>
<span aria-hidden="true">—</span>
<span className="sr-only">没有别名</span>
</>
)}
</span>
<span className="admin-room-access">
{room.hasPassword
? (
<StatusPill tone="neutral">
<LockKeyhole size={12} aria-hidden="true" />
<span className="sr-only">需要</span>
密码
</StatusPill>
)
: <StatusPill tone="neutral">公开</StatusPill>}
</span>
<ActionMenu
actions={[
{ icon: LogIn, label: '进入房间', run: () => 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="更多操作"
>
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
</ActionMenu>
</li>
))}
</ul>
</div>
<Pagination page={shown.page} totalPages={shown.totalPages} onPage={next => onFilter({ q, page: next })} />
</>
)}
{editing && (
<RoomFormDialog
@@ -114,12 +164,19 @@ export function PersistentRooms({ onEntered }: { onEntered: (ref: string) => 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}」后,房间里的所有人都会断开连接。`}
</ConfirmDialog>
+78
View File
@@ -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 (
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<label htmlFor={switchId} className="section-row-title">开放注册</label>
<span id={descriptionId} className="section-row-description">关闭后不能注册新账号,已有账号照常登录,游客照常使用。</span>
{error && <span id={errorId} className="admin-row-error" role="alert">{error}</span>}
</div>
<div className="admin-row-actions">
<SaveStatus status={setting.state.status} version={setting.savedVersion} />
<Switch id={switchId} checked={setting.value} aria-describedby={error ? `${descriptionId} ${errorId}` : descriptionId} onChange={setting.change} />
</div>
</div>
</div>
)
}
function OpenRegistration() {
// undefined 表示正在获取,Error 表示获取失败。
const [open, setOpen] = useState<boolean | Error>()
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 (
<LoadFailed
message={`暂时无法获取注册状态。${open.message}`}
onRetry={() => {
setOpen(undefined)
setAttempt(n => n + 1)
}}
/>
)
}
if (open === undefined)
return <Skeleton rows={1} />
return <OpenRegistrationRow open={open} onSaved={setOpen} />
}
export function Registration() {
return (
<section className="admin-section" aria-labelledby="admin-registration-title">
<SectionHead id="admin-registration-title" title="登录与注册" />
<OpenRegistration />
<h3 className="section-group-label">GitHub 登录</h3>
<GithubApp />
</section>
)
}
+6
View File
@@ -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<HTMLElement>(`li[data-key="${CSS.escape(key)}"] :is(input, button)`)
return row ?? heading
}
@@ -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 })
})
})
+14
View File
@@ -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 }
}
+31
View File
@@ -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 (
<header className="admin-head">
<div className="admin-head-copy">
<h2 id={id} tabIndex={-1}>{title}</h2>
{description && <p className="admin-head-description">{description}</p>}
</div>
{children && <div className="admin-head-actions">{children}</div>}
</header>
)
}
export function LoadFailed({ message, onRetry }: { message: string, onRetry: () => void }) {
return (
<div className="section-group admin-failed" role="alert">
<CloudOff size={20} strokeWidth={1.7} aria-hidden="true" />
<p>{message}</p>
<button type="button" className="button-secondary" onClick={onRetry}>重试</button>
</div>
)
}
export type StatusTone = 'success' | 'danger' | 'neutral'
export function StatusPill({ tone, children }: { tone: StatusTone, children: ReactNode }) {
return <span className="admin-status" data-tone={tone}>{children}</span>
}
+26 -118
View File
@@ -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<void> {
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<string | null>(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 (
<section className="admin-section" aria-labelledby="admin-site-title">
<div className="section-heading">
<h2 id="admin-site-title" className="text-ui-lg">站点</h2>
</div>
{status === 'failed'
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取品牌名。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
: status === 'loading'
? <Skeleton rows={1} />
<SectionHead id="admin-site-title" title="站点" />
{status === undefined
? <Skeleton rows={1} />
: status instanceof Error
? <LoadFailed message={`暂时无法获取品牌名。${status.message}`} onRetry={retry} />
: (
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-brand-name" className="admin-row-title text-ui-base">品牌名</label>
<span id="admin-brand-name-description" className="text-ui-sm muted">显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。</span>
</div>
<div className="admin-row-actions">
<input id="admin-brand-name" className="admin-brand-name" aria-describedby="admin-brand-name-description" autoComplete="off" value={name} onChange={event => setDraft(event.target.value)} />
<button type="submit" className="button-secondary" disabled={busy || name.trim() === ''}>{busy ? '正在保存…' : '保存'}</button>
<div className="section-group">
<div className="section-row">
<div className="section-row-copy">
<span className="section-row-title">品牌名</span>
<span className="section-row-description">显示在发现房间、后台页面的顶部和浏览器标签页上,最多 20 个字。已经打开的页面刷新后才会更新。</span>
</div>
<InlineTextField label="品牌名" value={brandName} onSave={saveBrandName} />
</div>
<RegistrationSetting />
</form>
</div>
)}
</section>
)
}
function RegistrationSetting() {
const [open, setOpen] = useState<boolean | null>(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 (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">注册</span>
<span className="text-ui-sm muted">{description}</span>
</div>
<div className="admin-row-actions">
{failed
? (
<button
type="button"
className="button-secondary"
onClick={() => {
setFailed(false)
setAttempt(n => n + 1)
}}
>
重试
</button>
)
: <button type="button" className="button-secondary" disabled={open === null || busy} onClick={() => void toggle()}>{open === false ? '开放注册' : '关闭注册'}</button>}
</div>
</div>
)
}
@@ -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<typeof sourceAccountView> }[] = [
{ 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<typeof sourceAccountView>, 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)
})
}
})
+18 -16
View File
@@ -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(' · ') }
}
+24 -68
View File
@@ -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<NonNullable<CredentialState>>
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<CredentialState>()
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 (
<li className="admin-row">
<div className="admin-row-copy">
<strong className="text-ui-base">{source.name}</strong>
<span className="text-ui-sm muted" role="status">{view.status}</span>
{view.details.map(detail => <span key={detail} className="text-ui-sm muted">{detail}</span>)}
<article className="admin-card" aria-labelledby={titleId}>
<div className="admin-card-top">
<h3 id={titleId}>{source.name}</h3>
<StatusPill tone={view.tone}>{view.status}</StatusPill>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setSigningIn(true)}>{credential?.set ? '重新登录' : '扫码登录'}</button>
{view.detail && <p className="admin-card-detail">{view.detail}</p>}
<div className="admin-card-actions">
{state instanceof Error
? <button type="button" className="button-secondary" onClick={reload}>重试</button>
: <button type="button" className={state?.set ? 'button-secondary' : 'button-primary'} disabled={state === undefined} onClick={() => setSigningIn(true)}>{state?.set ? '重新登录' : '扫码登录'}</button>}
</div>
{signingIn && (
<QrLoginDialog
@@ -71,26 +30,23 @@ function SourceAccount({ source }: { source: Source }) {
onSuccess={() => {
setSigningIn(false)
notificationStore.success(`已登录${afterChinese(source.name)}`)
setCredential(undefined)
setAttempt(n => n + 1)
reload()
}}
onClose={() => setSigningIn(false)}
/>
)}
</li>
</article>
)
}
export function SourceAccounts() {
export function SourceAccounts({ credentials }: { credentials: Record<SourceId, Credential> }) {
return (
<section className="admin-section" aria-labelledby="admin-accounts-title">
<div className="section-heading">
<h2 id="admin-accounts-title" className="text-ui-lg">音源账号</h2>
</div>
<ul className="admin-list">
{sources.map(source => <SourceAccount key={source.name} source={source} />)}
<section className="admin-section" aria-labelledby="admin-sources-title">
<SectionHead id="admin-sources-title" title="音源" description="扫码登录各平台的账号,用来搜索和播放它们的歌曲。" />
<div className="admin-cards">
{(Object.keys(sources) as SourceId[]).map(id => <SourceCard key={id} source={sources[id]} credential={credentials[id]} />)}
<YouTubeCookies />
</ul>
</div>
</section>
)
}
@@ -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<Exclude<CredentialState, Error | undefined>>
targets: QrLoginTarget[]
}
export type SourceId = 'netease' | 'qq' | 'bilibili'
export const sources: Record<SourceId, Source> = {
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<CredentialState>()
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<SourceId, Credential> {
return {
netease: useCredential(sources.netease),
qq: useCredential(sources.qq),
bilibili: useCredential(sources.bilibili),
}
}
+4 -9
View File
@@ -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 (
<section className="admin-section" aria-labelledby="admin-media-title">
<div className="section-heading">
<h2 id="admin-media-title" className="text-ui-lg">已存储的媒体</h2>
<SectionHead id="admin-media-title" title="媒体" description="服务器上缓存的音频。删除后,下次有人点这首歌时会重新下载。">
{media && <span className="text-ui-sm muted">{media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}</span>}
</div>
</SectionHead>
{failed
? (
<div className="admin-empty admin-failed">
<p className="text-ui-caption muted">暂时无法获取已存储的媒体。</p>
<button type="button" className="button-secondary" onClick={retry}>重试</button>
</div>
)
? <LoadFailed message="暂时无法获取已存储的媒体。" onRetry={retry} />
: !media
? <Skeleton rows={3} />
: !media.items.length
@@ -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<Location, 'protocol' | '
export function youtubeCookiesView(state: YoutubeCredentialState): { status: string, updatedAtMs: number | null } {
if (state === undefined)
return { status: '正在查询…', updatedAtMs: null }
if (state === null)
return { status: '正在查询', updatedAtMs: null }
if (state instanceof Error)
return { status: '查询失败', updatedAtMs: null }
if (!state.set)
return { status: '未配置', updatedAtMs: null }
@@ -62,9 +62,9 @@ describe('youtubeCookiesText', () => {
})
describe('youtubeCookiesView', () => {
const cases: { name: string, state: AdminGetYoutubeCredentialResponse | null | undefined, want: ReturnType<typeof youtubeCookiesView> }[] = [
{ name: '正在查询', state: undefined, want: { status: '正在查询…', updatedAtMs: null } },
{ name: '查询失败', state: null, want: { status: '查询失败', updatedAtMs: null } },
const cases: { name: string, state: AdminGetYoutubeCredentialResponse | Error | undefined, want: ReturnType<typeof youtubeCookiesView> }[] = [
{ 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 } },
]
+25 -11
View File
@@ -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<YoutubeCredentialState>()
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 (
<li className="admin-row">
<div className="admin-row-copy">
<strong className="text-ui-base">YouTube</strong>
<span className="text-ui-sm muted" role="status">{view.status}</span>
{view.updatedAtMs !== null && <span className="text-ui-sm muted">{`最近填写于 ${formatDateTime(view.updatedAtMs)}`}</span>}
{shouldWarnAboutPlaintext(window.location) && <span className="error-text text-ui-sm" role="alert">{youtubeCookiesText.plaintextWarning}</span>}
<article className="admin-card" aria-labelledby={titleId}>
<div className="admin-card-top">
<h3 id={titleId}>YouTube</h3>
<StatusPill tone="neutral">{view.status}</StatusPill>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setEditing(true)}>{credential?.set ? '覆盖 cookies' : '填写 cookies'}</button>
{credential?.set && <button type="button" className="button-secondary" onClick={() => setClearing(true)}>清除</button>}
{credential instanceof Error && <p className="admin-card-detail">{credential.message}</p>}
{view.updatedAtMs !== null && <p className="admin-card-detail">{`${formatDateTime(view.updatedAtMs)} 更新`}</p>}
{shouldWarnAboutPlaintext(window.location) && <p className="admin-card-detail error-text" role="alert">{youtubeCookiesText.plaintextWarning}</p>}
<div className="admin-card-actions">
{credential instanceof Error
? <button type="button" className="button-secondary" onClick={refetch}>重试</button>
: (
<>
<button type="button" className="button-secondary" disabled={credential === undefined} onClick={() => setEditing(true)}>{credential?.set ? '更新' : '填写 cookies'}</button>
{credential?.set && <button type="button" className="button-secondary" onClick={() => setClearing(true)}>清除</button>}
</>
)}
</div>
{editing && (
<YouTubeCookiesDialog
@@ -106,6 +118,7 @@ export function YouTubeCookies() {
onClose={() => setEditing(false)}
/>
)}
{/* 清除之后“清除”按钮不再显示,重新读取期间“填写 cookies”也不能点:焦点落到分类标题。 */}
{clearing && (
<ConfirmDialog
title="清除 YouTube cookies"
@@ -119,10 +132,11 @@ export function YouTubeCookies() {
notificationStore.success('已清除 YouTube cookies')
}}
onClose={() => setClearing(false)}
returnFocus={() => document.getElementById('admin-sources-title')}
>
清除后,下载 YouTube 链接时不再携带 cookies。
</ConfirmDialog>
)}
</li>
</article>
)
}
+4 -2
View File
@@ -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<HTMLHeadingElement>(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 && (
<>
<div className="page-title">
<h1 className="text-ui-xl">播放历史</h1>
<h1 ref={titleRef} className="text-ui-xl" tabIndex={-1}>播放历史</h1>
<p className="text-ui-base muted">重温那些一起听过的旋律。</p>
</div>
<div className="history-toolbar">
@@ -149,6 +150,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
setConfirming(false)
}}
onClose={() => setConfirming(false)}
returnFocus={() => titleRef.current}
>
将清空你的全部播放记录,登录同一个账号的其他设备上也会一起清空。
</ConfirmDialog>
+1 -2
View File
@@ -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'
@@ -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<HTMLHeadingElement>(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<HTMLElement>(`.my-playlist-page [data-key="${CSS.escape(refocus.key)}"]`)
const target = row?.querySelector<HTMLElement>('.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
<header className="my-playlist-header">
<PlaylistCover cover={playlist?.coverUrl ?? null} favorites={playlist?.favorites ?? false} size="lg" />
<div className="my-playlist-info">
<h1 id="my-playlist-title" className="my-playlist-name">
<h1 ref={titleRef} id="my-playlist-title" className="my-playlist-name" tabIndex={-1}>
{playlist || listed ? <span>{playlist?.name ?? '歌单'}</span> : <span className="my-playlist-name-pending" role="img" aria-label="正在加载" />}
{playlist?.favorites && <FavoritesMark size={22} />}
</h1>
+2 -3
View File
@@ -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<void> {
-7
View File
@@ -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; }
+44
View File
@@ -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')
})
})
+17
View File
@@ -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
}
+47
View File
@@ -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<boolean> = { pending: { value: true }, status: 'saving', error: null }
describe('settingSave', () => {
it('开始保存时控件先显示新值', () => {
const state = settingSave<boolean>(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<boolean>(idleSave, { type: 'succeeded' })).toBe(idleSave)
expect(settingSave<boolean>(idleSave, { type: 'failed', error: '暂时不可用' })).toBe(idleSave)
})
})
+33
View File
@@ -0,0 +1,33 @@
// 改完即生效的控件(开关、分段选择)的保存状态。pending 是正在保存的新值,控件先显示它;保存失败时丢掉它,控件回到已保存的值。
export interface SettingSave<T> {
pending: { value: T } | null
status: 'idle' | 'saving' | 'saved' | 'failed'
error: string | null
}
// expired:“已保存”的提示到时消失。
export type SettingSaveEvent<T>
= | { type: 'start', value: T }
| { type: 'succeeded' }
| { type: 'failed', error: string }
| { type: 'expired' }
export const idleSave: SettingSave<never> = { pending: null, status: 'idle', error: null }
export function settingSave<T>(state: SettingSave<T>, event: SettingSaveEvent<T>): SettingSave<T> {
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<T>(state: SettingSave<T>, saved: T): T {
return state.pending ? state.pending.value : saved
}
+51
View File
@@ -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<T>(saved: T, save: (value: T) => void | Promise<void>) {
const [state, dispatch] = useReducer(settingSave<T>, 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<void>
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 }
}
@@ -106,14 +106,14 @@ export function ActionMenu({ actions, menuLabel, align = 'end', className, label
>
{children}
</button>
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={menuLabel} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
<div ref={menuRef} id={menuId} className="action-menu" popover="auto" role="menu" aria-label={menuLabel} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
{actions.map(({ icon: Icon, label, danger, run }) => (
<button
key={label}
type="button"
role="menuitem"
tabIndex={-1}
className={danger ? 'playlist-menu-item playlist-menu-danger' : 'playlist-menu-item'}
className={danger ? 'action-menu-item action-menu-danger' : 'action-menu-item'}
onClick={() => {
menuRef.current!.hidePopover()
run()
+3 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Modal } from './modal'
export function ConfirmDialog({ title, children, confirmLabel, danger = false, closeOnConfirm = false, onConfirm, onClose }: {
export function ConfirmDialog({ title, children, confirmLabel, danger = false, closeOnConfirm = false, onConfirm, onClose, returnFocus }: {
title: string
children: string
confirmLabel: string
@@ -10,10 +10,11 @@ export function ConfirmDialog({ title, children, confirmLabel, danger = false, c
closeOnConfirm?: boolean
onConfirm: () => Promise<void>
onClose: () => void
returnFocus?: () => HTMLElement | null
}) {
const [busy, setBusy] = useState(false)
return (
<Modal title={title} onClose={onClose}>
<Modal title={title} onClose={onClose} returnFocus={returnFocus}>
<p className="text-ui-base muted">{children}</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
+3
View File
@@ -2,6 +2,8 @@ import './select.css'
import './ui.css'
import './section-layout.css'
export { ActionMenu } from './action-menu'
export type { MenuAction } from './action-menu'
export { Artwork } from './artwork'
export { Avatar } from './avatar'
export { ConfirmDialog } from './confirm-dialog'
@@ -14,6 +16,7 @@ export { notificationStore } from './notification-store'
export { Notifications } from './notifications'
export { Pagination } from './pagination'
export { PlayingBars } from './playing-bars'
export { SaveStatus } from './save-status'
export { SectionLayout } from './section-layout'
export type { SectionItem } from './section-layout'
export { SegmentedControl } from './segmented-control'
+10 -1
View File
@@ -5,10 +5,17 @@ import { createPortal } from 'react-dom'
import { registerModalHistory } from '@/shared/lib/session-history'
import { IconButton } from './icon-button'
export function Modal({ title, children, onClose }: { title: string, children: ReactNode, onClose: () => void }) {
export function Modal({ title, children, onClose, returnFocus }: {
title: string
children: ReactNode
onClose: () => void
// 关闭时焦点回到打开前的元素;它已经不能获得焦点(比如触发对话框的那一行被删掉了、按钮被禁用了)时,落到 returnFocus 返回的元素上。
returnFocus?: () => HTMLElement | null
}) {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
const close = useEffectEvent(onClose)
const fallbackFocus = useEffectEvent(() => returnFocus?.() ?? null)
useEffect(() => {
const dialog = dialogRef.current!
const previousFocus = document.activeElement as HTMLElement | null
@@ -20,6 +27,8 @@ export function Modal({ title, children, onClose }: { title: string, children: R
unregister()
dialog.close()
previousFocus?.focus()
if (document.activeElement !== previousFocus)
fallbackFocus()?.focus()
}
}, [])
return createPortal(
+17
View File
@@ -0,0 +1,17 @@
import type { SettingSave } from '@/shared/lib/setting-save'
import { Check } from 'lucide-react'
// 外层一直在文档里,读屏软件才能读出它的变化。version 每次保存成功都变,连续保存时“已保存”重新计时淡出。
export function SaveStatus({ status, version = 0, savingText = '保存中…' }: { status: SettingSave<unknown>['status'], version?: number, savingText?: string }) {
return (
<span className="save-status text-ui-caption" role="status">
{status === 'saving' && <span className="save-status-saving">{savingText}</span>}
{status === 'saved' && (
<span key={version} className="save-status-saved">
<Check size={14} strokeWidth={2.2} aria-hidden="true" />
已保存
</span>
)}
</span>
)
}
+15
View File
@@ -73,6 +73,12 @@
.song-badge { flex-shrink: 0; padding: 1px 8px; border-radius: 999px; background: var(--color-surface); color: var(--color-foreground-subtle); }
.song-album { max-width: 22%; width: 150px; }
.song-duration { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.action-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; }
.action-menu:popover-open { display: flex; flex-direction: column; gap: 2px; animation: popover-in 160ms cubic-bezier(.2, .8, .2, 1) both; }
.action-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; }
.action-menu-item:hover, .action-menu-item:focus-visible { outline: none; background: var(--color-hover); }
.action-menu-item > svg { color: var(--color-foreground-subtle); }
.action-menu-danger, .action-menu-danger > svg { color: var(--color-destructive); }
.modal { width: min(480px, calc(100vw - 32px)); max-width: none; max-height: calc(100dvh - 48px); margin: auto; padding: 0; overflow: auto; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 24px 64px var(--color-shadow); font-size: var(--text-ui-base); }
.modal::backdrop { background: var(--color-overlay); }
.modal-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--color-border); background: var(--color-popover); }
@@ -115,6 +121,14 @@
.pagination-step[aria-disabled='true'] { cursor: default; opacity: .45; }
.pagination-step[aria-disabled='true']:active { transform: none; }
.pagination-gap { min-width: 20px; color: var(--color-foreground-subtlest); text-align: center; user-select: none; }
.save-status { display: inline-flex; white-space: nowrap; }
.save-status-saving { color: var(--color-foreground-subtle); }
/* 显示 1.5 秒后用 0.4 秒淡出;减少动态效果时不淡出,到时直接消失。 */
.save-status-saved { display: inline-flex; align-items: center; gap: 4px; color: var(--color-success); animation: save-status-saved 1900ms ease forwards; }
@keyframes save-status-saved {
0%, 79% { opacity: 1; }
100% { opacity: 0; }
}
.inline-text-field { display: flex; flex-direction: column; gap: 6px; flex: 1 1 220px; min-width: 0; max-width: 320px; }
.inline-text-field-box { position: relative; }
.inline-text-field input { padding: 8px 12px; border-radius: 10px; transition: border-color 150ms, background-color 150ms; }
@@ -132,6 +146,7 @@
to { transform: translateY(0); }
}
:root[data-performance='low'] .modal { animation-duration: 120ms; }
:root[data-performance='low'] .action-menu:popover-open { animation: fade-in 160ms ease both; }
@media (max-width: 1100px) {
.page-content { padding: 24px; }
.song-album { display: none; }