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:
Kiro Agent
2026-05-16 08:41:43 +00:00
parent 108074feb0
commit 261d342fa1
6 changed files with 787 additions and 0 deletions
+26
View File
@@ -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={
+21
View File
@@ -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),
}
+6
View File
@@ -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="管理后台" />
</>
+242
View File
@@ -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>
)
}
+248
View File
@@ -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>
)
}
+244
View File
@@ -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">整理 &amp; 重命名</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>
)
}