feat: browse stored media page by page, sort and search it, and delete several songs at once
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
This commit is contained in:
1 parent
0f6ecaf6da
commit
d72b4203cb
8 files changed
+879
-81
No files matched your search
@@ -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' && <Accounts />}
|
||||
{shown === 'rooms' && <PersistentRooms q={q} page={page} onFilter={filter => onNavigate({ section: 'rooms', ...filter })} onEntered={onEntered} />}
|
||||
{shown === 'sources' && <SourceAccounts credentials={credentials} />}
|
||||
{shown === 'media' && <StoredMedia />}
|
||||
{shown === 'media' && <MediaPage q={q} sort={sort} page={page} onNavigate={view => onNavigate({ section: 'media', ...view })} />}
|
||||
</SectionLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<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)
|
||||
}
|
||||
}
|
||||
|
||||
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<string>
|
||||
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<Response> {
|
||||
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<void>(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<MediaStore> {
|
||||
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<MediaStore> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
// 读到的这一页是空的、又不是第 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<MediaState> {
|
||||
#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<AdminRemoveMediaResponse | null> {
|
||||
const result = await adminStore.call(() => api.adminRemoveMedia({ media: keys.map(key => ({ key })) }))
|
||||
await this.#load()
|
||||
return result
|
||||
}
|
||||
|
||||
async #load(): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<li className="media-row" data-key={item.key} data-selected={selected || undefined}>
|
||||
<label className="media-check" onMouseDown={keepTextSelection}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择${bookTitle(item.title)}`}
|
||||
checked={selected}
|
||||
onChange={event => onToggle(item.key, event.nativeEvent instanceof MouseEvent && event.nativeEvent.shiftKey)}
|
||||
/>
|
||||
</label>
|
||||
<div className="media-song">
|
||||
<Artwork src={item.coverUrl} title={item.title} />
|
||||
<div className="media-song-copy">
|
||||
<strong className="truncate" title={item.title}>{item.title}</strong>
|
||||
<span className="truncate">
|
||||
{mediaByline(item)}
|
||||
<span className="media-narrow">{` · ${size}`}</span>
|
||||
</span>
|
||||
<span className="media-narrow truncate">{`存储于 ${stored} · ${playedLabel}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="media-cell">{size}</span>
|
||||
<span className="media-cell" title={formatDateTime(item.storedAtMs)}>
|
||||
<span className="sr-only">存储于</span>
|
||||
{stored}
|
||||
</span>
|
||||
<span className="media-cell" title={item.lastPlayedAtMs === null ? undefined : formatDateTime(item.lastPlayedAtMs)}>
|
||||
{item.lastPlayedAtMs !== null && <span className="sr-only">最近播放</span>}
|
||||
{played}
|
||||
</span>
|
||||
<ActionMenu
|
||||
actions={[
|
||||
{ icon: ExternalLink, label: '打开歌曲页面', run: () => 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="更多操作"
|
||||
>
|
||||
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
|
||||
</ActionMenu>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// busy 时这一页还是换参数之前的内容,不能操作。
|
||||
export function MediaTable({ items, now, selected, busy, onToggle, onToggleAll, onDelete }: {
|
||||
items: StoredMedia[]
|
||||
now: number
|
||||
selected: ReadonlySet<string>
|
||||
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 (
|
||||
<div className="section-group media-table" aria-busy={busy} inert={busy}>
|
||||
<div className="media-head">
|
||||
<label className="media-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="全选本页"
|
||||
checked={count > 0 && count === items.length}
|
||||
ref={(input) => {
|
||||
if (input)
|
||||
input.indeterminate = count > 0 && count < items.length
|
||||
}}
|
||||
onChange={onToggleAll}
|
||||
/>
|
||||
</label>
|
||||
<span aria-hidden="true">歌曲</span>
|
||||
<span className="media-cell" aria-hidden="true">大小</span>
|
||||
<span className="media-cell" aria-hidden="true">存储于</span>
|
||||
<span className="media-cell" aria-hidden="true">最近播放</span>
|
||||
</div>
|
||||
<ul>
|
||||
{items.map(item => <MediaRow key={item.key} item={item} now={now} selected={selected.has(item.key)} onToggle={onToggle} onDelete={onDelete} />)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>): 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')
|
||||
})
|
||||
})
|
||||
@@ -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})的音频和封面后,再点这些歌会重新下载。`
|
||||
}
|
||||
@@ -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 (
|
||||
<label className="admin-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
aria-label="搜索标题"
|
||||
placeholder="搜索标题"
|
||||
maxLength={searchMaxLength}
|
||||
value={draft}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function StoredMedia() {
|
||||
const [media, setMedia] = useState<AdminListMediaResponse | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [reload, setReload] = useState(0)
|
||||
const [deleting, setDeleting] = useState<Media | null>(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<StoredMedia[] | null>(null)
|
||||
// 删掉的歌之后焦点落到哪一行:null 表示落到标题。
|
||||
const nextFocusRef = useRef<string | null>(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 <LoadFailed message={`暂时无法获取已存储的媒体。${error}`} onRetry={() => store.retry()} />
|
||||
if (!list || fallback !== null)
|
||||
return <Skeleton rows={3} />
|
||||
if (!list.items.length) {
|
||||
return q
|
||||
? (
|
||||
<EmptyState icon={SearchX} title="没有找到歌曲" action={<button type="button" className="button-secondary" onClick={() => onNavigate({ q: '', sort, page: 1 })}>清除搜索</button>}>
|
||||
换个标题再找找。
|
||||
</EmptyState>
|
||||
)
|
||||
: <EmptyState icon={Database} title="还没有存储任何歌曲">点过的链接和哔哩哔哩的歌会存在这里。</EmptyState>
|
||||
}
|
||||
const first = (list.page - 1) * list.pageSize + 1
|
||||
return (
|
||||
<>
|
||||
<MediaTable
|
||||
items={list.items}
|
||||
now={loadedAt}
|
||||
selected={selected}
|
||||
busy={loading}
|
||||
onToggle={(key, shift) => store.toggle(key, shift)}
|
||||
onToggleAll={() => store.toggleAll()}
|
||||
onDelete={item => setDeleting([item])}
|
||||
/>
|
||||
<Pagination page={page} totalPages={Math.ceil(list.total / list.pageSize)} onPage={goToPage} summary={`第 ${first}–${first + list.items.length - 1} 首`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-section" aria-labelledby="admin-media-title">
|
||||
<SectionHead id="admin-media-title" title="媒体" description="服务器上缓存的音频。删除后,下次有人点这首歌时会重新下载。">
|
||||
{media && <span className="text-ui-sm muted">{media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}</span>}
|
||||
</SectionHead>
|
||||
{failed
|
||||
? <LoadFailed message="暂时无法获取已存储的媒体。" onRetry={retry} />
|
||||
: !media
|
||||
? <Skeleton rows={3} />
|
||||
: !media.items.length
|
||||
? <p className="admin-empty text-ui-caption muted">还没有存储任何歌曲。</p>
|
||||
: (
|
||||
<ul className="admin-list">
|
||||
{media.items.map(item => (
|
||||
<li className="admin-row" key={item.key}>
|
||||
<div className="admin-row-copy">
|
||||
<strong className="text-ui-base truncate">{item.title}</strong>
|
||||
<a className="admin-link text-ui-sm truncate" href={item.link} target="_blank" rel="noopener noreferrer">{item.link}</a>
|
||||
<span className="text-ui-xs muted">
|
||||
{`${item.source === 'bilibili' ? '哔哩哔哩 · ' : ''}${formatSize(item.size)} · 存入于 ${formatDateTime(item.storedAtMs)} · ${item.lastPlayedAtMs === null ? '还没有播放过' : `最近播放于 ${formatDateTime(item.lastPlayedAtMs)}`}`}
|
||||
</span>
|
||||
</div>
|
||||
<IconButton icon={Trash2} label={`删除${bookTitle(item.title)}`} onClick={() => setDeleting(item)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<SectionHead id="admin-media-title" title="媒体" description="服务器上缓存的音频。删除后,下次有人点这首歌时会重新下载。" />
|
||||
{chosen.length > 0 && (
|
||||
<div className="media-selection-anchor">
|
||||
<div className="media-selection" role="toolbar" aria-label="选中的歌曲">
|
||||
<span className="media-selection-count">{`已选 ${chosen.length} 首 · ${formatSize(chosen.reduce((sum, item) => sum + item.size, 0))}`}</span>
|
||||
<button type="button" className="button-destructive" onClick={() => setDeleting(chosen)}>删除</button>
|
||||
<button type="button" className="button-ghost" onClick={() => store.clearSelection()}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-toolbar" data-hidden={chosen.length > 0 || undefined}>
|
||||
<MediaSearch q={q} onSearch={next => onNavigate({ q: next, sort, page: 1 })} />
|
||||
<Select className="media-sort" ariaLabel="排序" value={sort} options={sortOptions} onChange={next => onNavigate({ q, sort: next as MediaSort, page: 1 })} />
|
||||
{list && list.total > 0 && <span className="media-total">{`共 ${list.total.toLocaleString('zh-CN')} 首 · 占用 ${formatSize(list.totalBytes)}`}</span>}
|
||||
</div>
|
||||
{content()}
|
||||
{deleting && (
|
||||
<ConfirmDialog
|
||||
title="删除已存储的媒体"
|
||||
confirmLabel="删除"
|
||||
confirmLabel={deleting.length > 1 ? `删除 ${deleting.length} 首` : '删除'}
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
const result = await adminStore.call(() => api.adminRemoveMedia({ media: [{ key: deleting.key }] }))
|
||||
if (result === null)
|
||||
const before = list?.items.map(item => item.key) ?? []
|
||||
const keys = deleting.map(item => item.key)
|
||||
const result = await store.remove(keys)
|
||||
if (!result)
|
||||
return
|
||||
if (result.inUse.length) {
|
||||
notificationStore.error('这首歌还在房间的播放列表中,不能删除')
|
||||
return
|
||||
}
|
||||
nextFocusRef.current = rowAfterRemoval(before, store.state.list?.items.map(item => item.key) ?? [], keys)
|
||||
setDeleting(null)
|
||||
setFailed(false)
|
||||
setReload(n => n + 1)
|
||||
const message = removalMessage(deleting.length, result.inUse.length)
|
||||
if (result.inUse.length)
|
||||
notificationStore.info(message, 5000)
|
||||
else
|
||||
notificationStore.success(message)
|
||||
}}
|
||||
onClose={() => setDeleting(null)}
|
||||
returnFocus={() => rowOrHeading('admin-media-title', nextFocusRef.current)}
|
||||
>
|
||||
{`删除${bookTitle(deleting.title)}的音频和封面后,再点这首歌会重新下载。`}
|
||||
{deleteConfirmText(deleting)}
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user