mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
优化
优化
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">{title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
|
||||
{list?.roots ? (
|
||||
<span className="text-ink-50">选择盘符 / 根目录</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-brand-500"
|
||||
onClick={() => load('')}
|
||||
>
|
||||
盘符 / 根
|
||||
</button>
|
||||
<ChevronRight size={12} />
|
||||
<span className="truncate text-ink-50" title={list?.current}>
|
||||
{list?.current}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[260px] flex-1 overflow-y-auto p-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : !list || list.children.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-sand-500">该目录下没有子目录</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(list.parent ?? '') !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm text-ink-50 transition hover:bg-gray-50"
|
||||
onClick={() => load(list.parent ?? '')}
|
||||
>
|
||||
<FolderPlus size={16} className="text-sand-400" />
|
||||
<span>..(上级目录)</span>
|
||||
</button>
|
||||
)}
|
||||
{list.children.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||
onClick={() => load(entry.path)}
|
||||
>
|
||||
<Folder size={16} className="text-brand-400" />
|
||||
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
|
||||
<span className="text-xs text-sand-500">单击目录进入下一级,点击「选择当前目录」完成选择</span>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button"
|
||||
disabled={!selectable || loading}
|
||||
onClick={() => {
|
||||
if (list?.current) onSelect(list.current)
|
||||
}}
|
||||
>
|
||||
选择当前目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<number | null>(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<RootDraft> = { path: selectedPath }
|
||||
if (!currentRoot?.name?.trim() && baseName) {
|
||||
patch.name = baseName
|
||||
}
|
||||
onRootChange(browsingIndex, patch)
|
||||
setBrowsingIndex(null)
|
||||
}
|
||||
|
||||
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>
|
||||
<input
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="自定义封面 URL(可选)"
|
||||
value={coverURL}
|
||||
onChange={(e) => onCoverURLChange(e.target.value)}
|
||||
/>
|
||||
<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} /> 添加路径
|
||||
<>
|
||||
<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>
|
||||
<input
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="自定义封面 URL(可选)"
|
||||
value={coverURL}
|
||||
onChange={(e) => onCoverURLChange(e.target.value)}
|
||||
/>
|
||||
<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}
|
||||
onBrowse={() => setBrowsingIndex(index)}
|
||||
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">
|
||||
支持直接点选或手动输入;名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库。
|
||||
</p>
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建 / 追加路径
|
||||
</button>
|
||||
</div>
|
||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||
名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库;Docker
|
||||
部署请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧。
|
||||
</p>
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建 / 追加路径
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
{browsingIndex !== null && (
|
||||
<LocalDirBrowserDialog
|
||||
initialDir={roots[browsingIndex]?.path || undefined}
|
||||
title="选择媒体库目录"
|
||||
onSelect={handleSelectDir}
|
||||
onClose={() => setBrowsingIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,25 +110,35 @@ type CreateRootRowProps = {
|
||||
index: number
|
||||
canRemove: boolean
|
||||
onChange: (index: number, patch: Partial<RootDraft>) => 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 (
|
||||
<div className="grid gap-2 rounded-xl border border-gray-200/80 bg-white/60 p-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
|
||||
<div className="grid gap-2 rounded-xl border border-gray-200/80 bg-white/60 p-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto_auto]">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="路径名称"
|
||||
placeholder="路径名称(如 国产剧)"
|
||||
value={root.name ?? ''}
|
||||
onChange={(e) => onChange(index, { name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
required={index === 0}
|
||||
className="input-base"
|
||||
placeholder="容器路径,如 /media/电视剧/国产剧"
|
||||
placeholder="本地或容器目录,如 /media/电视剧/国产剧"
|
||||
value={root.path}
|
||||
onChange={(e) => onChange(index, { path: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
title="点选浏览本地目录"
|
||||
>
|
||||
<Folder size={14} className="text-brand-400" />
|
||||
浏览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-red-400/40 px-3 text-red-400 hover:bg-red-400/10 disabled:opacity-40"
|
||||
@@ -114,3 +151,4 @@ function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRoo
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { Folder, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
import type { RootDraft } from './adminLibraryPanelModel'
|
||||
import { displayLibraryRootName, displayLibraryRootPath, fallbackLibraryRoot } from './adminLibraryPanelModel'
|
||||
@@ -15,34 +16,80 @@ type LibraryTableProps = {
|
||||
onRemoveRoot: (library: Library, root: LibraryRoot) => void
|
||||
onScanLibrary: (library: Library) => void
|
||||
onRemoveLibrary: (library: Library) => void
|
||||
onAddLibraryRoot: (library: Library) => void
|
||||
onAddLibraryRoot: (library: Library, path?: string, name?: string) => void
|
||||
onEditLibraryCover: (library: Library) => void
|
||||
}
|
||||
|
||||
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
|
||||
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
|
||||
|
||||
const handleSelectRootPath = (selectedPath: string) => {
|
||||
if (browsingRoot) {
|
||||
actions.onEditableRootChange(browsingRoot.libraryID, browsingRoot.root, { path: selectedPath })
|
||||
setBrowsingRoot(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAddRoot = (selectedPath: string) => {
|
||||
if (addingRootLib) {
|
||||
const segments = selectedPath.split(/[/\\]/).filter(Boolean)
|
||||
const baseName = segments.length > 0 ? segments[segments.length - 1] : ''
|
||||
actions.onAddLibraryRoot(addingRootLib, selectedPath, baseName)
|
||||
setAddingRootLib(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-28 py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th className="w-20">类型</th>
|
||||
<th className="w-12 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libs.map((library) => (
|
||||
<LibraryTableRow key={library.id} library={library} {...actions} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-28 py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th className="w-20">类型</th>
|
||||
<th className="w-12 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libs.map((library) => (
|
||||
<LibraryTableRow
|
||||
key={library.id}
|
||||
library={library}
|
||||
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
|
||||
onOpenAddRoot={() => setAddingRootLib(library)}
|
||||
{...actions}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{browsingRoot && (
|
||||
<LocalDirBrowserDialog
|
||||
initialDir={browsingRoot.initialPath || undefined}
|
||||
title="选择媒体库路径"
|
||||
onSelect={handleSelectRootPath}
|
||||
onClose={() => setBrowsingRoot(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{addingRootLib && (
|
||||
<LocalDirBrowserDialog
|
||||
title={`为「${addingRootLib.name}」选择来源目录`}
|
||||
onSelect={handleSelectAddRoot}
|
||||
onClose={() => setAddingRootLib(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
||||
library: Library
|
||||
onBrowseRoot: (root: LibraryRoot) => void
|
||||
onOpenAddRoot: () => void
|
||||
}
|
||||
|
||||
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
@@ -103,7 +150,7 @@ function ReadonlyRootFields({ root }: { root: LibraryRoot }) {
|
||||
)
|
||||
}
|
||||
|
||||
function EditableRootFields({ library, root, draft, onEditableRootChange }: RootEditorProps & { draft: RootDraft }) {
|
||||
function EditableRootFields({ library, root, draft, onEditableRootChange, onBrowseRoot }: RootEditorProps & { draft: RootDraft }) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
@@ -112,12 +159,23 @@ function EditableRootFields({ library, root, draft, onEditableRootChange }: Root
|
||||
value={draft.name ?? ''}
|
||||
onChange={(e) => onEditableRootChange(library.id, root, { name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white/80 px-3 text-xs text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
|
||||
placeholder="真实路径"
|
||||
value={draft.path}
|
||||
onChange={(e) => onEditableRootChange(library.id, root, { path: e.target.value })}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="h-9 flex-1 min-w-0 rounded-lg border border-gray-200 bg-white/80 px-3 text-xs text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
|
||||
placeholder="真实路径"
|
||||
value={draft.path}
|
||||
onChange={(e) => onEditableRootChange(library.id, root, { path: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBrowseRoot(root)}
|
||||
className="inline-flex h-9 items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
title="点选浏览本地目录"
|
||||
>
|
||||
<Folder size={13} className="text-brand-400" />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<ActionMenu label="媒体库操作">
|
||||
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
|
||||
扫描
|
||||
</MenuButton>
|
||||
<MenuButton icon={<Plus size={14} />} label="添加来源" onClick={() => onAddLibraryRoot(library)}>
|
||||
<MenuButton icon={<Plus size={14} />} label="添加来源" onClick={onOpenAddRoot}>
|
||||
添加来源
|
||||
</MenuButton>
|
||||
<MenuButton icon={<Image size={14} />} label="自定义封面" onClick={() => onEditLibraryCover(library)}>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">选择本地目录</h3>
|
||||
<button type="button" onClick={onClose} className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
|
||||
{list?.roots ? (
|
||||
<span className="text-ink-50">{'选择盘符 / 根目录'}</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-brand-500"
|
||||
onClick={() => load('')}
|
||||
>
|
||||
盘符 / 根
|
||||
</button>
|
||||
<ChevronRight size={12} />
|
||||
<span className="truncate text-ink-50" title={list?.current}>
|
||||
{list?.current}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[260px] flex-1 overflow-y-auto p-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : !list || list.children.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-sand-500">该目录下没有子目录</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(list.parent ?? '') !== '' && (!list.roots || true) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm text-ink-50 transition hover:bg-gray-50"
|
||||
onClick={() => load(list.parent ?? '')}
|
||||
>
|
||||
<FolderPlus size={16} className="text-sand-400" />
|
||||
<span>..(上级目录)</span>
|
||||
</button>
|
||||
)}
|
||||
{list.children.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||
onClick={() => load(entry.path)}
|
||||
>
|
||||
<FolderPlus size={16} className="text-brand-400" />
|
||||
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
|
||||
<span className="text-xs text-sand-500">单击目录进入下一级,最后点击「选择此目录」回填路径</span>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button"
|
||||
disabled={!selectable || loading}
|
||||
onClick={() => {
|
||||
if (list?.current) onSelect(list.current)
|
||||
}}
|
||||
>
|
||||
选择当前目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 远端目录浏览选择器 ───────────────────────────────────────────────────────
|
||||
|
||||
function StrmDirBrowserDialog({
|
||||
|
||||
@@ -157,11 +157,14 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user