mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
feat(web): port AI Assistant, STRM management, and admin tools pages from Vue UI
- AIAssistantPage (/ai): smart natural-language search showing parsed intent + matching media grid, plus history-based recommendations. - StrmPage (/strm, admin): import streaming-only entries from URLs and attach/detach STRM URL on existing media. - ToolsPage (/tools, admin): organize an entire library or single media item, send test notifications through configured channels. - Wire new routes in App.tsx and add sidebar links in Layout.tsx. Co-authored-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
This commit is contained in:
@@ -75,6 +75,15 @@ const SitesPage = lazy(() =>
|
||||
const SiteSearchPage = lazy(() =>
|
||||
import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })),
|
||||
)
|
||||
const AIAssistantPage = lazy(() =>
|
||||
import('./pages/AIAssistantPage').then((m) => ({ default: m.AIAssistantPage })),
|
||||
)
|
||||
const StrmPage = lazy(() =>
|
||||
import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })),
|
||||
)
|
||||
const ToolsPage = lazy(() =>
|
||||
import('./pages/ToolsPage').then((m) => ({ default: m.ToolsPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -108,6 +117,7 @@ export default function App() {
|
||||
<Route path="poster-wall" element={<PosterWallPage />} />
|
||||
<Route path="sites" element={<SitesPage />} />
|
||||
<Route path="site-search" element={<SiteSearchPage />} />
|
||||
<Route path="ai" element={<AIAssistantPage />} />
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
@@ -164,6 +174,22 @@ export default function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="strm"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<StrmPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tools"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ToolsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="stats"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { api } from './client'
|
||||
|
||||
// toolsAPI groups admin-only endpoints that don't fit the other domain
|
||||
// modules: organizing media files into the canonical naming layout, and
|
||||
// dispatching a test notification through the configured channels.
|
||||
export const toolsAPI = {
|
||||
organizeMedia: (mediaID: string) =>
|
||||
api
|
||||
.post<{ path: string }>(`/admin/media/${mediaID}/organize`)
|
||||
.then((r) => r.data),
|
||||
|
||||
organizeLibrary: (libraryID: string) =>
|
||||
api
|
||||
.post<Record<string, unknown>>(`/admin/libraries/${libraryID}/organize`)
|
||||
.then((r) => r.data),
|
||||
|
||||
notifyTest: (title: string, body: string) =>
|
||||
api
|
||||
.post<{ message: string }>('/admin/notify/test', { title, body })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
Heart,
|
||||
Home,
|
||||
KeyRound,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wrench,
|
||||
Library as LibraryIcon,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -61,6 +64,7 @@ export function Layout() {
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
|
||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
||||
@@ -107,6 +111,8 @@ export function Layout() {
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2, Search, Sparkles, Wand2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { aiAPI, type SearchIntent } from '../api/ai'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// AIAssistantPage exposes the two AI helpers backed by the Go server:
|
||||
// - smart search: parses a natural-language query into a SearchIntent +
|
||||
// a list of matching local media items.
|
||||
// - recommendations: returns a list of recommended titles based on the
|
||||
// current user's recent watch history.
|
||||
//
|
||||
// The Vue version had a full chat surface; the Go backend has no chat or
|
||||
// operation-execute endpoints, so we render the same two capabilities as
|
||||
// a focused two-panel screen.
|
||||
export function AIAssistantPage() {
|
||||
const [status, setStatus] = useState<{
|
||||
enabled: boolean
|
||||
provider: string
|
||||
model: string
|
||||
} | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
|
||||
const [recs, setRecs] = useState<string[] | null>(null)
|
||||
const [recommending, setRecommending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
aiAPI
|
||||
.status()
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus({ enabled: false, provider: '', model: '' }))
|
||||
}, [])
|
||||
|
||||
const onSearch = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
setIntent(null)
|
||||
setItems([])
|
||||
try {
|
||||
const r = await aiAPI.smartSearch(query.trim())
|
||||
setIntent(r.intent)
|
||||
setItems(r.items)
|
||||
if (r.items.length === 0) toast('未找到匹配项')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'搜索失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onRecommend = async () => {
|
||||
setRecommending(true)
|
||||
try {
|
||||
const titles = await aiAPI.recommend()
|
||||
setRecs(titles)
|
||||
if (titles.length === 0) toast('暂无可推荐内容,请先观看一些媒体')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'获取推荐失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setRecommending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const quickHints = [
|
||||
'2023 年的科幻电影',
|
||||
'评分高的动漫',
|
||||
'最近添加的纪录片',
|
||||
'中文剧集',
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">AI 助手</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
自然语言搜索 · 基于观影历史的智能推荐
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status && (
|
||||
<div className="text-xs text-slate-400">
|
||||
<span
|
||||
className={
|
||||
'mr-2 inline-block h-2 w-2 rounded-full ' +
|
||||
(status.enabled ? 'bg-emerald-400' : 'bg-slate-500')
|
||||
}
|
||||
/>
|
||||
{status.enabled
|
||||
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
|
||||
: '未配置 AI 服务,使用本地规则解析'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Smart search */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">智能搜索</h2>
|
||||
<form onSubmit={onSearch} className="flex gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="试试: 2023 年的高分动作片"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={searching} className="neon-button">
|
||||
{searching ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Search size={16} />
|
||||
)}
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickHints.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => {
|
||||
setQuery(h)
|
||||
}}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{intent && (
|
||||
<div className="rounded-lg border border-white/5 bg-white/5 p-3 text-xs text-slate-300">
|
||||
<div className="mb-1 font-semibold text-slate-200">解析结果</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<span>
|
||||
查询: <span className="text-primary-400">{intent.query || '—'}</span>
|
||||
</span>
|
||||
{intent.year !== undefined && intent.year > 0 && (
|
||||
<span>
|
||||
年份: <span className="text-primary-400">{intent.year}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.genre && (
|
||||
<span>
|
||||
类型: <span className="text-primary-400">{intent.genre}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.type && (
|
||||
<span>
|
||||
分类: <span className="text-primary-400">{intent.type}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.sort && (
|
||||
<span>
|
||||
排序: <span className="text-primary-400">{intent.sort}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.language && (
|
||||
<span>
|
||||
语言: <span className="text-primary-400">{intent.language}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recommendations */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-white">为你推荐</h2>
|
||||
<button onClick={onRecommend} disabled={recommending} className="neon-button">
|
||||
{recommending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Wand2 size={16} />
|
||||
)}
|
||||
生成推荐
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
推荐基于你的最近观看历史。点击标题在媒体库中查找。
|
||||
</p>
|
||||
|
||||
{recs && recs.length > 0 && (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{recs.map((t, i) => (
|
||||
<li key={i}>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(t)}`}
|
||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-sm text-slate-200 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<span className="truncate">{t}</span>
|
||||
<Search size={14} className="shrink-0 opacity-60" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{recs && recs.length === 0 && (
|
||||
<p className="text-sm text-slate-400">
|
||||
还没有推荐结果 — 先去看几部片子,我再给你挑。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
|
||||
{!status?.enabled && (
|
||||
<p className="text-xs text-slate-500">
|
||||
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
|
||||
管理员可在 <Link to="/api-configs" className="text-primary-400">API 配置</Link>{' '}
|
||||
中接入 OpenAI / DeepSeek 等服务以获得更好效果。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link as LinkIcon, Plus, Search, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { strmAPI } from '../api/strm'
|
||||
import type { Library, Media } from '../types'
|
||||
|
||||
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
|
||||
// - import a brand-new media row directly from a (library, title, url)
|
||||
// tuple — useful for streaming-only entries with no on-disk file.
|
||||
// - search existing media and attach / detach a STRM URL so the player
|
||||
// issues a 302 redirect to the remote source instead of opening a
|
||||
// local file.
|
||||
export function StrmPage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
|
||||
// Import form state
|
||||
const [libraryID, setLibraryID] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setURL] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
// Search + attach state
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// Default the import library to the first available one once loaded.
|
||||
useEffect(() => {
|
||||
if (!libraryID && libraries[0]) setLibraryID(libraries[0].id)
|
||||
}, [libraries, libraryID])
|
||||
|
||||
const onImport = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!libraryID || !title.trim() || !url.trim()) return
|
||||
if (!/^https?:\/\//i.test(url.trim())) {
|
||||
toast.error('URL 必须以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
setImporting(true)
|
||||
try {
|
||||
await strmAPI.importURL(libraryID, title.trim(), url.trim())
|
||||
toast.success(`已导入「${title.trim()}」`)
|
||||
setTitle('')
|
||||
setURL('')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'导入失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doSearch = async (e?: FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const r = await mediaAPI.search(query.trim(), 30)
|
||||
setResults(r.items ?? [])
|
||||
} catch {
|
||||
toast.error('搜索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onAttach = async (m: Media) => {
|
||||
const next = (drafts[m.id] ?? '').trim()
|
||||
if (!next) return
|
||||
if (!/^https?:\/\//i.test(next)) {
|
||||
toast.error('URL 必须以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await strmAPI.set(m.id, next)
|
||||
toast.success('已设置 STRM URL')
|
||||
// Optimistic update so the user sees the new state without a re-search.
|
||||
setResults((rs) =>
|
||||
rs.map((x) => (x.id === m.id ? ({ ...x, container: 'strm' } as Media) : x)),
|
||||
)
|
||||
setDrafts((d) => ({ ...d, [m.id]: '' }))
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'设置失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDetach = async (m: Media) => {
|
||||
if (!confirm(`清除「${m.title}」的 STRM URL?`)) return
|
||||
try {
|
||||
await strmAPI.clear(m.id)
|
||||
toast.success('已清除')
|
||||
setResults((rs) =>
|
||||
rs.map((x) =>
|
||||
x.id === m.id ? ({ ...x, container: x.container === 'strm' ? '' : x.container } as Media) : x,
|
||||
),
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'清除失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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-amber-400/10 text-amber-400">
|
||||
<LinkIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">STRM 管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import a new STRM-only entry. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">导入 STRM 条目</h2>
|
||||
<form onSubmit={onImport} className="grid gap-3 md:grid-cols-4">
|
||||
<select
|
||||
required
|
||||
className="input-base"
|
||||
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>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="标题"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="https://example.com/movie.mp4"
|
||||
value={url}
|
||||
onChange={(e) => setURL(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={importing} className="neon-button md:col-span-4">
|
||||
<Plus size={16} /> {importing ? '导入中…' : '导入'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-slate-500">
|
||||
导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Attach / detach STRM URL on existing media. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">附加 STRM URL 到已有媒体</h2>
|
||||
<form onSubmit={doSearch} className="flex 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 && (
|
||||
<div className="space-y-3">
|
||||
{results.map((m) => {
|
||||
const isStrm = m.container === 'strm'
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-xl border border-white/5 bg-white/5 p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">{m.title}</div>
|
||||
<div className="truncate text-xs text-slate-400">
|
||||
{m.year > 0 && `${m.year} · `}
|
||||
{m.container || '本地文件'}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<div className="mt-1 break-all rounded bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
|
||||
已设置 STRM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<button
|
||||
onClick={() => onDetach(m)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="https://example.com/stream.m3u8"
|
||||
value={drafts[m.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [m.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onAttach(m)}
|
||||
disabled={!(drafts[m.id] ?? '').trim()}
|
||||
className="neon-button"
|
||||
>
|
||||
{isStrm ? '替换 URL' : '设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && query && results.length === 0 && (
|
||||
<p className="text-sm text-slate-400">未找到匹配的媒体。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Bell, FolderCog, Loader2, Search, Wrench } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import type { Library, Media } from '../types'
|
||||
|
||||
// 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-white">运维工具</h1>
|
||||
<p className="text-sm text-slate-400">整理媒体文件 · 测试通知渠道</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrganizePanel />
|
||||
<NotifyPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onOrganizeLibrary = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!libraryID) return
|
||||
setRunning(true)
|
||||
try {
|
||||
await toolsAPI.organizeLibrary(libraryID)
|
||||
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)
|
||||
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-primary-400" />
|
||||
<h2 className="font-display text-lg font-semibold text-white">整理 & 重命名</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
将媒体文件按 <code className="rounded bg-white/5 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '}
|
||||
的规范布局移动并重命名,确保已先完成刮削。
|
||||
</p>
|
||||
|
||||
<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-white/5 pt-4">
|
||||
<form onSubmit={doSearch} className="flex 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-lg border border-white/5 bg-white/5 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-white">{m.title}</div>
|
||||
<div className="truncate text-xs text-slate-400">{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-slate-400">未找到匹配的媒体。</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-white">通知渠道测试</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-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-slate-300">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user