mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26:36 +08:00
Resolve merge conflicts: merge all Go backend and React frontend changes
This commit is contained in:
@@ -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,221 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
assistantAPI,
|
||||
type AssistantMessage,
|
||||
type AssistantSession,
|
||||
type SessionView,
|
||||
} from '../api/assistant'
|
||||
|
||||
// AssistantChatPage is the multi-turn chat surface backed by the Go
|
||||
// AssistantService. It complements the older AIAssistantPage which is
|
||||
// limited to single-turn smart search + recommendations.
|
||||
export function AssistantChatPage() {
|
||||
const [sessions, setSessions] = useState<AssistantSession[]>([])
|
||||
const [active, setActive] = useState<SessionView | null>(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
const list = await assistantAPI.listSessions()
|
||||
setSessions(list)
|
||||
// Auto-select the most recent if nothing is open.
|
||||
if (list.length > 0 && !active) {
|
||||
const view = await assistantAPI.getSession(list[0].id)
|
||||
setActive(view)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [active?.messages.length])
|
||||
|
||||
const onNew = async () => {
|
||||
try {
|
||||
const sess = await assistantAPI.createSession('New chat')
|
||||
const view = await assistantAPI.getSession(sess.id)
|
||||
setActive(view)
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
toast.error('创建会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSelect = async (id: string) => {
|
||||
try {
|
||||
setActive(await assistantAPI.getSession(id))
|
||||
} catch {
|
||||
toast.error('加载会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!confirm('删除此会话?')) return
|
||||
try {
|
||||
await assistantAPI.deleteSession(id)
|
||||
if (active?.session.id === id) setActive(null)
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSend = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!draft.trim() || !active) return
|
||||
setSending(true)
|
||||
const text = draft.trim()
|
||||
setDraft('')
|
||||
// Optimistic append so the user's turn shows immediately.
|
||||
setActive((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: 'pending-' + Date.now(),
|
||||
session_id: s.session.id,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
} as AssistantMessage,
|
||||
],
|
||||
}
|
||||
: s,
|
||||
)
|
||||
try {
|
||||
const view = await assistantAPI.chat(active.session.id, text)
|
||||
setActive(view)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'发送失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-100px)] grid-cols-[260px_1fr] gap-4">
|
||||
{/* Sessions sidebar */}
|
||||
<aside className="glass-panel flex flex-col overflow-hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-display text-sm font-semibold text-white">会话</h2>
|
||||
<button onClick={onNew} className="neon-button !px-2 !py-1 text-xs">
|
||||
<Plus size={12} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-6 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 space-y-1 overflow-y-auto pr-1">
|
||||
{sessions.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className={
|
||||
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
||||
(active?.session.id === s.id
|
||||
? 'bg-primary-400/10 text-primary-400'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white')
|
||||
}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="flex-1 truncate">{s.title || '未命名'}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(s.id)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} className="text-red-400" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!loading && sessions.length === 0 && (
|
||||
<li className="px-2 py-2 text-xs text-slate-500">暂无会话</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Conversation pane */}
|
||||
<section className="glass-panel flex flex-col overflow-hidden">
|
||||
{!active && (
|
||||
<div className="m-auto text-center text-slate-400">
|
||||
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>选择或创建一个会话开始对话</p>
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<>
|
||||
<div className="mb-3 border-b border-white/5 pb-2">
|
||||
<h2 className="font-display text-base font-semibold text-white">
|
||||
{active.session.title || '未命名'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto pr-2">
|
||||
{active.messages.length === 0 && (
|
||||
<p className="text-sm text-slate-500">说点什么开始对话…</p>
|
||||
)}
|
||||
{active.messages.map((m) => (
|
||||
<Bubble key={m.id} message={m} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-white/5 pt-3">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="发送消息…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button type="submit" disabled={sending || !draft.trim()} className="neon-button">
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({ message }: { message: AssistantMessage }) {
|
||||
const mine = message.role === 'user'
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={
|
||||
'max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2 text-sm ' +
|
||||
(mine
|
||||
? 'bg-primary-400/20 text-primary-100'
|
||||
: message.role === 'system'
|
||||
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
|
||||
: 'bg-white/5 text-slate-200')
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
type DownloadClientInput,
|
||||
type DownloadClientType,
|
||||
} from '../api/download_clients'
|
||||
|
||||
// DownloadClientsPage manages multiple downloader integrations.
|
||||
// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
|
||||
// surface and a per-client Test button.
|
||||
export function DownloadClientsPage() {
|
||||
const [clients, setClients] = useState<DownloadClient[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<DownloadClient | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setClients(await downloadClientsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
try {
|
||||
const r = await downloadClientsAPI.test(id)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (c: DownloadClient) => {
|
||||
if (!confirm(`确定删除「${c.name}」?`)) return
|
||||
try {
|
||||
await downloadClientsAPI.remove(c.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} 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 justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-400/10 text-cyan-300">
|
||||
<Server size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">下载器管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加下载器
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无下载器</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="glass-panel flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{c.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{c.type}
|
||||
</span>
|
||||
{c.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!c.enabled && (
|
||||
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
{c.url}
|
||||
{c.username && ` · ${c.username}`}
|
||||
{c.save_path && ` · ${c.save_path}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(c.id)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(c)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(c)}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ClientFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: DownloadClient | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<DownloadClientInput>(() => ({
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'qbittorrent',
|
||||
url: editing?.url ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
save_path: editing?.save_path ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const update = <K extends keyof DownloadClientInput>(k: K, v: DownloadClientInput[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const placeholder = (
|
||||
{
|
||||
qbittorrent: 'http://127.0.0.1:8080',
|
||||
aria2: 'http://127.0.0.1:6800/jsonrpc',
|
||||
transmission: 'http://127.0.0.1:9091/transmission/rpc',
|
||||
} as Record<DownloadClientType, string>
|
||||
)[form.type]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">
|
||||
{editing ? '编辑下载器' : '添加下载器'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) => update('type', e.target.value as DownloadClientType)}
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="aria2">Aria2 (JSON-RPC)</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={form.url}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{form.type !== 'aria2' && (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.username ?? ''}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'aria2' && (
|
||||
<Field label="RPC Token (作为密码字段保存)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="默认保存路径">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.save_path ?? ''}
|
||||
onChange={(e) => update('save_path', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => update('is_default', e.target.checked)}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
KeySquare,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
licenseAPI,
|
||||
type GenerateKeyInput,
|
||||
type LicenseActivation,
|
||||
type LicenseKey,
|
||||
} from '../api/license'
|
||||
|
||||
// LicensePage is the admin UI for issuing / revoking license keys and
|
||||
// inspecting activations. Mirrors the Vue LicenseTab inside Settings.
|
||||
export function LicensePage() {
|
||||
const [keys, setKeys] = useState<LicenseKey[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showGen, setShowGen] = useState(false)
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
const [activations, setActivations] = useState<Record<string, LicenseActivation[]>>({})
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setKeys(await licenseAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const toggleOpen = async (k: LicenseKey) => {
|
||||
if (openKey === k.id) {
|
||||
setOpenKey(null)
|
||||
return
|
||||
}
|
||||
setOpenKey(k.id)
|
||||
if (!activations[k.id]) {
|
||||
try {
|
||||
const acts = await licenseAPI.listActivations(k.id)
|
||||
setActivations((a) => ({ ...a, [k.id]: acts }))
|
||||
} catch {
|
||||
toast.error('加载激活记录失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onRevoke = async (k: LicenseKey) => {
|
||||
if (!confirm(`确定吊销 ${k.key.slice(0, 14)}…?`)) return
|
||||
try {
|
||||
await licenseAPI.revoke(k.id)
|
||||
toast.success('已吊销')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'吊销失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onUnbind = async (a: LicenseActivation) => {
|
||||
if (!confirm(`解绑设备 ${a.device_name || a.device_id}?`)) return
|
||||
try {
|
||||
await licenseAPI.unbind(a.id)
|
||||
toast.success('已解绑')
|
||||
const acts = await licenseAPI.listActivations(a.key_id)
|
||||
setActivations((all) => ({ ...all, [a.key_id]: acts }))
|
||||
} catch {
|
||||
toast.error('解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
|
||||
<KeySquare 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>
|
||||
<button onClick={() => setShowGen(true)} className="neon-button">
|
||||
<Plus size={16} /> 生成新密钥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && keys.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无密钥</div>
|
||||
)}
|
||||
|
||||
{!loading && keys.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="glass-panel space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-white/5 px-2 py-0.5 text-xs text-primary-400">
|
||||
{k.key}
|
||||
</code>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{k.plan}
|
||||
</span>
|
||||
{k.revoked && (
|
||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
已吊销
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{k.customer && `客户: ${k.customer} · `}
|
||||
最多 {k.max_activations} 设备 ·
|
||||
{k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => toggleOpen(k)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
{openKey === k.id ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{' '}激活记录
|
||||
</button>
|
||||
{!k.revoked && (
|
||||
<button
|
||||
onClick={() => onRevoke(k)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<ShieldOff size={12} className="inline" /> 吊销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{openKey === k.id && (
|
||||
<div className="space-y-2 border-t border-white/5 pt-3">
|
||||
{(activations[k.id] ?? []).length === 0 && (
|
||||
<p className="text-sm text-slate-500">暂无激活记录</p>
|
||||
)}
|
||||
{(activations[k.id] ?? []).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded border border-white/5 bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-white">
|
||||
{a.device_name || a.device_id}
|
||||
{a.unbound_at && (
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
(已解绑 {new Date(a.unbound_at).toLocaleDateString()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{a.ip} · 心跳{' '}
|
||||
{a.heartbeat_at
|
||||
? new Date(a.heartbeat_at).toLocaleString()
|
||||
: '未上报'}
|
||||
</div>
|
||||
</div>
|
||||
{!a.unbound_at && (
|
||||
<button
|
||||
onClick={() => onUnbind(a)}
|
||||
className="shrink-0 rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-red-400/40 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 解绑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGen && (
|
||||
<GenerateModal
|
||||
onClose={() => setShowGen(false)}
|
||||
onCreated={async () => {
|
||||
setShowGen(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GenerateModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<GenerateKeyInput>({
|
||||
customer: '',
|
||||
plan: 'basic',
|
||||
max_activations: 1,
|
||||
expires_at: '',
|
||||
notes: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const k = await licenseAPI.generate({
|
||||
...form,
|
||||
expires_at: form.expires_at ? new Date(form.expires_at).toISOString() : undefined,
|
||||
})
|
||||
toast.success(`已生成: ${k.key}`)
|
||||
await onCreated()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'生成失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-md">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">生成密钥</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">客户</span>
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.customer ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customer: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">套餐</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.plan ?? 'basic'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, plan: e.target.value }))}
|
||||
>
|
||||
<option value="basic">basic</option>
|
||||
<option value="pro">pro</option>
|
||||
<option value="enterprise">enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">最多设备数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-base"
|
||||
value={form.max_activations ?? 1}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, max_activations: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">到期日期 (空 = 永久)</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input-base"
|
||||
value={form.expires_at ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expires_at: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">备注</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base"
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 生成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
notifyChannelsAPI,
|
||||
type NotifyChannelInput,
|
||||
} from '../api/notify_channels'
|
||||
import type { NotifyChannel } from '../types'
|
||||
|
||||
// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
|
||||
// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
|
||||
// fire a test notification on demand.
|
||||
export function NotifyChannelsPage() {
|
||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<NotifyChannel | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setChannels(await notifyChannelsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
try {
|
||||
await notifyChannelsAPI.test(id)
|
||||
toast.success('测试消息已发送')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (ch: NotifyChannel) => {
|
||||
if (!confirm(`确定删除「${ch.name}」?`)) return
|
||||
try {
|
||||
await notifyChannelsAPI.remove(ch.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} 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 justify-between">
|
||||
<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-300">
|
||||
<Bell size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">通知渠道</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && channels.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无通知渠道</div>
|
||||
)}
|
||||
|
||||
{!loading && channels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{channels.map((ch) => (
|
||||
<ChannelCard
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
onTest={() => onTest(ch.id)}
|
||||
onEdit={() => {
|
||||
setEditing(ch)
|
||||
setShowForm(true)
|
||||
}}
|
||||
onDelete={() => onDelete(ch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ChannelFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<NotifyChannel['channel_type'], string> = {
|
||||
telegram: 'Telegram',
|
||||
wechat: '企业微信',
|
||||
bark: 'Bark',
|
||||
webhook: 'Webhook',
|
||||
}
|
||||
|
||||
function ChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const summary = channelSummary(channel)
|
||||
return (
|
||||
<div className="glass-panel flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{channel.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">{summary}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function channelSummary(ch: NotifyChannel): string {
|
||||
const cfg = ch.config ?? {}
|
||||
switch (ch.channel_type) {
|
||||
case 'telegram':
|
||||
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
|
||||
case 'wechat':
|
||||
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
|
||||
case 'bark':
|
||||
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
|
||||
case 'webhook':
|
||||
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_CONFIG: Record<NotifyChannel['channel_type'], Record<string, string>> = {
|
||||
telegram: { bot_token: '', chat_id: '' },
|
||||
wechat: { sendkey: '' },
|
||||
bark: { device_key: '', server: '' },
|
||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||
}
|
||||
|
||||
function ChannelFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: NotifyChannel | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState(editing?.name ?? '')
|
||||
const [type, setType] = useState<NotifyChannel['channel_type']>(
|
||||
editing?.channel_type ?? 'telegram',
|
||||
)
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
editing?.config ?? EMPTY_CONFIG.telegram,
|
||||
)
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onTypeChange = (t: NotifyChannel['channel_type']) => {
|
||||
setType(t)
|
||||
setConfig({ ...EMPTY_CONFIG[t] })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const input: NotifyChannelInput = {
|
||||
name: name.trim(),
|
||||
channel_type: type,
|
||||
config,
|
||||
enabled,
|
||||
}
|
||||
if (editing) {
|
||||
await notifyChannelsAPI.update(editing.id, input)
|
||||
} else {
|
||||
await notifyChannelsAPI.create(input)
|
||||
}
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (k: string, v: string) =>
|
||||
setConfig((c) => ({ ...c, [k]: v }))
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">
|
||||
{editing ? '编辑通知渠道' : '添加通知渠道'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="如: Telegram 通知"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="渠道类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={type}
|
||||
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['channel_type'])}
|
||||
>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="wechat">企业微信 / Server酱</option>
|
||||
<option value="bark">Bark (iOS)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{type === 'telegram' && (
|
||||
<>
|
||||
<Field label="Bot Token">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="123456:ABC-DEF…"
|
||||
value={config.bot_token ?? ''}
|
||||
onChange={(e) => updateConfig('bot_token', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Chat ID">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="-100123456"
|
||||
value={config.chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'wechat' && (
|
||||
<Field label="SendKey">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="SCT…"
|
||||
value={config.sendkey ?? ''}
|
||||
onChange={(e) => updateConfig('sendkey', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === 'bark' && (
|
||||
<>
|
||||
<Field label="设备 Key">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.device_key ?? ''}
|
||||
onChange={(e) => updateConfig('device_key', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="服务器地址 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="https://api.day.app"
|
||||
value={config.server ?? ''}
|
||||
onChange={(e) => updateConfig('server', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'webhook' && (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="https://example.com/notify"
|
||||
value={config.url ?? ''}
|
||||
onChange={(e) => updateConfig('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Method">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.method ?? 'POST'}
|
||||
onChange={(e) => updateConfig('method', e.target.value)}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Headers (JSON)">
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"Content-Type":"application/json"}'
|
||||
value={config.headers ?? ''}
|
||||
onChange={(e) => updateConfig('headers', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Body 模板 (支持 {{title}} {{message}})">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"title":"{{title}}","message":"{{message}}"}'
|
||||
value={config.body_template ?? ''}
|
||||
onChange={(e) => updateConfig('body_template', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Trash2, UserCog } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
|
||||
// ProfileManagementPage replicates the Vue ProfileManagementView. It
|
||||
// lets a user (or admin) define multiple "viewing personas" with
|
||||
// different content-rating gates, library access, and player defaults.
|
||||
//
|
||||
// All persistence is real: data is written to /api/play-profiles which
|
||||
// is backed by the Go PlayProfileService.
|
||||
export function ProfileManagementPage() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const userID = useAuthStore((s) => s.user?.id ?? '')
|
||||
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<PlayProfile | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [p, l] = await Promise.all([
|
||||
playProfilesAPI.list(isAdmin),
|
||||
libraryAPI.list().catch(() => [] as Library[]),
|
||||
])
|
||||
setProfiles(p)
|
||||
setLibraries(l)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [isAdmin])
|
||||
|
||||
const onDelete = async (p: PlayProfile) => {
|
||||
if (!confirm(`确定删除 Profile「${p.name}」?`)) return
|
||||
try {
|
||||
await playProfilesAPI.remove(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (p: PlayProfile) => {
|
||||
setEditing(p)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-400/10 text-purple-400">
|
||||
<UserCog size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">观影 Profile</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={openCreate} className="neon-button">
|
||||
<Plus size={16} /> 创建 Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && profiles.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center">
|
||||
<div className="mb-2 text-4xl">👤</div>
|
||||
<p className="font-medium text-white">暂无 Profile</p>
|
||||
<p className="text-sm text-slate-400">点击右上角"创建 Profile"开始</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && profiles.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{profiles.map((p) => (
|
||||
<ProfileCard
|
||||
key={p.id}
|
||||
profile={p}
|
||||
libraries={libraries}
|
||||
onEdit={() => openEdit(p)}
|
||||
onDelete={() => onDelete(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ProfileFormModal
|
||||
editing={editing}
|
||||
libraries={libraries}
|
||||
defaultUserID={userID}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
profile,
|
||||
libraries,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: PlayProfile
|
||||
libraries: Library[]
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const libNames = useMemo(() => {
|
||||
if (!profile.allowed_library_ids?.length) return '全部'
|
||||
const idx = new Map(libraries.map((l) => [l.id, l.name]))
|
||||
return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ')
|
||||
}, [profile, libraries])
|
||||
|
||||
return (
|
||||
<div className="glass-panel flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||
style={{
|
||||
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
|
||||
}}
|
||||
>
|
||||
{profile.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-white">{profile.name}</span>
|
||||
{profile.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{profile.allow_adult && (
|
||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
成人内容
|
||||
</span>
|
||||
)}
|
||||
{profile.require_pin && (
|
||||
<span className="rounded bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
🔒 PIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||
{profile.content_rating_limit && <span>分级: {profile.content_rating_limit}</span>}
|
||||
<span>媒体库: {libNames}</span>
|
||||
<span>用户: {profile.user_id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
|
||||
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileFormModal({
|
||||
editing,
|
||||
libraries,
|
||||
defaultUserID,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: PlayProfile | null
|
||||
libraries: Library[]
|
||||
defaultUserID: string
|
||||
isAdmin: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<PlayProfileInput>(() => ({
|
||||
user_id: editing?.user_id ?? defaultUserID,
|
||||
name: editing?.name ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
content_rating_limit: editing?.content_rating_limit ?? '',
|
||||
allow_adult: editing?.allow_adult ?? false,
|
||||
require_pin: editing?.require_pin ?? false,
|
||||
pin: '',
|
||||
preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '',
|
||||
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
|
||||
autoplay_next: editing?.autoplay_next ?? true,
|
||||
skip_intro: editing?.skip_intro ?? false,
|
||||
allowed_library_ids: editing?.allowed_library_ids ?? [],
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const update = (patch: Partial<PlayProfileInput>) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const toggleLib = (id: string) => {
|
||||
const next = form.allowed_library_ids.includes(id)
|
||||
? form.allowed_library_ids.filter((x) => x !== id)
|
||||
: [...form.allowed_library_ids, id]
|
||||
update({ allowed_library_ids: next })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await playProfilesAPI.update(editing.id, form)
|
||||
} else {
|
||||
await playProfilesAPI.create(form)
|
||||
}
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">
|
||||
{editing ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{!editing && isAdmin && (
|
||||
<Field label="用户 ID">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.user_id ?? ''}
|
||||
onChange={(e) => update({ user_id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
maxLength={50}
|
||||
placeholder="如:儿童模式、影院模式"
|
||||
value={form.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="设为默认 Profile"
|
||||
hint="登录后默认使用"
|
||||
checked={form.is_default}
|
||||
onChange={(v) => update({ is_default: v })}
|
||||
/>
|
||||
|
||||
<Field label="内容分级限制">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.content_rating_limit ?? ''}
|
||||
onChange={(e) => update({ content_rating_limit: e.target.value })}
|
||||
>
|
||||
<option value="">不限制</option>
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NC-17">NC-17</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="允许成人内容"
|
||||
hint="开启后可访问 NSFW 媒体"
|
||||
checked={form.allow_adult}
|
||||
onChange={(v) => update({ allow_adult: v })}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label="切换时需要 PIN"
|
||||
hint="切换到此 Profile 需输入 PIN 码"
|
||||
checked={form.require_pin}
|
||||
onChange={(v) => update({ require_pin: v })}
|
||||
/>
|
||||
{form.require_pin && (
|
||||
<Field label="PIN 码 (4-8 位)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
maxLength={8}
|
||||
placeholder={editing ? '留空保持不变' : '设置 PIN'}
|
||||
value={form.pin ?? ''}
|
||||
onChange={(e) => update({ pin: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="首选字幕">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_subtitle_lang ?? ''}
|
||||
onChange={(e) => update({ preferred_subtitle_lang: e.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ja">日语</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="首选音轨">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_audio_lang ?? ''}
|
||||
onChange={(e) => update({ preferred_audio_lang: e.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日语</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="自动播放下一集"
|
||||
checked={form.autoplay_next}
|
||||
onChange={(v) => update({ autoplay_next: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label="自动跳过片头"
|
||||
checked={form.skip_intro}
|
||||
onChange={(v) => update({ skip_intro: v })}
|
||||
/>
|
||||
|
||||
<Field label="允许访问的媒体库 (空 = 全部)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{libraries.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggleLib(l.id)}
|
||||
className={
|
||||
'rounded-full border px-3 py-1 text-xs ' +
|
||||
(form.allowed_library_ids.includes(l.id)
|
||||
? 'border-primary-400/60 bg-primary-400/10 text-primary-400'
|
||||
: 'border-white/10 text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
{libraries.length === 0 && (
|
||||
<span className="text-xs text-slate-500">暂无媒体库</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm text-white">{label}</div>
|
||||
{hint && <div className="text-xs text-slate-400">{hint}</div>}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import type { Setting } from '../types'
|
||||
|
||||
// SettingsPage replaces the Vue SettingsView's General / Organize /
|
||||
// Scrape / Adult tabs. The Go backend stores settings as a single
|
||||
// key/value table; we group the most useful keys client-side and let
|
||||
// the operator edit them with typed widgets (select / toggle / input).
|
||||
//
|
||||
// Any key not in the curated list still works via the AdminPage generic
|
||||
// key/value editor — this page is the curated UX over the same store.
|
||||
interface SettingDef {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
|
||||
hint?: string
|
||||
options?: { value: string; label: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SettingGroup {
|
||||
key: string
|
||||
label: string
|
||||
description?: string
|
||||
items: SettingDef[]
|
||||
}
|
||||
|
||||
const GROUPS: SettingGroup[] = [
|
||||
{
|
||||
key: 'general',
|
||||
label: '常规',
|
||||
description: 'TMDb / 转码引擎默认参数',
|
||||
items: [
|
||||
{
|
||||
key: 'tmdb.language',
|
||||
label: 'TMDb 元数据语言',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁体中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'ja-JP', label: '日本語' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transcode.enabled',
|
||||
label: '启用转码',
|
||||
type: 'toggle',
|
||||
hint: '关闭后所有视频直连播放',
|
||||
},
|
||||
{
|
||||
key: 'transcode.hw_accel',
|
||||
label: '硬件加速',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'auto', label: '自动检测' },
|
||||
{ value: 'none', label: '软件转码' },
|
||||
{ value: 'nvenc', label: 'NVIDIA NVENC' },
|
||||
{ value: 'qsv', label: 'Intel QSV' },
|
||||
{ value: 'vaapi', label: 'VAAPI (Linux)' },
|
||||
{ value: 'videotoolbox', label: 'VideoToolbox (macOS)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transcode.max_jobs',
|
||||
label: '最大并发转码任务',
|
||||
type: 'number',
|
||||
hint: '建议 1-4',
|
||||
},
|
||||
{
|
||||
key: 'ffmpeg.path',
|
||||
label: 'FFmpeg 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffmpeg',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.path',
|
||||
label: 'FFprobe 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffprobe',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'organize',
|
||||
label: '整理 & 刮削',
|
||||
description: '媒体文件命名 + 自动刮削开关',
|
||||
items: [
|
||||
{
|
||||
key: 'organize.auto',
|
||||
label: '入库时自动整理',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'organize.movie_format',
|
||||
label: '电影命名格式',
|
||||
type: 'text',
|
||||
hint: '例: {title} ({year})/{title} ({year})',
|
||||
placeholder: '{title} ({year})/{title} ({year})',
|
||||
},
|
||||
{
|
||||
key: 'organize.tv_format',
|
||||
label: '剧集命名格式',
|
||||
type: 'text',
|
||||
placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
|
||||
},
|
||||
{
|
||||
key: 'organize.anime_format',
|
||||
label: '动漫命名格式',
|
||||
type: 'text',
|
||||
placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
|
||||
},
|
||||
{
|
||||
key: 'scrape.auto_on_scan',
|
||||
label: '扫描后自动刮削',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'scrape.providers',
|
||||
label: '刮削源优先级',
|
||||
type: 'text',
|
||||
hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
|
||||
placeholder: 'tmdb,bangumi,thetvdb,fanart',
|
||||
},
|
||||
{
|
||||
key: 'scrape.language',
|
||||
label: '刮削首选语言',
|
||||
type: 'text',
|
||||
placeholder: 'zh-CN',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'adult',
|
||||
label: 'Adult / NSFW',
|
||||
description: '成人内容隔离开关 (默认隐藏)',
|
||||
items: [
|
||||
{
|
||||
key: 'adult.enabled',
|
||||
label: '启用成人内容',
|
||||
type: 'toggle',
|
||||
hint: '关闭后 NSFW 媒体不会出现在列表中',
|
||||
},
|
||||
{
|
||||
key: 'adult.require_pin',
|
||||
label: '访问需要 PIN',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'adult.pin',
|
||||
label: 'PIN 码',
|
||||
type: 'text',
|
||||
hint: '4-8 位数字',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qbittorrent',
|
||||
label: 'qBittorrent',
|
||||
description: '默认下载器配置',
|
||||
items: [
|
||||
{ key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' },
|
||||
{ key: 'qbittorrent.username', label: '用户名', type: 'text' },
|
||||
{ key: 'qbittorrent.password', label: '密码', type: 'text' },
|
||||
{ key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
|
||||
|
||||
export function SettingsPage() {
|
||||
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [dirty, setDirty] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const all = await adminAPI.listSettings()
|
||||
const idx: Record<string, string> = {}
|
||||
for (const s of all as Setting[]) {
|
||||
if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
|
||||
}
|
||||
setValues(idx)
|
||||
setDirty(new Set())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onChange = (key: string, value: string) => {
|
||||
setValues((v) => ({ ...v, [key]: value }))
|
||||
setDirty((d) => new Set(d).add(key))
|
||||
}
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (dirty.size === 0) return
|
||||
setSaving(true)
|
||||
try {
|
||||
// Backend exposes a single-key updater; loop through dirty keys.
|
||||
for (const key of dirty) {
|
||||
await adminAPI.updateSetting(key, values[key] ?? '')
|
||||
}
|
||||
toast.success(`已保存 ${dirty.size} 项配置`)
|
||||
setDirty(new Set())
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const group = GROUPS.find((g) => g.key === activeGroup)!
|
||||
|
||||
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-slate-400/10 text-slate-300">
|
||||
<SettingsIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">系统设置</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
按分组编辑 TMDb / 整理 / 刮削 / 下载器等关键配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto border-b border-white/10">
|
||||
{GROUPS.map((g) => (
|
||||
<button
|
||||
key={g.key}
|
||||
onClick={() => setActiveGroup(g.key)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm whitespace-nowrap transition ' +
|
||||
(activeGroup === g.key
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-slate-500">{group.description}</p>}
|
||||
{group.items.map((it) => (
|
||||
<SettingRow
|
||||
key={it.key}
|
||||
def={it}
|
||||
value={values[it.key] ?? ''}
|
||||
onChange={(v) => onChange(it.key, v)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-slate-500">
|
||||
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || dirty.size === 0}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
def,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
def: SettingDef
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="font-medium">{def.label}</div>
|
||||
{def.hint && <div className="mt-0.5 text-xs text-slate-500">{def.hint}</div>}
|
||||
<div className="mt-0.5 font-mono text-[10px] text-slate-600">{def.key}</div>
|
||||
</label>
|
||||
<div>
|
||||
{def.type === 'text' && (
|
||||
<input
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'textarea' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'select' && (
|
||||
<select className="input-base" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">(未设置)</option>
|
||||
{def.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{def.type === 'toggle' && (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={value === 'true' || value === '1' || value === 'on'}
|
||||
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
<span className="text-sm text-slate-300">{value === 'true' ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Download, Search } from 'lucide-react'
|
||||
|
||||
import { sitesAPI, type SiteSearchResult } from '../api/sites'
|
||||
import { downloadsAPI } from '../api/downloads'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n || n <= 0) return '—'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
|
||||
// SiteSearchPage performs cross-site torrent search across all enabled
|
||||
// sites and displays a merged result table sorted by seeders.
|
||||
export function SiteSearchPage() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [results, setResults] = useState<SiteSearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!keyword.trim()) return
|
||||
setLoading(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
const data = await sitesAPI.search(keyword.trim())
|
||||
setResults(data.items || [])
|
||||
if ((data.items || []).length === 0) {
|
||||
toast('未找到结果,请检查站点配置或更换关键词')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'搜索失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTorrent = async (item: SiteSearchResult) => {
|
||||
const url = item.download_url || item.torrent_url
|
||||
if (!url) {
|
||||
toast.error('无可用下载链接')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await downloadsAPI.add(url)
|
||||
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'下载失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Search className="h-6 w-6 text-primary-400" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">站点搜索</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base flex-1"
|
||||
placeholder="输入关键词搜索(如: 流浪地球 / Dune 2024)"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={loading} className="neon-button">
|
||||
{loading ? '搜索中…' : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-slate-500">正在查询所有站点…</p>}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">站点</th>
|
||||
<th>标题</th>
|
||||
<th>大小</th>
|
||||
<th>S</th>
|
||||
<th>L</th>
|
||||
<th>Free</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((item, idx) => (
|
||||
<tr key={idx} className="border-t border-white/5 align-top">
|
||||
<td className="py-2 text-primary-400">{item.site_name}</td>
|
||||
<td className="max-w-md py-2">
|
||||
<a
|
||||
href={item.torrent_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white transition hover:text-primary-400"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title.length > 80
|
||||
? item.title.substring(0, 80) + '…'
|
||||
: item.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-slate-300">
|
||||
{fmtBytes(item.size)}
|
||||
</td>
|
||||
<td className="text-emerald-400">{item.seeders || '—'}</td>
|
||||
<td className="text-red-400">{item.leechers || '—'}</td>
|
||||
<td>
|
||||
{item.free && (
|
||||
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||
Free
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => downloadTorrent(item)}
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
title="加入下载"
|
||||
>
|
||||
<Download size={12} className="inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Cloud, Loader2, Save, Send } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { storageAPI, type StorageType } from '../api/storage_config'
|
||||
|
||||
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
|
||||
// the import / playback / STRM subsystems. Mirrors the Vue UI's
|
||||
// `admin/storage/*` tabs in a tabbed React surface.
|
||||
export function StorageConfigPage() {
|
||||
const [active, setActive] = useState<StorageType>('alist')
|
||||
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-blue-400/10 text-blue-300">
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">外部存储</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActive(t)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm uppercase ' +
|
||||
(active === t
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StorageForm key={active} type={active} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boolean; placeholder?: string }[]> = {
|
||||
alist: [
|
||||
{ key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
|
||||
{ key: 'token', label: 'Token', secret: true },
|
||||
],
|
||||
webdav: [
|
||||
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'password', label: '密码', secret: true },
|
||||
],
|
||||
s3: [
|
||||
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
|
||||
{ key: 'region', label: 'Region', placeholder: 'us-east-1' },
|
||||
{ key: 'bucket', label: 'Bucket' },
|
||||
{ key: 'access_key', label: 'Access Key', secret: true },
|
||||
{ key: 'secret_key', label: 'Secret Key', secret: true },
|
||||
{ key: 'force_path_style', label: 'force_path_style (true/false)' },
|
||||
],
|
||||
}
|
||||
|
||||
function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
const v = r.config?.[f.key]
|
||||
// List() redacts secrets to "********"; show empty so the user
|
||||
// doesn't accidentally save the placeholder.
|
||||
next[f.key] = v === '********' ? '' : v ?? ''
|
||||
}
|
||||
setConfig(next)
|
||||
setEnabled(r.enabled)
|
||||
} catch {
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) next[f.key] = ''
|
||||
setConfig(next)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await storageAPI.save(type, config, enabled)
|
||||
toast.success('已保存')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await storageAPI.test(type, config)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-slate-600">{f.key}</span>
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
className="input-base"
|
||||
placeholder={f.placeholder}
|
||||
value={config[f.key] ?? ''}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||
{' '}测试
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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