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 } from '../api/tools' import type { Library, Media, Setting } from '../types' import { ManagementShortcuts } from '../components/ManagementShortcuts' // 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 (

运维工具

整理媒体文件 · 测试通知渠道

) } function OrganizePanel() { const [libraries, setLibraries] = useState([]) const [libraryID, setLibraryID] = useState('') const [running, setRunning] = useState(false) const [query, setQuery] = useState('') const [searching, setSearching] = useState(false) const [results, setResults] = useState([]) const [busyID, setBusyID] = useState(null) const [smartClassify, setSmartClassify] = useState(false) const [loadingSettings, setLoadingSettings] = useState(true) // 单次整理覆盖项:留空则沿用设置页的默认源目录/目的地目录与转移方式。 const [sourcePath, setSourcePath] = useState('') const [destPath, setDestPath] = useState('') const [transferMode, setTransferMode] = useState('') 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().then(setLibraries).catch(() => undefined) }, []) useEffect(() => { const loadSettings = async () => { try { const settings = await adminAPI.listSettings() const setting = (settings as Setting[]).find(s => s.key === 'organizer.smart_classify') if (setting) { setSmartClassify(setting.value === 'true' || setting.value === '1' || setting.value === 'on') } } catch { // ignore } finally { setLoadingSettings(false) } } loadSettings() }, []) 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 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 (

整理 & 重命名

将媒体文件按 媒体库/电影或电视剧/分类/标题/Season{' '} 的规范布局移动并重命名,确保已先完成刮削。

{!loadingSettings && (
智能分类:{smartClassify ? '已启用' : '未启用'} {smartClassify && ( (将根据元数据自动分类到子目录) )}
)}

整理是「从源目录整理到目的地目录」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用设置页的默认值(默认源目录 = 媒体库路径)。

setQuery(e.target.value)} />
{results.length > 0 && (
    {results.map((m) => (
  • {m.title}
    {m.path}
  • ))}
)} {!searching && query && results.length === 0 && (

未找到匹配的媒体。

)}
) } 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 (

通知渠道测试

会向所有已配置的通知渠道(Telegram / Bark / Webhook 等)发送一条测试消息。

setTitle(e.target.value)} />