From d72b4203cbac1f4b0700e933fdfdb23ca6d76a59 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:23:50 +0800 Subject: [PATCH] feat: browse stored media page by page, sort and search it, and delete several songs at once --- web/src/features/admin/admin-page.tsx | 6 +- web/src/features/admin/admin.css | 48 +++- web/src/features/admin/media-store.test.ts | 300 +++++++++++++++++++++ web/src/features/admin/media-store.ts | 103 +++++++ web/src/features/admin/media-table.tsx | 107 ++++++++ web/src/features/admin/media-view.test.ts | 109 ++++++++ web/src/features/admin/media-view.ts | 80 ++++++ web/src/features/admin/stored-media.tsx | 207 +++++++++----- 8 files changed, 879 insertions(+), 81 deletions(-) create mode 100644 web/src/features/admin/media-store.test.ts create mode 100644 web/src/features/admin/media-store.ts create mode 100644 web/src/features/admin/media-table.tsx create mode 100644 web/src/features/admin/media-view.test.ts create mode 100644 web/src/features/admin/media-view.ts diff --git a/web/src/features/admin/admin-page.tsx b/web/src/features/admin/admin-page.tsx index 10f308e..6218206 100644 --- a/web/src/features/admin/admin-page.tsx +++ b/web/src/features/admin/admin-page.tsx @@ -13,7 +13,7 @@ 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 { MediaPage } from './stored-media' import './admin.css' export type AdminSection = 'site' | 'registration' | 'users' | 'rooms' | 'sources' | 'media' @@ -72,7 +72,7 @@ function SignIn() { ) } -function Sections({ section, q, page, onNavigate, onEntered }: AdminPageProps) { +function Sections({ section, q, page, sort, onNavigate, onEntered }: AdminPageProps) { const brandName = useStore(brandStore, s => s.name) const credentials = useSourceCredentials() const flagged = Object.values(credentials).some(credential => needsSignIn(credential.state)) @@ -97,7 +97,7 @@ function Sections({ section, q, page, onNavigate, onEntered }: AdminPageProps) { {shown === 'users' && } {shown === 'rooms' && onNavigate({ section: 'rooms', ...filter })} onEntered={onEntered} />} {shown === 'sources' && } - {shown === 'media' && } + {shown === 'media' && onNavigate({ section: 'media', ...view })} />} ) } diff --git a/web/src/features/admin/admin.css b/web/src/features/admin/admin.css index 73e835a..4c0e217 100644 --- a/web/src/features/admin/admin.css +++ b/web/src/features/admin/admin.css @@ -68,17 +68,36 @@ .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-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 12px; } +.media-toolbar[data-hidden] { visibility: hidden; } +.media-toolbar > .admin-search { width: 280px; } +.media-sort { flex-shrink: 0; } +.media-sort > .select-trigger { min-height: 36px; padding: 6px 12px; border-color: transparent; border-radius: 10px; background: var(--color-card-hover); font-size: var(--text-ui-caption); } +.media-total { margin-left: auto; color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; } +/* 操作条不占位置,盖在筛选上面:勾选之后表格不会移动,Shift 连选时不会点错行。滚动时停在顶部,删除和取消一直够得着。 */ +.media-selection-anchor { position: sticky; top: 0; z-index: 3; height: 0; } +.media-selection { position: absolute; top: 0; left: 50%; display: flex; align-items: center; gap: 6px; width: max-content; max-width: 100%; padding: 2px 2px 2px 16px; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-toast); box-shadow: 0 10px 30px var(--color-shadow); transform: translateX(-50%); animation: fade-in 120ms ease both; } +.media-selection-count { margin-right: 8px; font-size: var(--text-ui-caption); font-variant-numeric: tabular-nums; white-space: nowrap; } +.media-selection > button { min-height: 30px; padding: 4px 14px; border-radius: 999px; font-size: var(--text-ui-caption); } -@media (max-width: 767px) { - .admin-list { padding: 0 16px; } -} +/* 选中的行有底色,圆角要裁掉它。菜单在顶层显示,不会被裁。 */ +.media-table { overflow: clip; transition: opacity 150ms; } +.media-table[aria-busy='true'] { opacity: .6; } +.media-table > ul { margin: 0; padding: 0; list-style: none; } +.media-head, .media-row { display: grid; grid-template-columns: 16px minmax(0, 2.6fr) minmax(0, .8fr) minmax(0, 1fr) minmax(0, 1fr) 36px; align-items: center; gap: 12px; padding: 8px 8px 8px 16px; } +.media-head { padding-block: 10px 8px; color: var(--color-foreground-subtlest); font-size: var(--text-ui-sm); } +.media-row { min-height: 60px; border-top: 1px solid var(--color-card-hover); } +.media-row[data-selected] { background: var(--color-accent); } +/* 整格都能点:复选框本身只有 16px。 */ +.media-check { display: flex; align-items: center; align-self: stretch; margin: -8px -12px -8px -16px; padding: 0 12px 0 16px; cursor: pointer; } +.media-song { display: flex; align-items: center; gap: 10px; min-width: 0; } +.media-song > .artwork { width: 36px; border-radius: 6px; } +.media-song > .artwork > svg { width: 18px; } +.media-song-copy { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.media-song-copy > strong { font-weight: 500; } +.media-song-copy > span, .media-cell { color: var(--color-foreground-subtle); font-size: var(--text-ui-caption); } +.media-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.media-narrow { display: none; } @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; } @@ -91,4 +110,13 @@ .admin-room-slug { grid-area: slug; } .admin-room-access { grid-area: access; } .admin-room > .admin-menu-button { grid-area: menu; } + .media-toolbar > .admin-search { flex: 1 1 100%; width: auto; } + /* 搜索框独占一行,操作条只盖住这一行:排序和总数照常显示,操作条下面不会空出一行。 */ + .media-toolbar[data-hidden] > :not(.admin-search) { visibility: visible; } + .media-head, .media-row { grid-template-columns: 16px minmax(0, 1fr) 36px; } + .media-cell { display: none; } + /* 窄的时候封面让出位置:标题下面两行要放下大小、存储时间和最近播放。 */ + .media-song > .artwork { display: none; } + .media-song-copy .media-narrow { display: inline; } + .media-song-copy > .media-narrow { display: block; } } diff --git a/web/src/features/admin/media-store.test.ts b/web/src/features/admin/media-store.test.ts new file mode 100644 index 0000000..b2d5065 --- /dev/null +++ b/web/src/features/admin/media-store.test.ts @@ -0,0 +1,300 @@ +import type { AdminListMediaRequest, AdminRemoveMediaRequest } from '@/core/api' +import type { StoredMedia } from '@/core/realtime' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { notificationStore } from '@/shared/ui' +import { adminStore } from './admin-store' +import { fallbackPage, MediaStore } from './media-store' + +class FakeStorage { + readonly #items = new Map() + + getItem(key: string): string | null { + return this.#items.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.#items.set(key, value) + } + + removeItem(key: string): void { + this.#items.delete(key) + } +} + +function media(n: number): StoredMedia { + return { key: `k${n}`, source: 'link', link: `https://example.com/${n}.mp3`, title: `歌 ${n}`, artist: '', coverUrl: '', size: n * 1000, storedAtMs: n, lastPlayedAtMs: null } +} + +let library: StoredMedia[] +let playing: Set +let reads: AdminListMediaRequest[] +let removals: string[][] +// holdReads 时读取的结果在请求到达时确定,等 release 之后才返回,模拟慢的读取。 +let holdReads: boolean +let release: (() => void)[] +let failReads: boolean +let failRemovals: boolean + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) +} + +const unavailable = { error: { code: 'UNAVAILABLE', message: '服务暂时不可用,请稍后再试。' } } + +async function list(body: AdminListMediaRequest): Promise { + reads.push(body) + const page = body.page ?? 1 + const matched = library.filter(item => item.title.includes(body.q ?? '')) + const response = json(200, { + items: matched.slice((page - 1) * 50, page * 50), + total: matched.length, + totalBytes: matched.reduce((sum, item) => sum + item.size, 0), + page, + pageSize: 50, + }) + if (holdReads) + await new Promise(resolve => release.push(resolve)) + return failReads ? json(503, unavailable) : response +} + +function remove(body: AdminRemoveMediaRequest): Response { + const keys = body.media.map(ref => ref.key) + removals.push(keys) + const inUse = keys.filter(key => playing.has(key)) + const deleted = keys.filter(key => !playing.has(key) && library.some(item => item.key === key)) + // 中途出错时,出错之前删掉的不会恢复。 + const done = failRemovals ? deleted.slice(0, -1) : deleted + library = library.filter(item => !done.includes(item.key)) + return failRemovals ? json(503, unavailable) : json(200, { deleted, inUse }) +} + +const keys = (store: MediaStore) => store.state.list?.items.map(item => item.key) +const selected = (store: MediaStore) => [...store.state.selected].sort() +const messages = () => notificationStore.state.notifications.map(n => n.message) + +// 等作废的读取走完:它不改变任何状态,没有可以等待的信号。 +const drain = () => new Promise(resolve => setTimeout(resolve, 20)) + +function releaseAll(): void { + for (const resolve of release.splice(0)) + resolve() +} + +beforeEach(() => { + library = Array.from({ length: 55 }, (_, index) => media(index + 1)) + playing = new Set() + reads = [] + removals = [] + holdReads = false + release = [] + failReads = false + failRemovals = false + vi.stubGlobal('sessionStorage', new FakeStorage()) + vi.stubGlobal('fetch', async (path: string, init: RequestInit) => { + const body: unknown = JSON.parse(init.body as string) + if (path === '/api/admin/media/queries') + return list(body as AdminListMediaRequest) + if (path === '/api/admin/media/removals') + return remove(body as AdminRemoveMediaRequest) + return json(404, { error: { code: 'NOT_FOUND', message: '接口不存在' } }) + }) + for (const { id } of notificationStore.state.notifications) + notificationStore.dismiss(id) + adminStore.signIn('token') +}) + +describe('mediaStore 的读取', () => { + it('按地址里的参数读取一页', async () => { + const store = new MediaStore() + store.show({ q: '歌 1', sort: 'size', page: 1 }) + expect(store.state.loading).toBe(true) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + expect(reads).toEqual([{ q: '歌 1', sort: 'size', page: 1 }]) + expect(store.state.list).toMatchObject({ total: 11, page: 1, pageSize: 50 }) + expect(store.state.error).toBeNull() + }) + + it('同一组参数只请求一次', async () => { + const store = new MediaStore() + store.show({ q: '', sort: 'stored', page: 1 }) + store.show({ q: '', sort: 'stored', page: 1 }) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + store.show({ q: '', sort: 'stored', page: 1 }) + expect(reads).toHaveLength(1) + store.show({ q: '', sort: 'stored', page: 2 }) + store.show({ q: '', sort: 'stored', page: 2 }) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + expect(reads.map(read => read.page)).toEqual([1, 2]) + }) + + it('参数变化时,先发出的读取晚回来也不覆盖新的一页', async () => { + const store = new MediaStore() + holdReads = true + store.show({ q: '', sort: 'stored', page: 1 }) + store.show({ q: '', sort: 'stored', page: 2 }) + await vi.waitFor(() => expect(release).toHaveLength(2)) + const [first, second] = release.splice(0) + second!() + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + expect(keys(store)).toEqual(['k51', 'k52', 'k53', 'k54', 'k55']) + first!() + await drain() + expect(store.state.list?.page).toBe(2) + expect(store.state.loading).toBe(false) + }) + + it('新的一页读到之前保留上一页', async () => { + const store = new MediaStore() + store.show({ q: '', sort: 'stored', page: 1 }) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + holdReads = true + store.show({ q: '', sort: 'stored', page: 2 }) + expect(store.state.loading).toBe(true) + expect(store.state.list?.page).toBe(1) + await vi.waitFor(() => expect(release).toHaveLength(1)) + releaseAll() + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + expect(store.state.list?.page).toBe(2) + }) + + it('读取失败时就地给出原因,不弹通知;重试时即使参数没变也重新读取', async () => { + const store = new MediaStore() + failReads = true + store.show({ q: '', sort: 'stored', page: 1 }) + await vi.waitFor(() => expect(store.state.error).toBe('服务暂时不可用,请稍后再试。')) + expect(store.state.loading).toBe(false) + expect(messages()).toEqual([]) + failReads = false + store.retry() + expect(store.state.error).toBeNull() + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + expect(reads).toHaveLength(2) + expect(store.state.list?.items).toHaveLength(50) + }) +}) + +describe('mediaStore 的选择', () => { + async function opened(page = 1): Promise { + const store = new MediaStore() + store.show({ q: '', sort: 'stored', page }) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + return store + } + + it('逐个切换,按住 Shift 选中上一次点的到这一项之间的全部', async () => { + const store = await opened() + store.toggle('k2', false) + store.toggle('k5', true) + expect(selected(store)).toEqual(['k2', 'k3', 'k4', 'k5']) + store.toggle('k3', false) + expect(selected(store)).toEqual(['k2', 'k4', 'k5']) + }) + + it('表头的复选框全选本页,再点一次全部取消', async () => { + const store = await opened(2) + store.toggle('k52', false) + store.toggleAll() + expect(selected(store)).toEqual(['k51', 'k52', 'k53', 'k54', 'k55']) + store.toggleAll() + expect(selected(store)).toEqual([]) + }) + + it('换了参数就清空选择,Shift 也不再从上一页选起', async () => { + const store = await opened() + store.toggle('k1', false) + store.show({ q: '', sort: 'stored', page: 2 }) + expect(selected(store)).toEqual([]) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + store.toggle('k53', true) + expect(selected(store)).toEqual(['k53']) + }) + + it('取消选择', async () => { + const store = await opened() + store.toggle('k1', false) + store.clearSelection() + expect(selected(store)).toEqual([]) + }) +}) + +describe('mediaStore 的删除', () => { + async function opened(page = 1): Promise { + const store = new MediaStore() + store.show({ q: '', sort: 'stored', page }) + await vi.waitFor(() => expect(store.state.loading).toBe(false)) + return store + } + + it('删除之后重新读取当前页,读完才返回', async () => { + const store = await opened() + const result = await store.remove(['k1', 'k2']) + expect(result).toEqual({ deleted: ['k1', 'k2'], inUse: [] }) + expect(removals).toEqual([['k1', 'k2']]) + expect(reads.map(read => read.page)).toEqual([1, 1]) + expect(store.state.loading).toBe(false) + expect(keys(store)?.slice(0, 2)).toEqual(['k3', 'k4']) + expect(store.state.list?.total).toBe(53) + }) + + it('正在房间里播放的歌原样交给界面,它们留在这一页上,也仍然选中', async () => { + playing = new Set(['k2']) + const store = await opened() + store.toggle('k1', false) + store.toggle('k3', true) + const result = await store.remove([...store.state.selected]) + expect(result).toEqual({ deleted: ['k1', 'k3'], inUse: ['k2'] }) + expect(keys(store)?.slice(0, 2)).toEqual(['k2', 'k4']) + expect(selected(store)).toEqual(['k2']) + }) + + it('当前页删空、又不是第 1 页时,回到上一页', async () => { + const store = await opened(2) + await store.remove(['k51', 'k52', 'k53', 'k54', 'k55']) + expect(reads.map(read => read.page)).toEqual([2, 2]) + expect(store.state.list?.items).toEqual([]) + expect(fallbackPage(store.state.list!)).toBe(1) + }) + + it('中途出错时提示原因,也重新读取当前页:出错之前删掉的歌不再显示,选择里也去掉', async () => { + const store = await opened() + store.toggle('k1', false) + store.toggle('k3', true) + failRemovals = true + expect(await store.remove([...store.state.selected])).toBeNull() + expect(messages()).toEqual(['服务暂时不可用,请稍后再试。']) + expect(reads.map(read => read.page)).toEqual([1, 1]) + expect(keys(store)?.slice(0, 2)).toEqual(['k3', 'k4']) + expect(store.state.list?.total).toBe(53) + expect(selected(store)).toEqual(['k3']) + }) + + it('删除之前发出、还没回来的读取作废', async () => { + const store = await opened() + holdReads = true + store.retry() + await vi.waitFor(() => expect(release).toHaveLength(1)) + const stale = release.shift()! + const removing = store.remove(['k1']) + await vi.waitFor(() => expect(release).toHaveLength(1)) + releaseAll() + await removing + stale() + await drain() + expect(keys(store)?.[0]).toBe('k2') + }) +}) + +describe('fallbackPage', () => { + const page = (items: number, total: number, at: number) => ({ items: Array.from({ length: items }, (_, index) => media(index)), total, totalBytes: 0, page: at, pageSize: 50 }) + + it('这一页有内容,或者已经是第 1 页时不用换页', () => { + expect(fallbackPage(page(5, 55, 2))).toBeNull() + expect(fallbackPage(page(0, 0, 1))).toBeNull() + }) + + it('空页改去最后一页有内容的页,全都没有时去第 1 页', () => { + expect(fallbackPage(page(0, 100, 3))).toBe(2) + expect(fallbackPage(page(0, 101, 9))).toBe(3) + expect(fallbackPage(page(0, 0, 4))).toBe(1) + }) +}) diff --git a/web/src/features/admin/media-store.ts b/web/src/features/admin/media-store.ts new file mode 100644 index 0000000..faae7ec --- /dev/null +++ b/web/src/features/admin/media-store.ts @@ -0,0 +1,103 @@ +import type { AdminListMediaResponse, AdminRemoveMediaResponse } from '@/core/api' +import type { MediaSort } from '@/core/realtime' +import { api } from '@/core/api' +import { toggleSelection } from '@/shared/lib/range-selection' +import { Store } from '@/shared/lib/store' +import { adminStore } from './admin-store' + +export interface MediaQuery { + q: string + sort: MediaSort + page: number +} + +interface MediaState { + // 最近读到的一页。换了参数之后、新的一页读到之前仍是上一页,这时 loading 为 true。 + list: AdminListMediaResponse | null + // 读到 list 的时间,用来显示“几天前播放”。 + loadedAt: number + loading: boolean + // 最近一次读取失败的原因,给用户看。 + error: string | null + // 只含这一页上的歌。 + selected: ReadonlySet +} + +// 读到的这一页是空的、又不是第 1 页时(删空了最后一页,或者打开了页码超出的旧链接),返回最后一页有内容的页码。 +export function fallbackPage(list: AdminListMediaResponse): number | null { + if (list.items.length || list.page <= 1) + return null + return Math.max(1, Math.ceil(list.total / list.pageSize)) +} + +// 每打开一次媒体页新建一个:离开再回来时重新读取。 +export class MediaStore extends Store { + #query: MediaQuery | null = null + // 只有最后发起的读取能写入:换了参数、重试或删除之后,此前的读取作废。 + #generation = 0 + #anchor: string | null = null + + constructor() { + super({ list: null, loadedAt: 0, loading: false, error: null, selected: new Set() }) + } + + // 地址里的参数变化时调用;参数和上一次相同时不重新读取。换了参数就清空选择。 + show(query: MediaQuery): void { + const current = this.#query + if (current && current.q === query.q && current.sort === query.sort && current.page === query.page) + return + this.#query = query + this.clearSelection() + void this.#load() + } + + retry(): void { + void this.#load() + } + + toggle(key: string, shift: boolean): void { + const keys = this.state.list?.items.map(item => item.key) ?? [] + const { selected, anchor } = toggleSelection(this.state.selected, keys, key, this.#anchor, shift) + this.#anchor = anchor + this.setState({ selected }) + } + + toggleAll(): void { + const items = this.state.list?.items ?? [] + const all = items.every(item => this.state.selected.has(item.key)) + this.#anchor = null + this.setState({ selected: new Set(all ? [] : items.map(item => item.key)) }) + } + + clearSelection(): void { + this.#anchor = null + this.setState({ selected: new Set() }) + } + + // 失败时原因已经通过通知显示,返回 null。无论成败都重新读取当前页,读完才返回:界面不会再显示已经删掉的歌,中途出错时出错之前删掉的也一样。 + async remove(keys: string[]): Promise { + const result = await adminStore.call(() => api.adminRemoveMedia({ media: keys.map(key => ({ key })) })) + await this.#load() + return result + } + + async #load(): Promise { + const { q, sort, page } = this.#query! + const generation = ++this.#generation + this.setState({ loading: true, error: null }) + try { + const list = await adminStore.attempt(() => api.adminListMedia({ page, sort, q })) + if (generation !== this.#generation) + return + // 删除之后重新读到的这一页上,删掉的歌已经不在了,选择里也去掉。 + const present = new Set(list.items.map(item => item.key)) + if (this.#anchor !== null && !present.has(this.#anchor)) + this.#anchor = null + this.setState({ list, loadedAt: Date.now(), loading: false, selected: new Set([...this.state.selected].filter(key => present.has(key))) }) + } + catch (error) { + if (generation === this.#generation) + this.setState({ loading: false, error: (error as Error).message }) + } + } +} diff --git a/web/src/features/admin/media-table.tsx b/web/src/features/admin/media-table.tsx new file mode 100644 index 0000000..68151ae --- /dev/null +++ b/web/src/features/admin/media-table.tsx @@ -0,0 +1,107 @@ +import type { StoredMedia } from '@/core/realtime' +import { Ellipsis, ExternalLink, Trash2 } from 'lucide-react' +import { bookTitle } from '@/shared/lib/book-title' +import { formatDateTime } from '@/shared/lib/time' +import { ActionMenu, Artwork } from '@/shared/ui' +import { formatPlayed, formatSize, formatStoredDate, mediaByline } from './media-view' + +// 按住 Shift 点击会把页面上的文字一起选中,按下时就阻止。 +function keepTextSelection(event: { shiftKey: boolean, preventDefault: () => void }): void { + if (event.shiftKey) + event.preventDefault() +} + +function MediaRow({ item, now, selected, onToggle, onDelete }: { + item: StoredMedia + now: number + selected: boolean + onToggle: (key: string, shift: boolean) => void + onDelete: (item: StoredMedia) => void +}) { + const size = formatSize(item.size) + const stored = formatStoredDate(item.storedAtMs, now) + const played = formatPlayed(item.lastPlayedAtMs, now) + const playedLabel = item.lastPlayedAtMs === null ? played : `最近播放 ${played}` + return ( +
  • + +
    + +
    + {item.title} + + {mediaByline(item)} + {` · ${size}`} + + {`存储于 ${stored} · ${playedLabel}`} +
    +
    + {size} + + 存储于 + {stored} + + + {item.lastPlayedAtMs !== null && 最近播放} + {played} + + window.open(item.link, '_blank', 'noopener,noreferrer') }, + { icon: Trash2, label: '删除', danger: true, run: () => onDelete(item) }, + ]} + menuLabel={`${bookTitle(item.title)}的操作`} + className="icon-button admin-menu-button" + label={`${bookTitle(item.title)}的更多操作`} + title="更多操作" + > + +
  • + ) +} + +// busy 时这一页还是换参数之前的内容,不能操作。 +export function MediaTable({ items, now, selected, busy, onToggle, onToggleAll, onDelete }: { + items: StoredMedia[] + now: number + selected: ReadonlySet + busy: boolean + onToggle: (key: string, shift: boolean) => void + onToggleAll: () => void + onDelete: (item: StoredMedia) => void +}) { + const count = items.filter(item => selected.has(item.key)).length + return ( +
    +
    + + + + + +
    +
      + {items.map(item => )} +
    +
    + ) +} diff --git a/web/src/features/admin/media-view.test.ts b/web/src/features/admin/media-view.test.ts new file mode 100644 index 0000000..3718e5e --- /dev/null +++ b/web/src/features/admin/media-view.test.ts @@ -0,0 +1,109 @@ +import type { StoredMedia } from '@/core/realtime' +import { describe, expect, it } from 'vitest' +import { deleteConfirmText, formatPlayed, formatSize, formatStoredDate, mediaByline, mediaOrigin, removalMessage, searchDraft } from './media-view' + +const MiB = 1024 * 1024 + +function media(patch: Partial): StoredMedia { + return { key: 'k', source: 'link', link: 'https://example.com/a.mp3', title: '夜に駆ける', artist: '', coverUrl: '', size: 0, storedAtMs: 0, lastPlayedAtMs: null, ...patch } +} + +describe('formatSize', () => { + it('不到 1 GiB 用 MiB,之后用 GiB,保留一位小数', () => { + expect(formatSize(0)).toBe('0.0 MiB') + expect(formatSize(8.94 * MiB)).toBe('8.9 MiB') + expect(formatSize(1023 * MiB)).toBe('1023.0 MiB') + expect(formatSize(1024 * MiB)).toBe('1.0 GiB') + expect(formatSize(9.6 * 1024 * MiB)).toBe('9.6 GiB') + }) +}) + +describe('formatStoredDate', () => { + const now = new Date(2026, 10, 11, 20, 0).getTime() + + it('今年的只写月日,往年的带上年份', () => { + expect(formatStoredDate(new Date(2026, 2, 2, 8, 30).getTime(), now)).toBe('3 月 2 日') + expect(formatStoredDate(new Date(2025, 11, 31, 23, 59).getTime(), now)).toBe('2025 年 12 月 31 日') + }) +}) + +describe('formatPlayed', () => { + const now = new Date(2026, 10, 11, 20, 0).getTime() + const minute = 60 * 1000 + const day = 24 * 60 * minute + + it('没有播放过', () => { + expect(formatPlayed(null, now)).toBe('从未播放') + }) + + it('按经过的时间取最大的单位', () => { + expect(formatPlayed(now - 59 * 1000, now)).toBe('刚刚') + expect(formatPlayed(now - 5 * minute, now)).toBe('5 分钟前') + expect(formatPlayed(now - 3 * 60 * minute, now)).toBe('3 小时前') + expect(formatPlayed(now - day, now)).toBe('1 天前') + expect(formatPlayed(now - 29 * day, now)).toBe('29 天前') + expect(formatPlayed(now - 125 * day, now)).toBe('4 个月前') + expect(formatPlayed(now - 400 * day, now)).toBe('1 年前') + }) + + it('服务器的时钟比本机快时算作刚刚', () => { + expect(formatPlayed(now + 5 * minute, now)).toBe('刚刚') + }) +}) + +describe('mediaOrigin', () => { + it('哔哩哔哩的歌写平台名,链接写网站的域名', () => { + expect(mediaOrigin(media({ source: 'bilibili', link: 'https://www.bilibili.com/video/BV1xx411c7mD' }))).toBe('哔哩哔哩') + expect(mediaOrigin(media({ source: 'link', link: 'https://music.example.com/a/b.mp3?x=1' }))).toBe('music.example.com') + }) + + it('解析不了的链接原样显示', () => { + expect(mediaOrigin(media({ source: 'link', link: 'not a url' }))).toBe('not a url') + }) +}) + +describe('mediaByline', () => { + it('有歌手时写“歌手 · 来源”', () => { + expect(mediaByline(media({ artist: 'YOASOBI', source: 'link', link: 'https://music.example.com/a.mp3' }))).toBe('YOASOBI · music.example.com') + }) + + it('不知道歌手时只写来源', () => { + expect(mediaByline(media({ source: 'bilibili', link: 'https://www.bilibili.com/video/BV1xx411c7mD' }))).toBe('哔哩哔哩') + }) +}) + +describe('removalMessage', () => { + it('全部删除', () => { + expect(removalMessage(3, 0)).toBe('已删除 3 首') + }) + + it('有正在播放的歌时补一句', () => { + expect(removalMessage(3, 1)).toBe('已删除 2 首;1 首正在房间里播放,没有删除') + expect(removalMessage(2, 2)).toBe('2 首正在房间里播放,没有删除') + }) +}) + +describe('deleteConfirmText', () => { + it('一首写出歌名和大小', () => { + expect(deleteConfirmText([media({ size: 8.94 * MiB })])).toBe('删除《夜に駆ける》(8.9 MiB)的音频和封面后,再点这首歌会重新下载。') + }) + + it('多首写出数量和总大小', () => { + expect(deleteConfirmText([media({ size: 8.94 * MiB }), media({ key: 'k2', size: 7.4 * MiB }), media({ key: 'k3', size: 5.4 * MiB })])) + .toBe('删除选中的 3 首歌(共 21.7 MiB)的音频和封面后,再点这些歌会重新下载。') + }) +}) + +describe('searchDraft', () => { + it('地址里的搜索词是输入框自己提交的值时,保留输入框里的原样输入,包括首尾的空白', () => { + expect(searchDraft('summer ', 'summer')).toBe('summer ') + expect(searchDraft(' lemon', 'lemon')).toBe(' lemon') + expect(searchDraft('summer', 'summer')).toBe('summer') + }) + + it('地址里的搜索词被别处改掉时(清除搜索、后退),输入框跟着改', () => { + expect(searchDraft('summer ', '')).toBe('') + expect(searchDraft('summer breeze', 'summer')).toBe('summer') + expect(searchDraft(' ', 'lemon')).toBe('lemon') + }) +}) diff --git a/web/src/features/admin/media-view.ts b/web/src/features/admin/media-view.ts new file mode 100644 index 0000000..61f5870 --- /dev/null +++ b/web/src/features/admin/media-view.ts @@ -0,0 +1,80 @@ +import type { StoredMedia } from '@/core/realtime' +import { bookTitle } from '@/shared/lib/book-title' + +const MiB = 1024 * 1024 +const GiB = 1024 * MiB +const minute = 60 * 1000 +const hour = 60 * minute +const day = 24 * hour + +// 地址里的搜索词变了之后输入框显示什么:它就是输入框自己提交的值(去掉了首尾空白)时保留原样的输入,比如末尾刚打的空格;被别处改掉时(清除搜索、后退)换成地址里的。 +export function searchDraft(draft: string, q: string): string { + return draft.trim() === q ? draft : q +} + +export function formatSize(bytes: number): string { + return bytes < GiB ? `${(bytes / MiB).toFixed(1)} MiB` : `${(bytes / GiB).toFixed(1)} GiB` +} + +export function formatStoredDate(ms: number, now: number): string { + const date = new Date(ms) + const monthDay = `${date.getMonth() + 1} 月 ${date.getDate()} 日` + return date.getFullYear() === new Date(now).getFullYear() ? monthDay : `${date.getFullYear()} 年 ${monthDay}` +} + +export function formatPlayed(ms: number | null, now: number): string { + if (ms === null) + return '从未播放' + const elapsed = now - ms + if (elapsed < minute) + return '刚刚' + if (elapsed < hour) + return `${Math.floor(elapsed / minute)} 分钟前` + if (elapsed < day) + return `${Math.floor(elapsed / hour)} 小时前` + const days = Math.floor(elapsed / day) + if (days < 30) + return `${days} 天前` + if (days < 365) + return `${Math.floor(days / 30)} 个月前` + return `${Math.floor(days / 365)} 年前` +} + +export function mediaOrigin(item: StoredMedia): string { + switch (item.source) { + case 'bilibili': + return '哔哩哔哩' + case 'netease': + return '网易云音乐' + case 'qq': + return 'QQ 音乐' + case 'link': + try { + return new URL(item.link).host + } + catch { + return item.link + } + } +} + +export function mediaByline(item: StoredMedia): string { + const origin = mediaOrigin(item) + return item.artist ? `${item.artist} · ${origin}` : origin +} + +// 不存在的歌(比如在别的标签页里已经删掉了)也算删除:对管理员来说结果一样。 +export function removalMessage(requested: number, inUse: number): string { + const busy = `${inUse} 首正在房间里播放,没有删除` + if (inUse === requested) + return busy + const removed = `已删除 ${requested - inUse} 首` + return inUse ? `${removed};${busy}` : removed +} + +export function deleteConfirmText(items: StoredMedia[]): string { + const size = formatSize(items.reduce((sum, item) => sum + item.size, 0)) + return items.length === 1 + ? `删除${bookTitle(items[0]!.title)}(${size})的音频和封面后,再点这首歌会重新下载。` + : `删除选中的 ${items.length} 首歌(共 ${size})的音频和封面后,再点这些歌会重新下载。` +} diff --git a/web/src/features/admin/stored-media.tsx b/web/src/features/admin/stored-media.tsx index 4ffd102..153e169 100644 --- a/web/src/features/admin/stored-media.tsx +++ b/web/src/features/admin/stored-media.tsx @@ -1,89 +1,160 @@ -import type { AdminListMediaResponse } from '@/core/api' -import type { StoredMedia as Media } from '@/core/realtime' -import { Trash2 } from 'lucide-react' -import { useEffect, useState } from 'react' -import { api } from '@/core/api' -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 type { MediaQuery } from './media-store' +import type { MediaSort, StoredMedia } from '@/core/realtime' +import type { SelectOption } from '@/shared/ui' +import { Database, Search, SearchX } from 'lucide-react' +import { useEffect, useEffectEvent, useRef, useState } from 'react' +import { rowAfterRemoval } from '@/shared/lib/removal-focus' +import { useStore } from '@/shared/lib/use-store' +import { ConfirmDialog, EmptyState, notificationStore, Pagination, Select, Skeleton } from '@/shared/ui' +import { fallbackPage, MediaStore } from './media-store' +import { MediaTable } from './media-table' +import { deleteConfirmText, formatSize, removalMessage, searchDraft } from './media-view' +import { rowOrHeading } from './removal-focus' import { LoadFailed, SectionHead } from './section-head' -function formatSize(bytes: number): string { - return `${(bytes / 1024 / 1024).toFixed(1)} MiB` +const searchDelayMs = 300 +// 与接口对 q 的长度限制一致。 +const searchMaxLength = 100 + +const sortOptions: (SelectOption & { value: MediaSort })[] = [ + { value: 'stored', label: '最近存储' }, + { value: 'idle', label: '最久未播放' }, + { value: 'size', label: '占用最大' }, +] + +// 输入停顿之后才改写地址,输入法组字期间不算停顿:不拿拼音字母去搜索。 +function MediaSearch({ q, onSearch }: { q: string, onSearch: (q: string) => void }) { + const [draft, setDraft] = useState(q) + const [seenQ, setSeenQ] = useState(q) + const [composing, setComposing] = useState(false) + const search = useEffectEvent(onSearch) + if (q !== seenQ) { + setSeenQ(q) + setDraft(searchDraft(draft, q)) + } + useEffect(() => { + const next = draft.trim() + if (composing || next === q) + return + const timer = setTimeout(search, searchDelayMs, next) + return () => clearTimeout(timer) + }, [draft, q, composing]) + return ( + + ) } -export function StoredMedia() { - const [media, setMedia] = useState(null) - const [failed, setFailed] = useState(false) - const [reload, setReload] = useState(0) - const [deleting, setDeleting] = useState(null) +export function MediaPage({ q, sort, page, onNavigate }: MediaQuery & { onNavigate: (query: MediaQuery) => void }) { + const [store] = useState(() => new MediaStore()) + const list = useStore(store, s => s.list) + const loadedAt = useStore(store, s => s.loadedAt) + const loading = useStore(store, s => s.loading) + const error = useStore(store, s => s.error) + const selected = useStore(store, s => s.selected) + const [deleting, setDeleting] = useState(null) + // 删掉的歌之后焦点落到哪一行:null 表示落到标题。 + const nextFocusRef = useRef(null) + + useEffect(() => store.show({ q, sort, page }), [store, q, sort, page]) + + const fallback = list && !loading && error === null ? fallbackPage(list) : null + const navigate = useEffectEvent(onNavigate) useEffect(() => { - let active = true - void adminStore.call(() => api.adminListMedia({ page: 1 })).then((result) => { - if (!active) - return - if (result) - setMedia(result) - else - setFailed(true) - }) - return () => { - active = false - } - }, [reload]) - const retry = () => { - setFailed(false) - setMedia(null) - setReload(n => n + 1) + if (fallback !== null) + navigate({ q, sort, page: fallback }) + }, [fallback, q, sort]) + + // 页码在表格下面,换页之后回到顶部。用 nearest:start 会把外层不该滚动的页面框架也一起滚上去。 + const goToPage = (next: number) => { + onNavigate({ q, sort, page: next }) + document.getElementById('admin-media-title')?.scrollIntoView({ block: 'nearest' }) } + const chosen = list?.items.filter(item => selected.has(item.key)) ?? [] + + const content = () => { + if (error !== null) + return store.retry()} /> + if (!list || fallback !== null) + return + if (!list.items.length) { + return q + ? ( + onNavigate({ q: '', sort, page: 1 })}>清除搜索}> + 换个标题再找找。 + + ) + : 点过的链接和哔哩哔哩的歌会存在这里。 + } + const first = (list.page - 1) * list.pageSize + 1 + return ( + <> + store.toggle(key, shift)} + onToggleAll={() => store.toggleAll()} + onDelete={item => setDeleting([item])} + /> + + + ) + } + return (
    - - {media && {media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}} - - {failed - ? - : !media - ? - : !media.items.length - ?

    还没有存储任何歌曲。

    - : ( -
      - {media.items.map(item => ( -
    • -
      - {item.title} - {item.link} - - {`${item.source === 'bilibili' ? '哔哩哔哩 · ' : ''}${formatSize(item.size)} · 存入于 ${formatDateTime(item.storedAtMs)} · ${item.lastPlayedAtMs === null ? '还没有播放过' : `最近播放于 ${formatDateTime(item.lastPlayedAtMs)}`}`} - -
      - setDeleting(item)} /> -
    • - ))} -
    - )} + + {chosen.length > 0 && ( +
    +
    + {`已选 ${chosen.length} 首 · ${formatSize(chosen.reduce((sum, item) => sum + item.size, 0))}`} + + +
    +
    + )} +
    0 || undefined}> + onNavigate({ q: next, sort, page: 1 })} /> +