From 261d342fa18e8c1955b8df9638670ad947a200ac Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Sat, 16 May 2026 08:41:43 +0000 Subject: [PATCH] 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> --- web/src/App.tsx | 26 ++++ web/src/api/tools.ts | 21 +++ web/src/components/Layout.tsx | 6 + web/src/pages/AIAssistantPage.tsx | 242 +++++++++++++++++++++++++++++ web/src/pages/StrmPage.tsx | 248 ++++++++++++++++++++++++++++++ web/src/pages/ToolsPage.tsx | 244 +++++++++++++++++++++++++++++ 6 files changed, 787 insertions(+) create mode 100644 web/src/api/tools.ts create mode 100644 web/src/pages/AIAssistantPage.tsx create mode 100644 web/src/pages/StrmPage.tsx create mode 100644 web/src/pages/ToolsPage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 0dca9ac..b812c63 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () =>

加载中…

@@ -108,6 +117,7 @@ export default function App() { } /> } /> } /> + } /> } /> + + + + } + /> + + + + } + /> + api + .post<{ path: string }>(`/admin/media/${mediaID}/organize`) + .then((r) => r.data), + + organizeLibrary: (libraryID: string) => + api + .post>(`/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), +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index ca89c39..0fcd25d 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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() { } label="首页" end /> } label="发现" /> } label="搜索" /> + } label="AI 助手" /> } label="收藏" /> } label="播放列表" /> } label="观看历史" /> @@ -107,6 +111,8 @@ export function Layout() { } label="重复文件" /> } label="定时任务" /> } label="API 配置" /> + } label="STRM 管理" /> + } label="运维工具" /> } label="回收站" /> } label="管理后台" /> diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx new file mode 100644 index 0000000..2ea8f5c --- /dev/null +++ b/web/src/pages/AIAssistantPage.tsx @@ -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(null) + const [items, setItems] = useState([]) + + const [recs, setRecs] = useState(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 ( +
+
+
+
+ +
+
+

AI 助手

+

+ 自然语言搜索 · 基于观影历史的智能推荐 +

+
+
+ {status && ( +
+ + {status.enabled + ? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}` + : '未配置 AI 服务,使用本地规则解析'} +
+ )} +
+ + {/* Smart search */} +
+

智能搜索

+
+ setQuery(e.target.value)} + /> + +
+ +
+ {quickHints.map((h) => ( + + ))} +
+ + {intent && ( +
+
解析结果
+
+ + 查询: {intent.query || '—'} + + {intent.year !== undefined && intent.year > 0 && ( + + 年份: {intent.year} + + )} + {intent.genre && ( + + 类型: {intent.genre} + + )} + {intent.type && ( + + 分类: {intent.type} + + )} + {intent.sort && ( + + 排序: {intent.sort} + + )} + {intent.language && ( + + 语言: {intent.language} + + )} +
+
+ )} + + {items.length > 0 && ( +
+ {items.map((m) => ( + + ))} +
+ )} +
+ + {/* Recommendations */} +
+
+

为你推荐

+ +
+

+ 推荐基于你的最近观看历史。点击标题在媒体库中查找。 +

+ + {recs && recs.length > 0 && ( +
    + {recs.map((t, i) => ( +
  • + + {t} + + +
  • + ))} +
+ )} + + {recs && recs.length === 0 && ( +

+ 还没有推荐结果 — 先去看几部片子,我再给你挑。 +

+ )} +
+ + {/* Decorative footer (mirrors the Vue page hint that AI runs locally). */} + {!status?.enabled && ( +

+ 提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。 + 管理员可在 API 配置{' '} + 中接入 OpenAI / DeepSeek 等服务以获得更好效果。 +

+ )} +
+ ) +} diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx new file mode 100644 index 0000000..992fdf0 --- /dev/null +++ b/web/src/pages/StrmPage.tsx @@ -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([]) + + // 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([]) + const [drafts, setDrafts] = useState>({}) + + 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 ( +
+
+
+ +
+
+

STRM 管理

+

+ 将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库 +

+
+
+ + {/* Import a new STRM-only entry. */} +
+

导入 STRM 条目

+
+ + setTitle(e.target.value)} + /> + setURL(e.target.value)} + /> + +
+

+ 导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。 +

+
+ + {/* Attach / detach STRM URL on existing media. */} +
+

附加 STRM URL 到已有媒体

+
+ setQuery(e.target.value)} + /> + +
+ + {results.length > 0 && ( +
+ {results.map((m) => { + const isStrm = m.container === 'strm' + return ( +
+
+
+
{m.title}
+
+ {m.year > 0 && `${m.year} · `} + {m.container || '本地文件'} +
+ {isStrm && ( +
+ 已设置 STRM +
+ )} +
+ {isStrm && ( + + )} +
+
+ + setDrafts((d) => ({ ...d, [m.id]: e.target.value })) + } + /> + +
+
+ ) + })} +
+ )} + + {!searching && query && results.length === 0 && ( +

未找到匹配的媒体。

+ )} +
+
+ ) +} diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx new file mode 100644 index 0000000..da5d6df --- /dev/null +++ b/web/src/pages/ToolsPage.tsx @@ -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 ( +
+
+
+ +
+
+

运维工具

+

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

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

整理 & 重命名

+
+

+ 将媒体文件按 媒体库/年份/标题 (年份)/标题.ext{' '} + 的规范布局移动并重命名,确保已先完成刮削。 +

+ +
+ + +
+ +
+
+ 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)} + /> + + +