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 && (
(将根据元数据自动分类到子目录)
)}
)}
整理是「从源目录整理到目的地目录 」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用设置页的默认值(默认源目录 = 媒体库路径)。
源目录(待整理,可选)
setSourcePath(e.target.value)}
/>
目的地目录(整理输出,可选)
setDestPath(e.target.value)}
/>
转移方式(可选,覆盖默认设置)
setTransferMode(e.target.value)}
>
使用默认设置
移动(删除源文件)
复制(保留源文件)
硬链接(保留源,做种不中断)
软链接(符号链接,保留源)
{results.length > 0 && (
{results.map((m) => (
onOrganizeOne(m)}
disabled={busyID === m.id}
className="neon-button !px-3 !py-1 text-xs"
>
{busyID === m.id ? (
) : (
)}
整理
))}
)}
{!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 等)发送一条测试消息。
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
{label}
{children}
)
}