feat: organize the admin page into sections with room search, paging and source status cards
This commit is contained in:
1 parent
c7a5c44b1c
commit
8cacfb68ff
38 files changed
+1201
-538
No files matched your search
+18
-2
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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(' · ') }
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user