From 8b0260e0e1c27dc46cf83d8245854a1e6a5cf5da Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:11:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化 --- web/src/components/LocalDirBrowserDialog.tsx | 145 +++++++++++++++++++ web/src/pages/AdminLibraryPanelSections.tsx | 136 ++++++++++------- web/src/pages/AdminLibraryTable.tsx | 116 +++++++++++---- web/src/pages/StrmDialogs.tsx | 121 +--------------- web/src/pages/useAdminLibraryPanel.ts | 13 +- 5 files changed, 328 insertions(+), 203 deletions(-) create mode 100644 web/src/components/LocalDirBrowserDialog.tsx diff --git a/web/src/components/LocalDirBrowserDialog.tsx b/web/src/components/LocalDirBrowserDialog.tsx new file mode 100644 index 0000000..8146852 --- /dev/null +++ b/web/src/components/LocalDirBrowserDialog.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react' +import { ChevronRight, Folder, FolderPlus, Loader2, X } from 'lucide-react' +import toast from 'react-hot-toast' + +import { strmAPI } from '../api/strm' + +function apiErrorMessage(err: unknown): string { + if (typeof err === 'object' && err !== null && 'response' in err) { + const res = (err as { response?: { data?: { error?: string; message?: string } } }).response + if (res?.data?.error) return res.data.error + if (res?.data?.message) return res.data.message + } + if (err instanceof Error) return err.message + return '请求失败' +} + +export function LocalDirBrowserDialog({ + initialDir, + title = '选择本地目录', + onSelect, + onClose, +}: { + initialDir?: string + title?: string + onSelect: (path: string) => void + onClose: () => void +}) { + const [list, setList] = useState<{ + roots: boolean + parent?: string + current?: string + children: { name: string; path: string }[] + } | null>(null) + const [loading, setLoading] = useState(true) + + const load = async (target: string) => { + setLoading(true) + try { + const data = await strmAPI.listLocalDirs(target) + setList(data) + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load(initialDir ?? '').catch(() => undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const selectable = list && !list.roots && list.current + + return ( +
+
event.stopPropagation()} + > +
+

{title}

+ +
+
+ {list?.roots ? ( + 选择盘符 / 根目录 + ) : ( + <> + + + + {list?.current} + + + )} +
+
+ {loading ? ( +
+ +
+ ) : !list || list.children.length === 0 ? ( +

该目录下没有子目录

+ ) : ( +
+ {(list.parent ?? '') !== '' && ( + + )} + {list.children.map((entry) => ( + + ))} +
+ )} +
+
+ 单击目录进入下一级,点击「选择当前目录」完成选择 + +
+
+
+ ) +} diff --git a/web/src/pages/AdminLibraryPanelSections.tsx b/web/src/pages/AdminLibraryPanelSections.tsx index a3b3576..1833c3d 100644 --- a/web/src/pages/AdminLibraryPanelSections.tsx +++ b/web/src/pages/AdminLibraryPanelSections.tsx @@ -1,6 +1,7 @@ -import { FormEvent } from 'react' -import { Plus, Trash2 } from 'lucide-react' +import { FormEvent, useState } from 'react' +import { Folder, Plus, Trash2 } from 'lucide-react' +import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import type { RootDraft } from './adminLibraryPanelModel' type CreateFormProps = { @@ -30,51 +31,77 @@ export function AdminLibraryCreateForm({ onRemoveRoot, onSubmit, }: CreateFormProps) { + const [browsingIndex, setBrowsingIndex] = useState(null) + + const handleSelectDir = (selectedPath: string) => { + if (browsingIndex === null) return + const currentRoot = roots[browsingIndex] + const segments = selectedPath.split(/[/\\]/).filter(Boolean) + const baseName = segments.length > 0 ? segments[segments.length - 1] : '' + const patch: Partial = { path: selectedPath } + if (!currentRoot?.name?.trim() && baseName) { + patch.name = baseName + } + onRootChange(browsingIndex, patch) + setBrowsingIndex(null) + } + return ( -
- onNameChange(e.target.value)} - /> - - onCoverURLChange(e.target.value)} - /> -
- {roots.map((root, index) => ( - 1} - onChange={onRootChange} - onRemove={onRemoveRoot} - /> - ))} - +
+

+ 支持直接点选或手动输入;名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库。 +

+ - -

- 名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库;Docker - 部署请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧。 -

- -
+ + + {browsingIndex !== null && ( + setBrowsingIndex(null)} + /> + )} + ) } @@ -83,25 +110,35 @@ type CreateRootRowProps = { index: number canRemove: boolean onChange: (index: number, patch: Partial) => void + onBrowse: () => void onRemove: (index: number) => void } -function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) { +function CreateRootRow({ root, index, canRemove, onChange, onBrowse, onRemove }: CreateRootRowProps) { return ( -
+
onChange(index, { name: e.target.value })} /> onChange(index, { path: e.target.value })} /> + +
) } @@ -177,13 +235,13 @@ function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps ) } -function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onAddLibraryRoot, onEditLibraryCover }: LibraryTableRowProps) { +function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onOpenAddRoot, onEditLibraryCover }: LibraryTableRowProps) { return ( } label="扫描" onClick={() => onScanLibrary(library)}> 扫描 - } label="添加来源" onClick={() => onAddLibraryRoot(library)}> + } label="添加来源" onClick={onOpenAddRoot}> 添加来源 } label="自定义封面" onClick={() => onEditLibraryCover(library)}> diff --git a/web/src/pages/StrmDialogs.tsx b/web/src/pages/StrmDialogs.tsx index a1ccbb8..d165377 100644 --- a/web/src/pages/StrmDialogs.tsx +++ b/web/src/pages/StrmDialogs.tsx @@ -21,6 +21,7 @@ import { SettingRow } from './SettingsRow' import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm' import { STRM_PROVIDER_LABELS } from '../types/strm' import { apiErrorMessage } from './StrmManagePage' +import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' // ─── 弹框外壳 ──────────────────────────────────────────────────────────────── @@ -903,126 +904,6 @@ function ToggleRow({ label, checked, onChange }: { label: string; checked: boole ) } -// ─── 本地目录浏览选择器(Windows 盘符 / Linux 根目录兼容) ───────────────────── - -function LocalDirBrowserDialog({ - initialDir, - onSelect, - onClose, -}: { - initialDir?: string - onSelect: (path: string) => void - onClose: () => void -}) { - const [list, setList] = useState<{ roots: boolean; parent?: string; current?: string; children: { name: string; path: string }[] } | null>(null) - const [loading, setLoading] = useState(true) - - const load = async (target: string) => { - setLoading(true) - try { - const data = await strmAPI.listLocalDirs(target) - setList(data) - } catch (err) { - toast.error(apiErrorMessage(err)) - } finally { - setLoading(false) - } - } - - useEffect(() => { - load(initialDir ?? '').catch(() => undefined) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const selectable = list && !list.roots && list.current - - return ( -
-
event.stopPropagation()} - > -
-

选择本地目录

- -
-
- {list?.roots ? ( - {'选择盘符 / 根目录'} - ) : ( - <> - - - - {list?.current} - - - )} -
-
- {loading ? ( -
- -
- ) : !list || list.children.length === 0 ? ( -

该目录下没有子目录

- ) : ( -
- {(list.parent ?? '') !== '' && (!list.roots || true) && ( - - )} - {list.children.map((entry) => ( - - ))} -
- )} -
-
- 单击目录进入下一级,最后点击「选择此目录」回填路径 - -
-
-
- ) -} - // ─── 远端目录浏览选择器 ─────────────────────────────────────────────────────── function StrmDirBrowserDialog({ diff --git a/web/src/pages/useAdminLibraryPanel.ts b/web/src/pages/useAdminLibraryPanel.ts index 38ae408..b46c695 100644 --- a/web/src/pages/useAdminLibraryPanel.ts +++ b/web/src/pages/useAdminLibraryPanel.ts @@ -157,11 +157,14 @@ function useLibraryActions(refresh: () => Promise) { await refresh() } - const addLibraryRoot = async (library: Library) => { - const path = window.prompt(`为「${library.name}」添加来源目录:`) - if (!path?.trim()) return - const name = window.prompt('来源名称(可选):') ?? '' - await libraryAPI.addRoot(library.id, { path: path.trim(), name: name.trim(), enabled: true }) + const addLibraryRoot = async (library: Library, path?: string, name?: string) => { + if (!path) { + const p = window.prompt(`为「${library.name}」添加来源目录:`) + if (!p?.trim()) return + path = p.trim() + name = (window.prompt('来源名称(可选):') ?? '').trim() + } + await libraryAPI.addRoot(library.id, { path: path.trim(), name: (name ?? '').trim(), enabled: true }) toast.success('来源目录已添加') await refresh() }