From d45a8ceeec909387c093d742169528122c2b0b93 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:45:37 +0800 Subject: [PATCH] split admin library panel --- web/src/pages/AdminLibraryPanel.tsx | 342 ++------------------ web/src/pages/AdminLibraryPanelSections.tsx | 295 +++++++++++++++++ web/src/pages/adminLibraryPanelModel.ts | 36 +++ web/src/pages/useAdminLibraryPanel.ts | 181 +++++++++++ 4 files changed, 540 insertions(+), 314 deletions(-) create mode 100644 web/src/pages/AdminLibraryPanelSections.tsx create mode 100644 web/src/pages/adminLibraryPanelModel.ts create mode 100644 web/src/pages/useAdminLibraryPanel.ts diff --git a/web/src/pages/AdminLibraryPanel.tsx b/web/src/pages/AdminLibraryPanel.tsx index 4ef627a..1b62b34 100644 --- a/web/src/pages/AdminLibraryPanel.tsx +++ b/web/src/pages/AdminLibraryPanel.tsx @@ -1,322 +1,36 @@ -import { FormEvent, useEffect, useState } from 'react' -import toast from 'react-hot-toast' -import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react' - -import { libraryAPI, type LibraryRootInput } from '../api/library' -import type { Library, LibraryRoot } from '../types' -import { confirmAction } from '../components/confirmAction' - -type RootDraft = LibraryRootInput - -const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: true }) +import { AdminLibraryCreateForm, AdminLibraryTable } from './AdminLibraryPanelSections' +import { useAdminLibraryPanel } from './useAdminLibraryPanel' export function AdminLibraryPanel() { - const [libs, setLibs] = useState([]) - const [name, setName] = useState('') - const [roots, setRoots] = useState([emptyRootDraft()]) - const [type, setType] = useState('movie') - const [newRootByLibrary, setNewRootByLibrary] = useState>({}) - const [rootDrafts, setRootDrafts] = useState>({}) - - const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs) - useEffect(() => { - refresh().catch(() => undefined) - }, []) - - const handleCreate = async (e: FormEvent) => { - e.preventDefault() - try { - const payload = roots - .map((root, index) => ({ ...root, path: root.path.trim(), name: root.name?.trim(), sort_order: index })) - .filter((root) => root.path) - if (payload.length === 0) { - toast.error('请至少填写一个路径') - return - } - await libraryAPI.createWithRoots(name, type, payload) - toast.success('媒体库已创建') - setName('') - setRoots([emptyRootDraft()]) - await refresh() - } catch (err: unknown) { - const msg = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? - '创建失败' - toast.error(msg) - } - } - - const updateCreateRoot = (index: number, patch: Partial) => { - setRoots((prev) => prev.map((root, i) => (i === index ? { ...root, ...patch } : root))) - } - - const addCreateRoot = () => setRoots((prev) => [...prev, emptyRootDraft()]) - - const removeCreateRoot = (index: number) => { - setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))) - } - - const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft() - - const setNewRootDraft = (libraryID: string, patch: Partial) => { - setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: { ...newRootDraft(libraryID), ...patch } })) - } - - const addLibraryRoot = async (libraryID: string) => { - const draft = newRootDraft(libraryID) - if (!draft.path?.trim()) { - toast.error('请填写路径') - return - } - await libraryAPI.addRoot(libraryID, { ...draft, path: draft.path.trim(), name: draft.name?.trim() }) - setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: emptyRootDraft() })) - toast.success('路径已添加') - await refresh() - } - - const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}` - - const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => { - const key = rootDraftKey(libraryID, root.id) - return rootDrafts[key] ?? { - name: root.name ?? '', - path: root.path, - enabled: root.enabled, - sort_order: root.sort_order, - } - } - - const setEditableRootDraft = (libraryID: string, root: LibraryRoot, patch: Partial) => { - const key = rootDraftKey(libraryID, root.id) - setRootDrafts((prev) => ({ ...prev, [key]: { ...editableRootDraft(libraryID, root), ...patch } })) - } - - const saveLibraryRoot = async (libraryID: string, root: LibraryRoot) => { - const draft = editableRootDraft(libraryID, root) - if (!draft.path?.trim()) { - toast.error('请填写路径') - return - } - await libraryAPI.updateRoot(libraryID, root.id, { - name: draft.name?.trim(), - path: draft.path.trim(), - enabled: draft.enabled, - sort_order: draft.sort_order, - }) - setRootDrafts((prev) => { - const next = { ...prev } - delete next[rootDraftKey(libraryID, root.id)] - return next - }) - toast.success('路径已保存') - await refresh() - } + const { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel() return (
-
- setName(e.target.value)} - /> - -
- {roots.map((root, index) => ( -
- updateCreateRoot(index, { name: e.target.value })} - /> - updateCreateRoot(index, { path: e.target.value })} - /> - -
- ))} - -
-

- Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS - 宿主机路径,系统会尝试按 compose 挂载自动转换。 -

- -
- -
- - - - - - - - - - - {libs.map((l) => ( - - - - - - - ))} - -
名称路径类型操作
{l.name} -
- {(l.roots?.length - ? l.roots - : [{ - id: '', - library_id: l.id, - path: l.path, - enabled: l.enabled, - name: '', - sort_order: 0, - created_at: l.created_at, - updated_at: l.updated_at, - }]).map((root) => ( -
-
- {root.id ? ( - <> - setEditableRootDraft(l.id, root, { name: e.target.value })} - /> - setEditableRootDraft(l.id, root, { path: e.target.value })} - /> - - ) : ( - {root.name ? `${root.name}:${root.path}` : root.path} - )} -
- {root.id && ( - - )} - - {root.id && ( - - )} - {root.id && ( - - )} -
-
-
- ))} -
- setNewRootDraft(l.id, { name: e.target.value })} - /> - setNewRootDraft(l.id, { path: e.target.value })} - /> - -
-
-
{l.type} - - -
-
+ +
) } diff --git a/web/src/pages/AdminLibraryPanelSections.tsx b/web/src/pages/AdminLibraryPanelSections.tsx new file mode 100644 index 0000000..8cd7a35 --- /dev/null +++ b/web/src/pages/AdminLibraryPanelSections.tsx @@ -0,0 +1,295 @@ +import { FormEvent } from 'react' +import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react' + +import type { Library, LibraryRoot } from '../types' +import type { RootDraft } from './adminLibraryPanelModel' +import { fallbackLibraryRoot } from './adminLibraryPanelModel' + +type CreateFormProps = { + name: string + type: string + roots: RootDraft[] + onNameChange: (value: string) => void + onTypeChange: (value: string) => void + onRootChange: (index: number, patch: Partial) => void + onAddRoot: () => void + onRemoveRoot: (index: number) => void + onSubmit: (e: FormEvent) => void +} + +export function AdminLibraryCreateForm({ + name, + type, + roots, + onNameChange, + onTypeChange, + onRootChange, + onAddRoot, + onRemoveRoot, + onSubmit, +}: CreateFormProps) { + return ( +
+ onNameChange(e.target.value)} + /> + +
+ {roots.map((root, index) => ( + 1} + onChange={onRootChange} + onRemove={onRemoveRoot} + /> + ))} + +
+

+ Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS + 宿主机路径,系统会尝试按 compose 挂载自动转换。 +

+ +
+ ) +} + +type CreateRootRowProps = { + root: RootDraft + index: number + canRemove: boolean + onChange: (index: number, patch: Partial) => void + onRemove: (index: number) => void +} + +function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) { + return ( +
+ onChange(index, { name: e.target.value })} + /> + onChange(index, { path: e.target.value })} + /> + +
+ ) +} + +type LibraryTableProps = { + libs: Library[] + newRootDraft: (libraryID: string) => RootDraft + editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft + onNewRootChange: (libraryID: string, patch: Partial) => void + onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial) => void + onAddRoot: (libraryID: string) => void + onSaveRoot: (libraryID: string, root: LibraryRoot) => void + onScanRoot: (libraryID: string, root: LibraryRoot) => void + onToggleRoot: (libraryID: string, root: LibraryRoot) => void + onRemoveRoot: (library: Library, root: LibraryRoot) => void + onScanLibrary: (library: Library) => void + onRemoveLibrary: (library: Library) => void +} + +export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) { + return ( +
+ + + + + + + + + + + {libs.map((library) => ( + + ))} + +
名称路径类型操作
+
+ ) +} + +type LibraryTableRowProps = Omit & { + library: Library +} + +function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) { + return ( + + {library.name} + + + + {library.type} + + + + + ) +} + +function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) { + const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)] + return ( +
+ {roots.map((root) => ( + + ))} + +
+ ) +} + +type RootEditorProps = Omit & { + library: Library + root: LibraryRoot +} + +function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) { + const draft = actions.editableRootDraft(library.id, root) + return ( +
+
+ {root.id ? ( + + ) : ( + {root.name ? `${root.name}:${root.path}` : root.path} + )} + +
+
+ ) +} + +function EditableRootFields({ library, root, draft, onEditableRootChange }: RootEditorProps & { draft: RootDraft }) { + return ( + <> + onEditableRootChange(library.id, root, { name: e.target.value })} + /> + onEditableRootChange(library.id, root, { path: e.target.value })} + /> + + ) +} + +function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) { + return ( +
+ {root.id && ( + + )} + + {root.id && ( + + )} + {root.id && ( + + )} +
+ ) +} + +function AddRootRow({ library, ...actions }: LibraryTableRowProps) { + const draft = actions.newRootDraft(library.id) + return ( +
+ actions.onNewRootChange(library.id, { name: e.target.value })} + /> + actions.onNewRootChange(library.id, { path: e.target.value })} + /> + +
+ ) +} + +function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) { + return ( + <> + + + + ) +} diff --git a/web/src/pages/adminLibraryPanelModel.ts b/web/src/pages/adminLibraryPanelModel.ts new file mode 100644 index 0000000..ff55fd3 --- /dev/null +++ b/web/src/pages/adminLibraryPanelModel.ts @@ -0,0 +1,36 @@ +import type { Library, LibraryRoot } from '../types' +import type { LibraryRootInput } from '../api/library' + +export type RootDraft = LibraryRootInput + +export const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: true }) + +export const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}` + +export function createRootPayload(roots: RootDraft[]) { + return roots + .map((root, index) => ({ + ...root, + path: root.path.trim(), + name: root.name?.trim(), + sort_order: index, + })) + .filter((root) => root.path) +} + +export function fallbackLibraryRoot(library: Library): LibraryRoot { + return { + id: '', + library_id: library.id, + path: library.path, + enabled: library.enabled, + name: '', + sort_order: 0, + created_at: library.created_at, + updated_at: library.updated_at, + } +} + +export function apiErrorMessage(err: unknown, fallback: string) { + return (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback +} diff --git a/web/src/pages/useAdminLibraryPanel.ts b/web/src/pages/useAdminLibraryPanel.ts new file mode 100644 index 0000000..7a76555 --- /dev/null +++ b/web/src/pages/useAdminLibraryPanel.ts @@ -0,0 +1,181 @@ +import { FormEvent, useEffect, useState } from 'react' +import toast from 'react-hot-toast' + +import { libraryAPI } from '../api/library' +import type { Library, LibraryRoot } from '../types' +import { confirmAction } from '../components/confirmAction' +import { apiErrorMessage, createRootPayload, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel' + +export function useAdminLibraryPanel() { + const { libs, refresh } = useAdminLibraryList() + const createForm = useCreateLibraryForm(refresh) + const newRoots = useNewLibraryRootDrafts(refresh) + const editableRoots = useEditableRootDrafts() + const rootActions = useEditableLibraryRootActions(refresh, editableRoots) + const libraryActions = useLibraryActions(refresh) + + return { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } +} + +function useAdminLibraryList() { + const [libs, setLibs] = useState([]) + const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs) + + useEffect(() => { + refresh().catch(() => undefined) + }, []) + + return { libs, refresh } +} + +function useCreateLibraryForm(refresh: () => Promise) { + const [name, setName] = useState('') + const [roots, setRoots] = useState([emptyRootDraft()]) + const [type, setType] = useState('movie') + + const handleCreate = async (e: FormEvent) => { + e.preventDefault() + try { + const payload = createRootPayload(roots) + if (payload.length === 0) { + toast.error('请至少填写一个路径') + return + } + await libraryAPI.createWithRoots(name, type, payload) + toast.success('媒体库已创建') + setName('') + setRoots([emptyRootDraft()]) + await refresh() + } catch (err: unknown) { + toast.error(apiErrorMessage(err, '创建失败')) + } + } + + const updateRoot = (index: number, patch: Partial) => { + setRoots((prev) => prev.map((root, i) => (i === index ? { ...root, ...patch } : root))) + } + + return { + name, + type, + roots, + setName, + setType, + updateRoot, + addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]), + removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))), + handleCreate, + } +} + +function useNewLibraryRootDrafts(refresh: () => Promise) { + const [newRootByLibrary, setNewRootByLibrary] = useState>({}) + const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft() + + const setNewRootDraft = (libraryID: string, patch: Partial) => { + setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: { ...newRootDraft(libraryID), ...patch } })) + } + + const addLibraryRoot = async (libraryID: string) => { + const draft = newRootDraft(libraryID) + if (!draft.path?.trim()) { + toast.error('请填写路径') + return + } + await libraryAPI.addRoot(libraryID, { ...draft, path: draft.path.trim(), name: draft.name?.trim() }) + setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: emptyRootDraft() })) + toast.success('路径已添加') + await refresh() + } + + return { newRootDraft, setNewRootDraft, addLibraryRoot } +} + +function useEditableRootDrafts() { + const [rootDrafts, setRootDrafts] = useState>({}) + + const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => { + const key = rootDraftKey(libraryID, root.id) + return rootDrafts[key] ?? { + name: root.name ?? '', + path: root.path, + enabled: root.enabled, + sort_order: root.sort_order, + } + } + + const setEditableRootDraft = (libraryID: string, root: LibraryRoot, patch: Partial) => { + const key = rootDraftKey(libraryID, root.id) + setRootDrafts((prev) => ({ ...prev, [key]: { ...editableRootDraft(libraryID, root), ...patch } })) + } + + const clearEditableRootDraft = (libraryID: string, rootID: string) => { + setRootDrafts((prev) => { + const next = { ...prev } + delete next[rootDraftKey(libraryID, rootID)] + return next + }) + } + + return { editableRootDraft, setEditableRootDraft, clearEditableRootDraft } +} + +type EditableRootDrafts = ReturnType + +function useEditableLibraryRootActions(refresh: () => Promise, drafts: EditableRootDrafts) { + const saveLibraryRoot = async (libraryID: string, root: LibraryRoot) => { + const draft = drafts.editableRootDraft(libraryID, root) + if (!draft.path?.trim()) { + toast.error('请填写路径') + return + } + await libraryAPI.updateRoot(libraryID, root.id, { + name: draft.name?.trim(), + path: draft.path.trim(), + enabled: draft.enabled, + sort_order: draft.sort_order, + }) + drafts.clearEditableRootDraft(libraryID, root.id) + toast.success('路径已保存') + await refresh() + } + + const scanLibraryRoot = async (libraryID: string, root: LibraryRoot) => { + if (!root.id) return + await libraryAPI.scanRoot(libraryID, root.id) + toast.success('路径扫描已加入后台任务') + } + + const toggleLibraryRoot = async (libraryID: string, root: LibraryRoot) => { + const enabled = !drafts.editableRootDraft(libraryID, root).enabled + drafts.setEditableRootDraft(libraryID, root, { enabled }) + await libraryAPI.updateRoot(libraryID, root.id, { enabled }) + await refresh() + } + + const removeLibraryRoot = async (library: Library, root: LibraryRoot) => { + if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${root.path}」?`, confirmText: '删除' }))) return + await libraryAPI.removeRoot(library.id, root.id) + toast.success('路径已删除') + await refresh() + } + + return { saveLibraryRoot, scanLibraryRoot, toggleLibraryRoot, removeLibraryRoot } +} + +function useLibraryActions(refresh: () => Promise) { + const scanLibrary = async (library: Library) => { + const result = await libraryAPI.scan(library.id) + if (result.queued) toast.success('云盘扫描已加入后台队列,会自动入库') + else toast.success(`扫描完成,新增 ${result.added},更新 ${result.updated ?? 0}`) + } + + const removeLibrary = async (library: Library) => { + if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${library.name}」?`, confirmText: '删除' }))) return + await libraryAPI.remove(library.id) + toast.success('已删除') + await refresh() + } + + return { scanLibrary, removeLibrary } +}