mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
split admin library panel
This commit is contained in:
@@ -1,322 +1,36 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { AdminLibraryCreateForm, AdminLibraryTable } from './AdminLibraryPanelSections'
|
||||||
import toast from 'react-hot-toast'
|
import { useAdminLibraryPanel } from './useAdminLibraryPanel'
|
||||||
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 })
|
|
||||||
|
|
||||||
export function AdminLibraryPanel() {
|
export function AdminLibraryPanel() {
|
||||||
const [libs, setLibs] = useState<Library[]>([])
|
const { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
|
|
||||||
const [type, setType] = useState('movie')
|
|
||||||
const [newRootByLibrary, setNewRootByLibrary] = useState<Record<string, RootDraft>>({})
|
|
||||||
const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({})
|
|
||||||
|
|
||||||
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<RootDraft>) => {
|
|
||||||
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<RootDraft>) => {
|
|
||||||
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<RootDraft>) => {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
|
<AdminLibraryCreateForm
|
||||||
<input
|
name={createForm.name}
|
||||||
required
|
type={createForm.type}
|
||||||
className="input-base"
|
roots={createForm.roots}
|
||||||
placeholder="名称"
|
onNameChange={createForm.setName}
|
||||||
value={name}
|
onTypeChange={createForm.setType}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onRootChange={createForm.updateRoot}
|
||||||
/>
|
onAddRoot={createForm.addRoot}
|
||||||
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
|
onRemoveRoot={createForm.removeRoot}
|
||||||
<option value="movie">电影</option>
|
onSubmit={createForm.handleCreate}
|
||||||
<option value="tv">电视剧</option>
|
/>
|
||||||
<option value="variety">综艺</option>
|
<AdminLibraryTable
|
||||||
<option value="anime">动漫</option>
|
libs={libs}
|
||||||
<option value="music">音乐</option>
|
newRootDraft={newRoots.newRootDraft}
|
||||||
</select>
|
editableRootDraft={editableRoots.editableRootDraft}
|
||||||
<div className="md:col-span-4 space-y-2">
|
onNewRootChange={newRoots.setNewRootDraft}
|
||||||
{roots.map((root, index) => (
|
onEditableRootChange={editableRoots.setEditableRootDraft}
|
||||||
<div key={index} className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
onAddRoot={newRoots.addLibraryRoot}
|
||||||
<input
|
onSaveRoot={rootActions.saveLibraryRoot}
|
||||||
className="input-base"
|
onScanRoot={rootActions.scanLibraryRoot}
|
||||||
placeholder="路径名称"
|
onToggleRoot={rootActions.toggleLibraryRoot}
|
||||||
value={root.name ?? ''}
|
onRemoveRoot={rootActions.removeLibraryRoot}
|
||||||
onChange={(e) => updateCreateRoot(index, { name: e.target.value })}
|
onScanLibrary={libraryActions.scanLibrary}
|
||||||
/>
|
onRemoveLibrary={libraryActions.removeLibrary}
|
||||||
<input
|
/>
|
||||||
required={index === 0}
|
|
||||||
className="input-base"
|
|
||||||
placeholder="容器路径,如 /media/电视剧/国产剧"
|
|
||||||
value={root.path}
|
|
||||||
onChange={(e) => updateCreateRoot(index, { path: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg border border-red-400/40 px-3 text-red-400 hover:bg-red-400/10 disabled:opacity-40"
|
|
||||||
disabled={roots.length <= 1}
|
|
||||||
onClick={() => removeCreateRoot(index)}
|
|
||||||
title="删除路径"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={addCreateRoot}>
|
|
||||||
<Plus size={16} /> 添加路径
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
|
||||||
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
|
|
||||||
宿主机路径,系统会尝试按 compose 挂载自动转换。
|
|
||||||
</p>
|
|
||||||
<button type="submit" className="neon-button md:col-span-4">
|
|
||||||
新建媒体库
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="glass-panel">
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2">名称</th>
|
|
||||||
<th>路径</th>
|
|
||||||
<th>类型</th>
|
|
||||||
<th className="text-right">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{libs.map((l) => (
|
|
||||||
<tr key={l.id} className="border-t border-gray-200">
|
|
||||||
<td className="py-2 text-ink-600">{l.name}</td>
|
|
||||||
<td className="text-ink-100">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(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) => (
|
|
||||||
<div key={root.id || root.path} className="rounded border border-gray-200/70 p-2">
|
|
||||||
<div className="grid gap-2 xl:grid-cols-[minmax(120px,0.8fr)_minmax(220px,2fr)_auto]">
|
|
||||||
{root.id ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
className="input-base"
|
|
||||||
placeholder="路径名称"
|
|
||||||
value={editableRootDraft(l.id, root).name ?? ''}
|
|
||||||
onChange={(e) => setEditableRootDraft(l.id, root, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input-base"
|
|
||||||
placeholder="真实路径"
|
|
||||||
value={editableRootDraft(l.id, root).path}
|
|
||||||
onChange={(e) => setEditableRootDraft(l.id, root, { path: e.target.value })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="min-w-0 break-all xl:col-span-2">{root.name ? `${root.name}:${root.path}` : root.path}</span>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{root.id && (
|
|
||||||
<button
|
|
||||||
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
|
|
||||||
title="保存路径"
|
|
||||||
onClick={() => saveLibraryRoot(l.id, root)}
|
|
||||||
>
|
|
||||||
<Save size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
|
|
||||||
title="扫描路径"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!root.id) return
|
|
||||||
await libraryAPI.scanRoot(l.id, root.id)
|
|
||||||
toast.success('路径扫描已加入后台任务')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
</button>
|
|
||||||
{root.id && (
|
|
||||||
<button
|
|
||||||
className="rounded border border-gray-300 px-2 py-1 text-xs"
|
|
||||||
onClick={async () => {
|
|
||||||
const enabled = !editableRootDraft(l.id, root).enabled
|
|
||||||
setEditableRootDraft(l.id, root, { enabled })
|
|
||||||
await libraryAPI.updateRoot(l.id, root.id, { enabled })
|
|
||||||
await refresh()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{editableRootDraft(l.id, root).enabled ? '启用' : '禁用'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{root.id && (
|
|
||||||
<button
|
|
||||||
className="rounded border border-red-400/40 p-1 text-red-400 hover:bg-red-400/10"
|
|
||||||
title="删除路径"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${root.path}」?`, confirmText: '删除' }))) return
|
|
||||||
await libraryAPI.removeRoot(l.id, root.id)
|
|
||||||
toast.success('路径已删除')
|
|
||||||
await refresh()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
|
||||||
<input
|
|
||||||
className="input-base"
|
|
||||||
placeholder="路径名称"
|
|
||||||
value={newRootDraft(l.id).name ?? ''}
|
|
||||||
onChange={(e) => setNewRootDraft(l.id, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input-base"
|
|
||||||
placeholder="新增路径"
|
|
||||||
value={newRootDraft(l.id).path}
|
|
||||||
onChange={(e) => setNewRootDraft(l.id, { path: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => addLibraryRoot(l.id)}>
|
|
||||||
<Plus size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="text-ink-100">{l.type}</td>
|
|
||||||
<td className="space-x-2 py-2 text-right">
|
|
||||||
<button
|
|
||||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
|
||||||
onClick={async () => {
|
|
||||||
const r = await libraryAPI.scan(l.id)
|
|
||||||
if (r.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
|
|
||||||
else toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
扫描
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${l.name}」?`, confirmText: '删除' }))) return
|
|
||||||
await libraryAPI.remove(l.id)
|
|
||||||
toast.success('已删除')
|
|
||||||
await refresh()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<RootDraft>) => 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 (
|
||||||
|
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="名称"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select className="input-base" value={type} onChange={(e) => onTypeChange(e.target.value)}>
|
||||||
|
<option value="movie">电影</option>
|
||||||
|
<option value="tv">电视剧</option>
|
||||||
|
<option value="variety">综艺</option>
|
||||||
|
<option value="anime">动漫</option>
|
||||||
|
<option value="music">音乐</option>
|
||||||
|
</select>
|
||||||
|
<div className="md:col-span-4 space-y-2">
|
||||||
|
{roots.map((root, index) => (
|
||||||
|
<CreateRootRow
|
||||||
|
key={index}
|
||||||
|
root={root}
|
||||||
|
index={index}
|
||||||
|
canRemove={roots.length > 1}
|
||||||
|
onChange={onRootChange}
|
||||||
|
onRemove={onRemoveRoot}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={onAddRoot}>
|
||||||
|
<Plus size={16} /> 添加路径
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||||
|
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
|
||||||
|
宿主机路径,系统会尝试按 compose 挂载自动转换。
|
||||||
|
</p>
|
||||||
|
<button type="submit" className="neon-button md:col-span-4">
|
||||||
|
新建媒体库
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRootRowProps = {
|
||||||
|
root: RootDraft
|
||||||
|
index: number
|
||||||
|
canRemove: boolean
|
||||||
|
onChange: (index: number, patch: Partial<RootDraft>) => void
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="路径名称"
|
||||||
|
value={root.name ?? ''}
|
||||||
|
onChange={(e) => onChange(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required={index === 0}
|
||||||
|
className="input-base"
|
||||||
|
placeholder="容器路径,如 /media/电视剧/国产剧"
|
||||||
|
value={root.path}
|
||||||
|
onChange={(e) => onChange(index, { path: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg border border-red-400/40 px-3 text-red-400 hover:bg-red-400/10 disabled:opacity-40"
|
||||||
|
disabled={!canRemove}
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
title="删除路径"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryTableProps = {
|
||||||
|
libs: Library[]
|
||||||
|
newRootDraft: (libraryID: string) => RootDraft
|
||||||
|
editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft
|
||||||
|
onNewRootChange: (libraryID: string, patch: Partial<RootDraft>) => void
|
||||||
|
onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => 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 (
|
||||||
|
<div className="glass-panel">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2">名称</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th className="text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{libs.map((library) => (
|
||||||
|
<LibraryTableRow key={library.id} library={library} {...actions} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
||||||
|
library: Library
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-gray-200">
|
||||||
|
<td className="py-2 text-ink-600">{library.name}</td>
|
||||||
|
<td className="text-ink-100">
|
||||||
|
<LibraryRootsCell library={library} {...actions} />
|
||||||
|
</td>
|
||||||
|
<td className="text-ink-100">{library.type}</td>
|
||||||
|
<td className="space-x-2 py-2 text-right">
|
||||||
|
<LibraryActionsCell library={library} {...actions} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
|
||||||
|
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{roots.map((root) => (
|
||||||
|
<ExistingRootEditor key={root.id || root.path} library={library} root={root} {...actions} />
|
||||||
|
))}
|
||||||
|
<AddRootRow library={library} {...actions} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RootEditorProps = Omit<LibraryTableRowProps, 'library'> & {
|
||||||
|
library: Library
|
||||||
|
root: LibraryRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) {
|
||||||
|
const draft = actions.editableRootDraft(library.id, root)
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-gray-200/70 p-2">
|
||||||
|
<div className="grid gap-2 xl:grid-cols-[minmax(120px,0.8fr)_minmax(220px,2fr)_auto]">
|
||||||
|
{root.id ? (
|
||||||
|
<EditableRootFields library={library} root={root} draft={draft} {...actions} />
|
||||||
|
) : (
|
||||||
|
<span className="min-w-0 break-all xl:col-span-2">{root.name ? `${root.name}:${root.path}` : root.path}</span>
|
||||||
|
)}
|
||||||
|
<RootActionButtons library={library} root={root} draft={draft} {...actions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableRootFields({ library, root, draft, onEditableRootChange }: RootEditorProps & { draft: RootDraft }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="路径名称"
|
||||||
|
value={draft.name ?? ''}
|
||||||
|
onChange={(e) => onEditableRootChange(library.id, root, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="真实路径"
|
||||||
|
value={draft.path}
|
||||||
|
onChange={(e) => onEditableRootChange(library.id, root, { path: e.target.value })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{root.id && (
|
||||||
|
<button
|
||||||
|
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
|
||||||
|
title="保存路径"
|
||||||
|
onClick={() => actions.onSaveRoot(library.id, root)}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
|
||||||
|
title="扫描路径"
|
||||||
|
onClick={() => actions.onScanRoot(library.id, root)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{root.id && (
|
||||||
|
<button className="rounded border border-gray-300 px-2 py-1 text-xs" onClick={() => actions.onToggleRoot(library.id, root)}>
|
||||||
|
{draft.enabled ? '启用' : '禁用'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{root.id && (
|
||||||
|
<button
|
||||||
|
className="rounded border border-red-400/40 p-1 text-red-400 hover:bg-red-400/10"
|
||||||
|
title="删除路径"
|
||||||
|
onClick={() => actions.onRemoveRoot(library, root)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddRootRow({ library, ...actions }: LibraryTableRowProps) {
|
||||||
|
const draft = actions.newRootDraft(library.id)
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="路径名称"
|
||||||
|
value={draft.name ?? ''}
|
||||||
|
onChange={(e) => actions.onNewRootChange(library.id, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="新增路径"
|
||||||
|
value={draft.path}
|
||||||
|
onChange={(e) => actions.onNewRootChange(library.id, { path: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => actions.onAddRoot(library.id)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||||
|
onClick={() => onScanLibrary(library)}
|
||||||
|
>
|
||||||
|
扫描
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
|
onClick={() => onRemoveLibrary(library)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Library[]>([])
|
||||||
|
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { libs, refresh }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [roots, setRoots] = useState<RootDraft[]>([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<RootDraft>) => {
|
||||||
|
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<void>) {
|
||||||
|
const [newRootByLibrary, setNewRootByLibrary] = useState<Record<string, RootDraft>>({})
|
||||||
|
const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft()
|
||||||
|
|
||||||
|
const setNewRootDraft = (libraryID: string, patch: Partial<RootDraft>) => {
|
||||||
|
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<Record<string, RootDraft>>({})
|
||||||
|
|
||||||
|
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<RootDraft>) => {
|
||||||
|
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<typeof useEditableRootDrafts>
|
||||||
|
|
||||||
|
function useEditableLibraryRootActions(refresh: () => Promise<void>, 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<void>) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user