fix: improve media file organization workflow

This commit is contained in:
ShukeBta
2026-06-09 13:57:34 +08:00
parent 76d6e0c8ff
commit fe0e7a09ce
28 changed files with 2054 additions and 859 deletions
+1 -8
View File
@@ -81,9 +81,6 @@ const AIAssistantPage = lazy(() =>
const StrmPage = lazy(() =>
import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })),
)
const ToolsPage = lazy(() =>
import('./pages/ToolsPage').then((m) => ({ default: m.ToolsPage })),
)
const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
@@ -210,11 +207,7 @@ export default function App() {
/>
<Route
path="tools"
element={
<RequireAdmin>
<ToolsPage />
</RequireAdmin>
}
element={<Navigate to="/storage" replace />}
/>
<Route
path="notify-channels"
+21 -2
View File
@@ -6,6 +6,7 @@ export interface FileEntry {
is_dir: boolean
size: number
modified: number
ext?: string
}
export interface FileListing {
@@ -16,8 +17,26 @@ export interface FileListing {
}
export const filesAPI = {
list: (path = '', max = 1000) =>
list: (path = '', max = 1000, recursive = false) =>
api
.get<FileListing>('/files', { params: { path, max } })
.get<FileListing>('/files', { params: { path, max, recursive } })
.then((r) => r.data),
createFolder: (path: string, name: string) =>
api.post<{ path: string }>('/files/folders', { path, name }).then((r) => r.data),
rename: (path: string, name: string) =>
api.put<{ path: string }>('/files/rename', { path, name }).then((r) => r.data),
remove: (path: string) =>
api.delete<{ removed: boolean }>('/files', { params: { path } }).then((r) => r.data),
transfer: (sourcePath: string, destPath: string, transferMode = 'copy') =>
api
.post<{ path: string }>('/files/transfer', {
source_path: sourcePath,
dest_path: destPath,
transfer_mode: transferMode,
})
.then((r) => r.data),
}
+32 -1
View File
@@ -10,6 +10,10 @@ export interface OrganizeOverrides {
source_path?: string
dest_path?: string
transfer_mode?: string
media_type?: string
scan_after?: boolean
library_id?: string
dry_run?: boolean
}
// OrganizeSource is a selectable organize source directory (e.g. the download
@@ -45,7 +49,34 @@ export const toolsAPI = {
// into the destination with dedup + 洗版 (resolution replacement).
organizeDirectory: (opts: OrganizeOverrides) =>
api
.post<{ organized: number; skipped: number; replaced?: number; errors?: string[] }>(
.post<{
organized: number
skipped: number
replaced?: number
source_path?: string
dest_path?: string
errors?: string[]
dry_run?: boolean
items?: Array<{
source: string
target?: string
action: string
reason?: string
media_type?: string
category?: string
title?: string
}>
scans?: Array<{
library_id: string
name: string
path: string
visited: number
added: number
updated: number
removed: number
error?: string
}>
}>(
'/admin/organize/source',
opts,
)
+1 -2
View File
@@ -6,7 +6,7 @@ import {
Activity, Bell, Clock, CloudDownload, Compass, Film,
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
ListMusic, LogOut, Rss, Search,
Settings, Sliders, Sparkles, UserCog, Wrench,
Settings, Sliders, Sparkles, UserCog,
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
} from 'lucide-react'
import clsx from 'clsx'
@@ -194,7 +194,6 @@ export function Layout() {
<div className="space-y-1">
<SidebarLink to="/admin" icon={<Settings size={18} />} label="媒体与用户" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点与下载器" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="授权许可" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
+1 -1
View File
@@ -46,7 +46,7 @@ export function AdminPage() {
items={[
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' },
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' },
{ to: '/tools', title: '整理与维护', description: '媒体整理、任务维护和辅助工具' },
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' },
]}
/>
+628 -45
View File
@@ -1,7 +1,28 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronUp, FileVideo, Folder, FolderOpen, Home } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import {
ChevronUp,
Copy,
FileVideo,
Folder,
FolderOpen,
GitBranch,
HardDrive,
Home,
Move,
Pencil,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react'
import { filesAPI, type FileEntry, type FileListing } from '../api/files'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import { schedulerAPI } from '../api/scheduler'
import { toolsAPI } from '../api/tools'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library, Setting } from '../types'
function fmtBytes(n: number): string {
if (!n) return '0 B'
@@ -15,19 +36,107 @@ function fmtBytes(n: number): string {
return `${v.toFixed(1)} ${u[i]}`
}
// FileManagerPage browses the server's filesystem within the allowed
// roots so the operator can pick library paths visually.
function formatScanSummary(scans: Array<{ name: string; added: number; updated: number; visited: number; error?: string }>): string {
if (scans.length === 0) return ' · 未扫描:没有匹配的媒体库'
const ok = scans.filter((scan) => !scan.error)
const added = ok.reduce((sum, scan) => sum + (scan.added ?? 0), 0)
const updated = ok.reduce((sum, scan) => sum + (scan.updated ?? 0), 0)
const visited = ok.reduce((sum, scan) => sum + (scan.visited ?? 0), 0)
return ` · 扫描 ${ok.length}/${scans.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}`
}
type AutoOrganizeConfig = {
enabled: string
afterDownload: string
sourceDir: string
targetDir: string
transferMode: string
intervalSeconds: string
}
const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
enabled: 'false',
afterDownload: 'false',
sourceDir: '',
targetDir: '',
transferMode: 'hardlink',
intervalSeconds: '300',
}
const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
enabled: 'organize.auto',
afterDownload: 'organizer.auto_after_download',
sourceDir: 'organize.source_dir',
targetDir: 'organize.target_dir',
transferMode: 'organize.transfer_mode',
intervalSeconds: 'organize.interval_seconds',
}
function settingIndex(rows: Setting[]): Record<string, string> {
const out: Record<string, string> = {}
for (const row of rows) out[row.key] = row.value
return out
}
function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
const idx = settingIndex(rows)
return {
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
}
}
function settingOn(value: string): boolean {
return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase())
}
// FileManagerPage mirrors MoviePilot's local Storage view at a smaller scope:
// browse allowed roots, optionally recurse, and perform safe local operations.
export function FileManagerPage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [path, setPath] = useState('')
const [data, setData] = useState<FileListing | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [recursive, setRecursive] = useState(false)
const [selected, setSelected] = useState<FileEntry | null>(null)
const [folderName, setFolderName] = useState('')
const [renameTo, setRenameTo] = useState('')
const [destPath, setDestPath] = useState('')
const [transferMode, setTransferMode] = useState('copy')
const [busy, setBusy] = useState('')
const [organizeLibraryID, setOrganizeLibraryID] = useState('')
const [organizeDestPath, setOrganizeDestPath] = useState('')
const [organizeTransferMode, setOrganizeTransferMode] = useState('hardlink')
const [organizeMediaType, setOrganizeMediaType] = useState('auto')
const [scanAfter, setScanAfter] = useState(true)
const [organizeBusy, setOrganizeBusy] = useState('')
const [previewItems, setPreviewItems] = useState<Array<{
source: string
target?: string
action: string
reason?: string
}>>([])
const [autoConfig, setAutoConfig] = useState<AutoOrganizeConfig>(AUTO_ORGANIZE_DEFAULTS)
const [autoDirty, setAutoDirty] = useState(false)
const [autoSaving, setAutoSaving] = useState(false)
const [autoRunning, setAutoRunning] = useState(false)
const [autoLoading, setAutoLoading] = useState(true)
const currentDir = useMemo(() => {
if (data?.path) return data.path
return path
}, [data?.path, path])
const refresh = useCallback(() => {
setLoading(true)
setError('')
filesAPI
.list(path)
.list(path, recursive ? 5000 : 1000, recursive)
.then(setData)
.catch((err: unknown) => {
const msg =
@@ -36,41 +145,235 @@ export function FileManagerPage() {
setError(msg)
})
.finally(() => setLoading(false))
}, [path])
}, [path, recursive])
useEffect(() => {
refresh()
}, [refresh])
useEffect(() => {
libraryAPI.list({ includeHidden: true }).then(setLibraries).catch(() => undefined)
}, [])
const refreshAutoConfig = useCallback(() => {
setAutoLoading(true)
adminAPI
.listSettings()
.then((rows) => {
setAutoConfig(mergeAutoOrganizeSettings(rows))
setAutoDirty(false)
})
.catch(() => undefined)
.finally(() => setAutoLoading(false))
}, [])
useEffect(() => {
refreshAutoConfig()
}, [refreshAutoConfig])
useEffect(() => {
setSelected(null)
setRenameTo('')
}, [path])
useEffect(() => {
const lib = libraries.find((item) => item.id === organizeLibraryID)
if (!lib) return
setOrganizeDestPath(lib.path)
setOrganizeMediaType(lib.type || 'auto')
}, [libraries, organizeLibraryID])
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
setAutoConfig((current) => ({ ...current, [key]: value }))
setAutoDirty(true)
}
const saveAutoConfig = async (): Promise<boolean> => {
setAutoSaving(true)
try {
for (const key of Object.keys(AUTO_ORGANIZE_KEYS) as Array<keyof AutoOrganizeConfig>) {
await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '')
}
setAutoDirty(false)
toast.success('自动整理设置已保存')
return true
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存自动整理设置失败')
return false
} finally {
setAutoSaving(false)
}
}
const runAutoOrganizeNow = async () => {
if (autoDirty) {
const saved = await saveAutoConfig()
if (!saved) return
}
setAutoRunning(true)
try {
await schedulerAPI.run('organize_source')
toast.success('已触发自动整理任务,请稍后刷新媒体库查看入库结果')
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '触发自动整理失败')
} finally {
setAutoRunning(false)
}
}
const enter = (e: FileEntry) => {
if (e.is_dir) setPath(e.path)
}
const choose = (e: FileEntry) => {
setSelected(e)
setRenameTo(e.name)
if (e.is_dir) setDestPath(e.path)
}
const createFolder = async () => {
if (!currentDir || !folderName.trim()) return
setBusy('mkdir')
try {
const res = await filesAPI.createFolder(currentDir, folderName.trim())
toast.success(`已创建目录:${res.path}`)
setFolderName('')
refresh()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '创建失败')
} finally {
setBusy('')
}
}
const renameSelected = async () => {
if (!selected || !renameTo.trim()) return
setBusy('rename')
try {
const res = await filesAPI.rename(selected.path, renameTo.trim())
toast.success(`已重命名:${res.path}`)
setSelected(null)
refresh()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重命名失败')
} finally {
setBusy('')
}
}
const deleteSelected = async () => {
if (!selected) return
const ok = await confirmAction({
title: '确认删除文件',
message: `将删除:${selected.path}。此操作不可恢复,请确认不是媒体库根目录。`,
confirmText: '确认删除',
danger: true,
})
if (!ok) return
setBusy('delete')
try {
await filesAPI.remove(selected.path)
toast.success('已删除')
setSelected(null)
refresh()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败')
} finally {
setBusy('')
}
}
const transferSelected = async () => {
if (!selected || selected.is_dir || !destPath.trim()) return
setBusy('transfer')
try {
const res = await filesAPI.transfer(selected.path, destPath.trim(), transferMode)
toast.success(`已完成转移:${res.path}`)
if (transferMode === 'move') setSelected(null)
refresh()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转移失败')
} finally {
setBusy('')
}
}
const organizeSource = selected?.path || currentDir
const organizeReady = Boolean(organizeSource && organizeDestPath.trim())
const runManualOrganize = async (dryRun: boolean) => {
if (!organizeReady) {
toast.error('请选择来源文件/文件夹,并设置目标媒体库或目的路径')
return
}
if (!dryRun) {
const ok = await confirmAction({
title: '确认整理入库',
message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}`,
confirmText: '开始整理',
})
if (!ok) return
}
setOrganizeBusy(dryRun ? 'preview' : 'run')
try {
const result = await toolsAPI.organizeDirectory({
source_path: organizeSource,
dest_path: organizeDestPath.trim(),
transfer_mode: organizeTransferMode,
media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType,
scan_after: !dryRun && scanAfter,
library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined,
dry_run: dryRun,
})
setPreviewItems(result.items ?? [])
const replaced = result.replaced ?? 0
const total = (result.organized ?? 0) + replaced + (result.skipped ?? 0) + (result.errors?.length ?? 0)
if (total === 0) {
toast(`未发现可整理视频:${result.source_path || organizeSource}`, {
icon: '⚠️',
duration: 6000,
})
return
}
if (dryRun) {
toast.success(`预览完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}`)
return
}
const scanText = scanAfter ? formatScanSummary(result.scans ?? []) : ''
toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}`)
refresh()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '整理失败')
} finally {
setOrganizeBusy('')
}
}
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">文件浏览器</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">文件管理</h1>
<p className="text-sm text-ink-50">
只允许访问已配置的根目录(媒体库 + data + cache)。
像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。
</p>
</header>
<div className="flex flex-wrap gap-2">
<button
className="neon-button !px-3 !py-1 !text-xs"
onClick={() => setPath('')}
title="返回根列表"
>
<div className="flex flex-wrap items-center gap-2">
<button className="neon-button !px-3 !py-1 !text-xs" onClick={() => setPath('')} title="返回根列表">
<Home size={14} /> 根
</button>
{data?.parent && (
<button
className="neon-button !px-3 !py-1 !text-xs"
onClick={() => setPath(data.parent ?? '')}
>
<button className="neon-button !px-3 !py-1 !text-xs" onClick={() => setPath(data.parent ?? '')}>
<ChevronUp size={14} /> 上一级
</button>
)}
<button className="neon-button !px-3 !py-1 !text-xs" onClick={refresh}>
<RefreshCw size={14} /> 刷新
</button>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={recursive} onChange={(e) => setRecursive(e.target.checked)} />
递归扫描
</label>
{data?.path && (
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 font-mono text-xs text-ink-100">
{data.path}
@@ -78,17 +381,302 @@ export function FileManagerPage() {
)}
</div>
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">自动整理设置</h2>
<p className="text-xs text-sand-500">
设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={autoLoading || autoSaving}
onClick={refreshAutoConfig}
>
<RefreshCw size={14} /> 重新读取
</button>
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={autoLoading || autoSaving || !autoDirty}
onClick={() => void saveAutoConfig()}
>
{autoSaving ? '保存中…' : '保存设置'}
</button>
<button
type="button"
className="neon-button"
disabled={autoLoading || autoSaving || autoRunning}
onClick={runAutoOrganizeNow}
>
{autoRunning ? '执行中…' : '立即整理一次'}
</button>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\downloads 或 /downloads"
value={autoConfig.sourceDir}
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('sourceDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={autoConfig.targetDir}
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={autoConfig.transferMode}
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={autoConfig.intervalSeconds}
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.enabled)}
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
/>
整理源目录定时自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.afterDownload)}
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
/>
qB 下载完成后自动整理
</label>
<span className="text-xs text-sand-500">
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
</span>
</div>
</section>
{data?.path && (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">手动整理入库</h2>
<p className="text-xs text-sand-500">来源优先使用选中项;未选中时使用当前目录。</p>
</div>
<div className="max-w-xl truncate rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-xs text-ink-100" title={organizeSource}>
来源:{organizeSource || '未选择'}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[1.2fr_1fr_150px_150px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">目标媒体库 / 存储</span>
<select
className="input-base w-full"
value={organizeLibraryID}
onChange={(event) => setOrganizeLibraryID(event.target.value)}
>
<option value="">手动填写目的路径</option>
{libraries.map((library) => (
<option key={library.id} value={library.id}>
{library.name}({library.type})— {library.path}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">目的路径</span>
<input
className="input-base w-full"
placeholder="例如 F:\\media\\电影 或 /media/电影"
value={organizeDestPath}
onChange={(event) => setOrganizeDestPath(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">类型</span>
<select className="input-base w-full" value={organizeMediaType} onChange={(event) => setOrganizeMediaType(event.target.value)}>
<option value="auto">自动识别</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
<option value="adult">成人</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理方式</span>
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => setOrganizeTransferMode(event.target.value)}>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={scanAfter} onChange={(event) => setScanAfter(event.target.checked)} />
整理后扫描入库
</label>
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={!organizeReady || organizeBusy !== ''}
onClick={() => runManualOrganize(true)}
>
{organizeBusy === 'preview' ? '预览中…' : '预览整理'}
</button>
<button
type="button"
className="neon-button"
disabled={!organizeReady || organizeBusy !== ''}
onClick={() => runManualOrganize(false)}
>
{organizeBusy === 'run' ? '整理中…' : '开始整理入库'}
</button>
</div>
{previewItems.length > 0 && (
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-white text-sand-500">
<tr>
<th className="px-3 py-2">动作</th>
<th>来源</th>
<th>目标</th>
<th>原因</th>
</tr>
</thead>
<tbody>
{previewItems.map((item, index) => (
<tr key={`${item.source}-${index}`} className="border-t border-gray-200 align-top">
<td className="px-3 py-2 font-semibold text-brand-500">{item.action}</td>
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.source}>{item.source}</td>
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.target}>{item.target || '—'}</td>
<td className="py-2 text-sand-500">{item.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{data?.path && (
<details className="glass-panel" open={Boolean(selected)}>
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
文件操作
<span className="ml-2 text-xs font-normal text-sand-500">
新建目录 / 重命名 / 删除 / 转移
</span>
</summary>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-2">
<h2 className="text-sm font-semibold text-ink-600">新建目录</h2>
<div className="flex gap-2">
<input className="input-base flex-1" placeholder="新目录名称" value={folderName} onChange={(e) => setFolderName(e.target.value)} />
<button className="neon-button" disabled={busy === 'mkdir' || !folderName.trim()} onClick={createFolder}>
<Plus size={16} /> 创建
</button>
</div>
</div>
<div className="space-y-2">
<h2 className="text-sm font-semibold text-ink-600">选中项</h2>
{selected ? (
<div className="space-y-2">
<p className="truncate font-mono text-xs text-ink-50" title={selected.path}>{selected.path}</p>
<div className="flex flex-wrap gap-2">
<input className="input-base min-w-[220px] flex-1" value={renameTo} onChange={(e) => setRenameTo(e.target.value)} />
<button className="neon-button" disabled={busy === 'rename' || !renameTo.trim()} onClick={renameSelected}>
<Pencil size={16} /> 重命名
</button>
<button className="rounded-xl border border-red-400/40 px-3 py-2 text-sm text-red-500 hover:bg-red-50" disabled={busy === 'delete'} onClick={deleteSelected}>
<Trash2 size={16} className="inline" /> 删除
</button>
</div>
{!selected.is_dir && (
<div className="grid gap-2 md:grid-cols-[1fr_140px_auto]">
<input className="input-base" placeholder="目标目录路径" value={destPath} onChange={(e) => setDestPath(e.target.value)} />
<select className="input-base" value={transferMode} onChange={(e) => setTransferMode(e.target.value)}>
<option value="copy">复制</option>
<option value="move">移动</option>
<option value="hardlink">硬链接</option>
<option value="symlink">软链接</option>
</select>
<button className="neon-button" disabled={busy === 'transfer' || !destPath.trim()} onClick={transferSelected}>
{transferMode === 'move' ? <Move size={16} /> : transferMode === 'copy' ? <Copy size={16} /> : <GitBranch size={16} />}
转移
</button>
</div>
)}
</div>
) : (
<p className="text-sm text-ink-50">先在下方列表点击“操作”选择文件或目录。</p>
)}
</div>
</div>
</details>
)}
{loading && <p className="text-sand-500">加载中…</p>}
{error && <div className="glass-panel !border-red-400/40 text-red-400">{error}</div>}
{!loading && data && !data.entries && data.roots && (
{!loading && data && !data.path && data.roots && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.roots.map((r) => (
<button
key={r.path}
onClick={() => setPath(r.path)}
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
>
<button key={r.path} onClick={() => setPath(r.path)} className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40">
<FolderOpen size={20} className="text-brand-500" />
<div>
<p className="font-mono text-sm text-ink-600">{r.label}</p>
@@ -100,34 +688,31 @@ export function FileManagerPage() {
)}
{!loading && data?.entries && data.entries.length > 0 && (
<div className="glass-panel">
<div className="glass-panel overflow-x-auto">
<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>
{data.entries.map((e) => (
<tr
key={e.path}
className="cursor-pointer border-t border-gray-200 transition hover:bg-gray-50"
onClick={() => enter(e)}
title={e.path}
>
<td className="flex items-center gap-2 py-2 text-ink-600">
{e.is_dir ? (
<Folder size={16} className="text-brand-500" />
) : (
<FileVideo size={16} className="text-ink-50" />
)}
{e.name}
{data.entries.map((entry) => (
<tr key={entry.path} className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selected?.path === entry.path ? 'bg-primary-400/5' : '')}>
<td className="py-2 text-ink-600">
<button className="flex max-w-xl items-center gap-2 text-left" onClick={() => (entry.is_dir ? enter(entry) : choose(entry))} title={entry.path}>
{entry.is_dir ? <Folder size={16} className="text-brand-500" /> : <FileVideo size={16} className="text-ink-50" />}
<span className="truncate">{recursive ? entry.path.replace((data.path || '') + '\\', '').replace((data.path || '') + '/', '') : entry.name}</span>
</button>
</td>
<td className="text-ink-100">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
<td className="text-sand-500">
{new Date(e.modified * 1000).toLocaleString()}
<td className="text-ink-100">{entry.is_dir ? '—' : fmtBytes(entry.size)}</td>
<td className="text-sand-500">{new Date(entry.modified * 1000).toLocaleString()}</td>
<td className="text-right">
<button className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40" onClick={() => choose(entry)}>
<HardDrive size={12} className="mr-1 inline" /> 操作
</button>
</td>
</tr>
))}
@@ -136,9 +721,7 @@ export function FileManagerPage() {
</div>
)}
{!loading && data?.entries && data.entries.length === 0 && (
<p className="text-ink-50">空目录。</p>
)}
{!loading && data?.entries && data.entries.length === 0 && <p className="text-ink-50">空目录。</p>}
</div>
)
}
+19 -3
View File
@@ -148,9 +148,17 @@ const GROUPS: SettingGroup[] = [
description: '媒体文件命名 + 自动刮削 + 整理目标',
items: [
{
key: 'organizer.auto_after_download',
label: '入库时自动整理',
key: 'organize.auto',
label: '整理源目录定时自动整理',
type: 'toggle',
hint: '开启后后台会按下方间隔递归扫描「整理源目录」,自动整理到「整理目的地目录」并扫描入库。默认关闭,避免无意中频繁读盘。',
defaultValue: 'false',
},
{
key: 'organizer.auto_after_download',
label: '下载完成后自动整理入库',
type: 'toggle',
hint: '开启后 qB 下载完成时,系统会优先使用种子的 content_path 整理该文件/目录,并在整理完成后扫描目标媒体库。',
},
{
key: 'downloads.smart_classify',
@@ -191,11 +199,19 @@ const GROUPS: SettingGroup[] = [
{ value: 'symlink', label: '软链接(符号链接,保留源)' },
],
},
{
key: 'organize.interval_seconds',
label: '自动整理间隔秒数',
type: 'number',
hint: '仅在「整理源目录定时自动整理」开启后生效;最小 60 秒,建议 300 秒或更高。',
defaultValue: '300',
placeholder: '300',
},
{
key: 'organize.keep_seeding',
label: '保种(整理后继续做种上传)',
type: 'toggle',
hint: '开启后即使选择「移动」也会自动改用硬链接(跨盘退化为复制)保留源文件,确保 qBittorrent 转移后继续做种上传。',
hint: '开启后即使选择「移动」也会自动改用硬链接保留源文件,确保 qBittorrent 继续做种。硬链接要求源和目标在同一文件系统;失败时会提示,不再静默复制占用双倍空间。',
},
{
key: 'organize.movie_format',
+31 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Database, HardDrive, PieChart } from 'lucide-react'
import { storageAPI, type StorageBreakdown } from '../api/storage'
@@ -44,24 +45,37 @@ export function StoragePage() {
<header className="flex items-center gap-3">
<HardDrive className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">存储</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">存储与文件</h1>
<p className="text-sm text-ink-50">
按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。
文件浏览、整理入库、排重与存储统计统一在这里,避免入口重复。
</p>
</div>
</header>
<ManagementShortcuts
title="存储与文件入口"
description="存储统计只负责看数据,文件管理、排重、回收与配置入口集中放在这里。"
title="核心操作"
description="保留常用入口:先整理/入库,再按需进入文件或清理功能。"
items={[
{ to: '/files', title: '文件管理', description: '浏览服务器文件并执行基础文件操作' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
{ to: '/files', title: '文件管理', description: '浏览服务器文件,做少量安全文件操作' },
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理' },
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
]}
/>
<details className="glass-panel group">
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span>
</summary>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-5">
<MaintenanceLink to="/strm" title="STRM 生成" />
<MaintenanceLink to="/scheduler" title="定时任务" />
<MaintenanceLink to="/tasks" title="任务队列" />
<MaintenanceLink to="/notify-channels" title="通知渠道" />
<MaintenanceLink to="/assistant" title="AI 对话台" />
</div>
</details>
<section className="grid gap-4 sm:grid-cols-3">
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
@@ -151,3 +165,14 @@ function Tile({
</div>
)
}
function MaintenanceLink({ to, title }: { to: string; title: string }) {
return (
<Link
to={to}
className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-ink-100 transition hover:border-primary-400/40 hover:text-brand-500"
>
{title}
</Link>
)
}
-649
View File
@@ -1,649 +0,0 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, FolderCog, Info, Loader2, Search, Wrench } from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI, type OrganizeSource } from '../api/tools'
import type { Library, Media, Setting } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
// ToolsPage gathers admin-only one-off operations that don't belong on a
// dedicated screen of their own:
//
// - Organize one media item or an entire library: renames + moves files
// into the canonical layout (`Library/Year/Title (Year)/...`).
// - Send a test notification through every configured channel.
//
// The original Vue version surfaces these as tabs inside SettingsView; here
// we co-locate them on a single admin tools page.
export function ToolsPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-400/10 text-violet-400">
<Wrench size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">运维工具</h1>
<p className="text-sm text-ink-50">整理媒体文件 · 测试通知渠道</p>
</div>
</div>
<ManagementShortcuts
title="运维与自动化入口"
description="把整理、任务、通知和高级辅助功能统一放回工具台。"
items={[
{ to: '/strm', title: 'STRM 生成', description: '生成 STRM 文件供外部播放器或媒体服务使用' },
{ to: '/scheduler', title: '定时任务', description: '查看和维护自动扫描、刮削与订阅任务' },
{ to: '/tasks', title: '任务队列', description: '跟踪后台任务执行状态和失败原因' },
{ to: '/notify-channels', title: '通知渠道', description: '配置并测试消息通知渠道' },
{ to: '/assistant', title: 'AI 对话台', description: '进入管理员辅助诊断和问答界面' },
]}
/>
<OrganizePanel />
<NotifyPanel />
</div>
)
}
function isOn(v: string | undefined): boolean {
return v === 'true' || v === '1' || v === 'on'
}
function OrganizePanel() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryID, setLibraryID] = useState('')
const [running, setRunning] = useState(false)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<Media[]>([])
const [busyID, setBusyID] = useState<string | null>(null)
const [smartClassify, setSmartClassify] = useState(false)
const [loadingSettings, setLoadingSettings] = useState(true)
// 整理 & 重命名「默认设置」内联编辑器:与设置页共用同一批 organize.* 配置键,
// 这样管理员无需在「设置」和「工具」之间来回跳转即可调默认值并直接执行整理。
const [showDefaults, setShowDefaults] = useState(false)
const [savingDefaults, setSavingDefaults] = useState(false)
const ORGANIZE_KEYS = [
'organizer.auto_after_download',
'downloads.smart_classify',
'organizer.smart_classify',
'organize.source_dir',
'organize.target_dir',
'organize.transfer_mode',
'organize.keep_seeding',
'organize.movie_format',
'organize.tv_format',
'organize.anime_format',
] as const
const [defaults, setDefaults] = useState<Record<string, string>>({})
const setDefault = (key: string, value: string) =>
setDefaults((prev) => ({ ...prev, [key]: value }))
// 单次整理覆盖项:留空则沿用设置页的默认源目录/目的地目录与转移方式。
const [sourcePath, setSourcePath] = useState('')
const [destPath, setDestPath] = useState('')
const [transferMode, setTransferMode] = useState('')
// 整理来源目录(如下载目录):可选择整个目录作为整理源,不要求是已登记媒体库。
const [sources, setSources] = useState<OrganizeSource[]>([])
const [sourceDir, setSourceDir] = useState('')
const [organizingDir, setOrganizingDir] = useState(false)
const [ingestingDir, setIngestingDir] = useState(false)
const overrides = () => {
const o: { source_path?: string; dest_path?: string; transfer_mode?: string } = {}
if (sourcePath.trim()) o.source_path = sourcePath.trim()
if (destPath.trim()) o.dest_path = destPath.trim()
if (transferMode) o.transfer_mode = transferMode
return o
}
useEffect(() => {
libraryAPI.list({ includeHidden: true }).then(setLibraries).catch(() => undefined)
toolsAPI
.organizeSources()
.then((s) => {
setSources(s)
if (s.length > 0) setSourceDir(s[0].path)
})
.catch(() => undefined)
}, [])
useEffect(() => {
const loadSettings = async () => {
try {
const settings = (await adminAPI.listSettings()) as Setting[]
const byKey: Record<string, string> = {}
for (const s of settings) byKey[s.key] = s.value
const next: Record<string, string> = {}
for (const k of ORGANIZE_KEYS) next[k] = byKey[k] ?? ''
setDefaults(next)
const sc = byKey['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
} catch {
// ignore
} finally {
setLoadingSettings(false)
}
}
loadSettings()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSaveDefaults = async (e: FormEvent) => {
e.preventDefault()
setSavingDefaults(true)
try {
for (const k of ORGANIZE_KEYS) {
await adminAPI.updateSetting(k, defaults[k] ?? '')
}
const sc = defaults['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
toast.success('整理默认设置已保存')
} catch {
toast.error('保存失败')
} finally {
setSavingDefaults(false)
}
}
const onOrganizeLibrary = async (e: FormEvent) => {
e.preventDefault()
if (!libraryID) return
setRunning(true)
try {
await toolsAPI.organizeLibrary(libraryID, overrides())
toast.success('已触发媒体库整理')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setRunning(false)
}
}
const runOrganizeDir = async (scanAfter: boolean) => {
const src = (sourceDir || sourcePath).trim()
if (!src) {
toast.error('请选择或填写整理来源目录')
return
}
const ok = await confirmAction({
title: scanAfter ? '确认整理并入库' : '确认整理来源目录',
message: scanAfter
? '将从来源目录整理/重命名媒体文件到目的地目录,然后扫描媒体库把新文件入库。若开启洗版,高分辨率来源可能会替换低分辨率旧版本。'
: '将从来源目录整理/重命名媒体文件到目的地目录,并执行去重与洗版判断。此操作不会自动扫描入库。',
confirmText: scanAfter ? '开始整理入库' : '确认整理',
danger: false,
})
if (!ok) return
if (scanAfter) setIngestingDir(true)
else setOrganizingDir(true)
try {
const r = await toolsAPI.organizeDirectory({
source_path: src,
dest_path: destPath.trim() || undefined,
transfer_mode: transferMode || undefined,
})
const replaced = r.replaced ?? 0
let scanSummary = ''
if (scanAfter) {
scanSummary = await scanLibrariesForIngest()
}
toast.success(
`整理完成:新增 ${r.organized} · 替换(洗版) ${replaced} · 去重跳过 ${r.skipped}${scanSummary}`,
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setOrganizingDir(false)
setIngestingDir(false)
}
}
const onOrganizeDir = async (e: FormEvent) => {
e.preventDefault()
await runOrganizeDir(false)
}
const scanLibrariesForIngest = async (): Promise<string> => {
let availableLibraries = libraries
if (availableLibraries.length === 0) {
availableLibraries = await libraryAPI.list({ includeHidden: true }).catch(() => [])
if (availableLibraries.length > 0) setLibraries(availableLibraries)
}
const targets = (libraryID
? availableLibraries.filter((l) => l.id === libraryID)
: availableLibraries.filter((l) => l.enabled !== false)
)
if (targets.length === 0) return ' · 未找到可扫描媒体库'
let added = 0
let updated = 0
let visited = 0
const failed: string[] = []
for (const lib of targets) {
try {
const res = await libraryAPI.scan(lib.id)
added += res.added ?? 0
updated += res.updated ?? 0
visited += res.visited ?? 0
} catch {
failed.push(lib.name)
}
}
if (failed.length > 0) {
toast.error(`部分媒体库扫描失败:${failed.join('、')}`)
}
return ` · 入库扫描 ${targets.length - failed.length}/${targets.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}`
}
const doSearch = async (e?: FormEvent) => {
e?.preventDefault()
if (!query.trim()) return
setSearching(true)
try {
const r = await mediaAPI.search(query.trim(), 20)
setResults(r.items ?? [])
} catch {
toast.error('搜索失败')
} finally {
setSearching(false)
}
}
const onOrganizeOne = async (m: Media) => {
setBusyID(m.id)
try {
const r = await toolsAPI.organizeMedia(m.id, overrides())
toast.success(`已整理到 ${r.path}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setBusyID(null)
}
}
return (
<section className="glass-panel space-y-5">
<div className="flex items-center gap-2">
<FolderCog size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</h2>
</div>
<p className="text-xs text-sand-500">
将媒体文件按 <code className="rounded-lg bg-gray-50 px-1">媒体库/电影或电视剧/分类/标题/Season</code>{' '}
的规范布局移动并重命名,确保已先完成刮削。
</p>
{!loadingSettings && (
<div className="flex items-center gap-2 text-xs">
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-sand-500'} />
<span className={smartClassify ? 'text-green-400' : 'text-sand-500'}>
智能分类:{smartClassify ? '已启用' : '未启用'}
</span>
{smartClassify && (
<span className="text-sand-500">(将根据元数据自动分类到子目录)</span>
)}
</div>
)}
<div className="rounded-xl border border-gray-200 bg-white/40">
<button
type="button"
onClick={() => setShowDefaults((v) => !v)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-medium text-ink-600"
>
<span className="flex items-center gap-2">
<Wrench size={14} className="text-brand-500" />
整理 &amp; 重命名 默认设置(与设置页同步,改这里即可,无需再去设置页)
</span>
<span className="text-xs text-sand-500">{showDefaults ? '收起 ▲' : '展开 ▼'}</span>
</button>
{showDefaults && (
<form onSubmit={onSaveDefaults} className="space-y-3 border-t border-gray-200 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理源目录(待整理)</span>
<input
className="input-base w-full"
placeholder="留空则默认整理整个媒体库"
value={defaults['organize.source_dir'] ?? ''}
onChange={(e) => setDefault('organize.source_dir', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理目的地目录</span>
<input
className="input-base w-full"
placeholder="留空则按媒体库归类"
value={defaults['organize.target_dir'] ?? ''}
onChange={(e) => setDefault('organize.target_dir', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认转移方式</span>
<select
className="input-base w-full"
value={defaults['organize.transfer_mode'] ?? ''}
onChange={(e) => setDefault('organize.transfer_mode', e.target.value)}
>
<option value="">未设置</option>
<option value="move">移动(删除源文件)</option>
<option value="copy">复制(保留源文件)</option>
<option value="hardlink">硬链接(保留源,做种不中断)</option>
<option value="symlink">软链接(符号链接,保留源)</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">电影命名格式</span>
<input
className="input-base w-full"
placeholder="{title} ({year})"
value={defaults['organize.movie_format'] ?? ''}
onChange={(e) => setDefault('organize.movie_format', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">剧集命名格式</span>
<input
className="input-base w-full"
placeholder="{title} - S{season:02d}E{episode:02d}"
value={defaults['organize.tv_format'] ?? ''}
onChange={(e) => setDefault('organize.tv_format', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">动漫命名格式</span>
<input
className="input-base w-full"
placeholder="{title} - {episode:02d}"
value={defaults['organize.anime_format'] ?? ''}
onChange={(e) => setDefault('organize.anime_format', e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organizer.auto_after_download'])}
onChange={(e) => setDefault('organizer.auto_after_download', e.target.checked ? 'true' : 'false')}
/>
入库时自动整理
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={!defaults['downloads.smart_classify'] || isOn(defaults['downloads.smart_classify'])}
onChange={(e) => setDefault('downloads.smart_classify', e.target.checked ? 'true' : 'false')}
/>
下载器智能分类
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organizer.smart_classify'])}
onChange={(e) => setDefault('organizer.smart_classify', e.target.checked ? 'true' : 'false')}
/>
智能分类(按元数据分目录)
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organize.keep_seeding'])}
onChange={(e) => setDefault('organize.keep_seeding', e.target.checked ? 'true' : 'false')}
/>
保种(整理后继续做种)
</label>
<button type="submit" disabled={savingDefaults} className="neon-button ml-auto">
{savingDefaults ? <Loader2 size={16} className="animate-spin" /> : null}
保存默认设置
</button>
</div>
</form>
)}
</div>
<p className="text-xs text-sand-500">
整理是「<b>从源目录整理到目的地目录</b>」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用上方默认设置(默认源目录 = 媒体库路径)。
</p>
<div className="grid gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-ink-50">源目录(待整理,可选)</span>
<input
className="input-base w-full"
placeholder="留空则使用「整理源目录」设置或媒体库路径"
value={sourcePath}
onChange={(e) => setSourcePath(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">目的地目录(整理输出,可选)</span>
<input
className="input-base w-full"
placeholder="留空则使用「整理目的地目录」设置或媒体库路径"
value={destPath}
onChange={(e) => setDestPath(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">转移方式(可选,覆盖默认设置)</span>
<select
className="input-base w-full"
value={transferMode}
onChange={(e) => setTransferMode(e.target.value)}
>
<option value="">使用默认设置</option>
<option value="move">移动(删除源文件)</option>
<option value="copy">复制(保留源文件)</option>
<option value="hardlink">硬链接(保留源,做种不中断)</option>
<option value="symlink">软链接(符号链接,保留源)</option>
</select>
</label>
</div>
<div className="space-y-2 rounded-xl border border-brand-200 bg-brand-50/40 p-3">
<p className="text-xs text-ink-50">
<b>整理来源目录</b>:直接整理整个目录(如下载目录 <code className="rounded bg-gray-100 px-1">/downloads</code>),无需是已登记的媒体库。已存在于目的地的媒体会<b>自动去重跳过</b>,来源<b>分辨率更高</b>时会<b>替换(洗版)</b>旧版本。点击「开始整理入库」会在整理完成后自动扫描媒体库。
</p>
<form onSubmit={onOrganizeDir} className="flex flex-wrap gap-2">
<select
className="input-base flex-1 min-w-[220px]"
value={sourceDir}
onChange={(e) => setSourceDir(e.target.value)}
>
{sources.length === 0 && (
<option value="">(无可选来源目录,请在上方「源目录」手动填写)</option>
)}
{sources.map((s) => (
<option key={s.path} value={s.path}>
{s.label}({s.path})
</option>
))}
</select>
<button
type="submit"
disabled={organizingDir || ingestingDir || !(sourceDir || sourcePath).trim()}
className="neon-button !border-sand-400/40 !text-ink-100"
>
{organizingDir ? (
<Loader2 size={16} className="animate-spin" />
) : (
<FolderCog size={16} />
)}
确认整理(仅重命名/洗版)
</button>
<button
type="button"
disabled={organizingDir || ingestingDir || !(sourceDir || sourcePath).trim()}
onClick={() => runOrganizeDir(true)}
className="neon-button"
>
{ingestingDir ? (
<Loader2 size={16} className="animate-spin" />
) : (
<FolderCog size={16} />
)}
开始整理入库
</button>
</form>
<p className="text-[11px] text-sand-500">
入库扫描范围:如果下方已选择媒体库,则只扫描该库;未选择时扫描全部已启用媒体库。
</p>
</div>
<form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2">
<select
required
className="input-base flex-1 min-w-[200px]"
value={libraryID}
onChange={(e) => setLibraryID(e.target.value)}
>
<option value="" disabled>
选择要整理的媒体库
</option>
{libraries.map((l) => (
<option key={l.id} value={l.id}>
{l.name} ({l.type})
</option>
))}
</select>
<button type="submit" disabled={running || !libraryID} className="neon-button">
{running ? <Loader2 size={16} className="animate-spin" /> : <FolderCog size={16} />}
整理整个库
</button>
</form>
<div className="border-t border-gray-200 pt-4">
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
<input
className="input-base flex-1"
placeholder="或搜索单个媒体进行整理…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
<Search size={16} /> {searching ? '搜索中…' : '搜索'}
</button>
</form>
{results.length > 0 && (
<ul className="mt-4 space-y-2">
{results.map((m) => (
<li
key={m.id}
className="flex items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm text-ink-600">{m.title}</div>
<div className="truncate text-xs text-ink-50">{m.path}</div>
</div>
<button
onClick={() => onOrganizeOne(m)}
disabled={busyID === m.id}
className="neon-button !px-3 !py-1 text-xs"
>
{busyID === m.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FolderCog size={12} />
)}
整理
</button>
</li>
))}
</ul>
)}
{!searching && query && results.length === 0 && (
<p className="mt-3 text-sm text-ink-50">未找到匹配的媒体。</p>
)}
</div>
</section>
)
}
function NotifyPanel() {
const [title, setTitle] = useState('MediaStation 测试通知')
const [body, setBody] = useState('如果你收到这条消息,说明通知渠道工作正常。')
const [sending, setSending] = useState(false)
const onSend = async (e: FormEvent) => {
e.preventDefault()
if (!title.trim() || !body.trim()) return
setSending(true)
try {
await toolsAPI.notifyTest(title.trim(), body.trim())
toast.success('已派发到所有已启用通道')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'发送失败'
toast.error(msg)
} finally {
setSending(false)
}
}
return (
<section className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Bell size={18} className="text-amber-300" />
<h2 className="font-display text-lg font-semibold text-ink-600">通知渠道测试</h2>
</div>
<p className="text-xs text-sand-500">
会向所有已配置的通知渠道(Telegram / Bark / Webhook 等)发送一条测试消息。
</p>
<form onSubmit={onSend} className="space-y-3">
<Field label="标题">
<input
required
className="input-base"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</Field>
<Field label="内容">
<textarea
required
rows={3}
className="input-base resize-none"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</Field>
<button type="submit" disabled={sending} className="neon-button">
{sending ? <Loader2 size={16} className="animate-spin" /> : <Bell size={16} />}
发送测试通知
</button>
</form>
</section>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}