feat: page, sort and search stored media and delete several at once in the admin API

This commit is contained in:
DiheChen committed 2026-11-11 00:19:07 +08:00
1 parent c39331aa75
commit fb751ca57f
23 files changed
+1161 -213

No files matched your search

+22 -3
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, MediaSort, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, StoredMediaRef, Track } from '../realtime/protocol.gen'
export interface AddMyPlaylistItemRequest {
track: Track
@@ -65,8 +65,18 @@ export interface AdminGetYoutubeCredentialResponse {
updatedAtMs: number
}
export interface AdminListMediaRequest {
page?: number
q?: string
sort?: MediaSort
}
export interface AdminListMediaResponse {
items: StoredMedia[]
page: number
pageSize: number
total: number
totalBytes: number
}
export interface AdminListRoomsResponse {
@@ -75,6 +85,15 @@ export interface AdminListRoomsResponse {
export type AdminOpenRegistrationRequest = Record<string, never>
export interface AdminRemoveMediaRequest {
media: StoredMediaRef[]
}
export interface AdminRemoveMediaResponse {
deleted: string[]
inUse: string[]
}
export type AdminResetAccountPasswordRequest = Record<string, never>
export interface AdminResetAccountPasswordResponse {
@@ -391,8 +410,8 @@ export function httpClient(send: HttpSend) {
adminGetGithubCredential: () => send<AdminGetGithubCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/github', auth: 'admin' }),
adminSetGithubCredential: (body: AdminSetGithubCredentialRequest) => send<void>({ method: 'PUT', path: '/api/admin/credentials/github', auth: 'admin', body }),
adminDeleteGithubCredential: () => send<void>({ method: 'DELETE', path: '/api/admin/credentials/github', auth: 'admin' }),
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminListMedia: (body: AdminListMediaRequest) => send<AdminListMediaResponse>({ method: 'POST', path: '/api/admin/media/queries', auth: 'admin', body }),
adminRemoveMedia: (body: AdminRemoveMediaRequest) => send<AdminRemoveMediaResponse>({ method: 'POST', path: '/api/admin/media/removals', auth: 'admin', body }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
adminOpenRegistration: () => send<void>({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }),
adminCloseRegistration: () => send<void>({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }),
+9 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '6bee23791ffa52ad'
export const PROTOCOL_VERSION = 'ce462456c63356c6'
export interface APIKey {
key: string
@@ -76,6 +76,8 @@ export interface ImportedTrack {
track: Track
}
export type MediaSort = 'stored' | 'idle' | 'size'
export interface Member {
avatarHash: string
id: string
@@ -308,6 +310,8 @@ export interface SourceAccount {
}
export interface StoredMedia {
artist: string
coverUrl: string
key: string
lastPlayedAtMs: number | null
link: string
@@ -317,6 +321,10 @@ export interface StoredMedia {
title: string
}
export interface StoredMediaRef {
key: string
}
export interface Track {
id: string
source: Source
+17 -12
View File
@@ -1,10 +1,11 @@
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, Skeleton } from '@/shared/ui'
import { ConfirmDialog, IconButton, notificationStore, Skeleton } from '@/shared/ui'
import { adminStore } from './admin-store'
function formatSize(bytes: number): string {
@@ -12,17 +13,17 @@ function formatSize(bytes: number): string {
}
export function StoredMedia() {
const [media, setMedia] = useState<Media[] | null>(null)
const [media, setMedia] = useState<AdminListMediaResponse | null>(null)
const [failed, setFailed] = useState(false)
const [reload, setReload] = useState(0)
const [deleting, setDeleting] = useState<Media | null>(null)
useEffect(() => {
let active = true
void adminStore.call(() => api.adminListMedia()).then((result) => {
void adminStore.call(() => api.adminListMedia({ page: 1 })).then((result) => {
if (!active)
return
if (result)
setMedia(result.items)
setMedia(result)
else
setFailed(true)
})
@@ -35,12 +36,11 @@ export function StoredMedia() {
setMedia(null)
setReload(n => n + 1)
}
const totalSize = media?.reduce((sum, item) => sum + item.size, 0) ?? 0
return (
<section className="admin-section" aria-labelledby="admin-media-title">
<div className="section-heading">
<h2 id="admin-media-title" className="text-ui-lg">已存储的媒体</h2>
{media && <span className="text-ui-sm muted">{media.length ? `${media.length} 首 · ${formatSize(totalSize)}` : '0 首'}</span>}
{media && <span className="text-ui-sm muted">{media.total ? `${media.total} 首 · ${formatSize(media.totalBytes)}` : '0 首'}</span>}
</div>
{failed
? (
@@ -51,11 +51,11 @@ export function StoredMedia() {
)
: !media
? <Skeleton rows={3} />
: !media.length
: !media.items.length
? <p className="admin-empty text-ui-caption muted">还没有存储任何歌曲。</p>
: (
<ul className="admin-list">
{media.map(item => (
{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>
@@ -75,11 +75,16 @@ export function StoredMedia() {
confirmLabel="删除"
danger
onConfirm={async () => {
if (await adminStore.call(() => api.adminDeleteMedia(deleting.key)) !== null) {
setDeleting(null)
setFailed(false)
setReload(n => n + 1)
const result = await adminStore.call(() => api.adminRemoveMedia({ media: [{ key: deleting.key }] }))
if (result === null)
return
if (result.inUse.length) {
notificationStore.error('这首歌还在房间的播放列表中,不能删除')
return
}
setDeleting(null)
setFailed(false)
setReload(n => n + 1)
}}
onClose={() => setDeleting(null)}
>