mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
feat: site management + cross-site torrent search
Port the complete site management system from the original MediaStation:
Model:
- model/model.go: Added Site table with fields for name, base_url,
site_type (nexusphp/gazelle/unit3d/mteam/custom_rss), auth_type
(cookie/api_key/authorization), cookie, api_key, auth_header,
user_agent, rss_url, timeout, priority, use_proxy, enabled,
login_status, downloader. Registered in AllModels().
Backend services:
- service/site.go: SiteService with CRUD (Create/List/FindByID/Update/
Delete), TestConnection (validates credentials via HTTP, updates
login_status in DB), and Search (cross-site fan-out that queries
every enabled site adapter and returns merged results sorted by
seeders descending).
- service/site_adapter.go: SiteAdapter interface + NewSiteAdapter
factory + 5 implementations:
* nexusPhpAdapter — regex HTML scraping of torrents.php
* gazelleAdapter — JSON API /ajax.php?action=browse
* unit3dAdapter — REST API /api/torrents/filter
* mteamAdapter — M-Team v3 POST /api/torrent/search with x-api-key
* rssAdapter — XML RSS/Atom feed parsing with keyword filter
Handler + routes:
- handler/sites.go: listSites, getSite, createSite, updateSite,
deleteSite, testSite, siteSearch handlers.
- handler/handler.go: 7 new routes under authed group:
GET/POST /sites, GET/PUT/DELETE /sites/:id,
POST /sites/:id/test, GET /sites/search.
Frontend:
- api/sites.ts: typed interfaces (Site, SiteSearchResult,
CreateSiteInput) and sitesAPI helper.
- pages/SitesPage.tsx: full CRUD UI with add-site form (site_type +
auth_type selectors, cookie/api_key/rss inputs), connection test
button with live status indicator, delete action.
- pages/SiteSearchPage.tsx: cross-site search with keyword input,
merged result table (site/title/size/seeders/leechers/free),
one-click download-to-qBittorrent button.
- App.tsx: lazy-loaded routes /sites and /site-search.
- Layout.tsx: sidebar links 站点管理 + 站点搜索 under 自动化 group.
Verified: go build + go vet + go test pass; tsc -b + vite build passes
(32 lazy chunks, main bundle 255 KB / 85 KB gzipped).
This commit is contained in:
@@ -69,6 +69,12 @@ const WatchHistoryPage = lazy(() =>
|
||||
const PosterWallPage = lazy(() =>
|
||||
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
|
||||
)
|
||||
const SitesPage = lazy(() =>
|
||||
import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })),
|
||||
)
|
||||
const SiteSearchPage = lazy(() =>
|
||||
import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -100,6 +106,8 @@ export default function App() {
|
||||
<Route path="dlna" element={<DlnaPage />} />
|
||||
<Route path="history" element={<WatchHistoryPage />} />
|
||||
<Route path="poster-wall" element={<PosterWallPage />} />
|
||||
<Route path="sites" element={<SitesPage />} />
|
||||
<Route path="site-search" element={<SiteSearchPage />} />
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
base_url: string
|
||||
site_type: string
|
||||
auth_type: string
|
||||
cookie?: string
|
||||
api_key?: string
|
||||
auth_header?: string
|
||||
user_agent?: string
|
||||
rss_url?: string
|
||||
timeout: number
|
||||
priority: number
|
||||
use_proxy: boolean
|
||||
enabled: boolean
|
||||
login_status: string
|
||||
downloader?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SiteSearchResult {
|
||||
site_name: string
|
||||
site_id: string
|
||||
title: string
|
||||
torrent_url: string
|
||||
download_url: string
|
||||
size: number
|
||||
seeders: number
|
||||
leechers: number
|
||||
free: boolean
|
||||
}
|
||||
|
||||
export interface CreateSiteInput {
|
||||
name: string
|
||||
base_url: string
|
||||
site_type?: string
|
||||
auth_type?: string
|
||||
cookie?: string
|
||||
api_key?: string
|
||||
auth_header?: string
|
||||
user_agent?: string
|
||||
rss_url?: string
|
||||
timeout?: number
|
||||
priority?: number
|
||||
use_proxy?: boolean
|
||||
enabled?: boolean
|
||||
downloader?: string
|
||||
}
|
||||
|
||||
export const sitesAPI = {
|
||||
list: () => api.get<{ items: Site[] }>('/sites').then((r) => r.data.items),
|
||||
|
||||
get: (id: string) => api.get<Site>(`/sites/${id}`).then((r) => r.data),
|
||||
|
||||
create: (input: CreateSiteInput) =>
|
||||
api.post<Site>('/sites', input).then((r) => r.data),
|
||||
|
||||
update: (id: string, patch: Partial<CreateSiteInput>) =>
|
||||
api.put<Site>(`/sites/${id}`, patch).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/sites/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api
|
||||
.post<{ success: boolean; message: string }>(`/sites/${id}/test`)
|
||||
.then((r) => r.data),
|
||||
|
||||
search: (keyword: string) =>
|
||||
api
|
||||
.get<{ items: SiteSearchResult[]; total: number }>('/sites/search', {
|
||||
params: { keyword },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
GalleryHorizontalEnd,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
@@ -86,6 +87,8 @@ export function Layout() {
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
||||
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
账号
|
||||
|
||||
@@ -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,258 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Globe, Plus, Trash2, Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
import { sitesAPI, type Site } from '../api/sites'
|
||||
|
||||
const SITE_TYPES = [
|
||||
{ value: 'nexusphp', label: 'NexusPHP (大多数国内PT站)' },
|
||||
{ value: 'gazelle', label: 'Gazelle (HDBits/OPS/RED)' },
|
||||
{ value: 'unit3d', label: 'UNIT3D (BeyondHD/BluTopia)' },
|
||||
{ value: 'mteam', label: 'M-Team (馒头)' },
|
||||
{ value: 'custom_rss', label: '自定义 RSS' },
|
||||
]
|
||||
|
||||
const AUTH_TYPES = [
|
||||
{ value: 'cookie', label: 'Cookie' },
|
||||
{ value: 'api_key', label: 'API Key / Passkey' },
|
||||
{ value: 'authorization', label: 'Authorization Header' },
|
||||
]
|
||||
|
||||
// SitesPage manages PT/BT tracker sites — CRUD + connection test.
|
||||
export function SitesPage() {
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = () =>
|
||||
sitesAPI
|
||||
.list()
|
||||
.then(setSites)
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
const testSite = async (id: string) => {
|
||||
try {
|
||||
const r = await sitesAPI.test(id)
|
||||
if (r.success) {
|
||||
toast.success(r.message)
|
||||
} else {
|
||||
toast.error(r.message)
|
||||
}
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('连接测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (site: Site) => {
|
||||
if (!confirm(`确定删除站点「${site.name}」?`)) return
|
||||
await sitesAPI.remove(site.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe 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>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(!showForm)} className="neon-button">
|
||||
<Plus size={16} /> 添加站点
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{showForm && <AddSiteForm onCreated={() => { setShowForm(false); refresh() }} />}
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && sites.length === 0 && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
还没有配置任何站点。点击"添加站点"开始配置你的 PT/BT 资源站。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sites.map((site) => (
|
||||
<div key={site.id} className="glass-panel flex items-center gap-4 !p-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-white">{site.name}</p>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 text-xs text-slate-400">
|
||||
{site.site_type}
|
||||
</span>
|
||||
{site.login_status === 'ok' && (
|
||||
<Wifi size={14} className="text-emerald-400" />
|
||||
)}
|
||||
{site.login_status === 'fail' && (
|
||||
<WifiOff size={14} className="text-red-400" />
|
||||
)}
|
||||
{!site.enabled && (
|
||||
<span className="rounded border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-400">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">{site.base_url}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => testSite(site.id)}
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSite(site)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddSiteForm({ onCreated }: { onCreated: () => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [baseURL, setBaseURL] = useState('')
|
||||
const [siteType, setSiteType] = useState('nexusphp')
|
||||
const [authType, setAuthType] = useState('cookie')
|
||||
const [cookie, setCookie] = useState('')
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [rssURL, setRSSURL] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await sitesAPI.create({
|
||||
name,
|
||||
base_url: baseURL,
|
||||
site_type: siteType,
|
||||
auth_type: authType,
|
||||
cookie: authType === 'cookie' ? cookie : undefined,
|
||||
api_key: authType === 'api_key' ? apiKey : undefined,
|
||||
rss_url: siteType === 'custom_rss' ? rssURL : undefined,
|
||||
})
|
||||
toast.success('站点已添加')
|
||||
onCreated()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'添加失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="glass-panel space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">站点名称</span>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="如: 馒头 / HDSky / OPS"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">站点地址</span>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="https://example.com"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">站点类型</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={siteType}
|
||||
onChange={(e) => setSiteType(e.target.value)}
|
||||
>
|
||||
{SITE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">认证方式</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={authType}
|
||||
onChange={(e) => setAuthType(e.target.value)}
|
||||
>
|
||||
{AUTH_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{authType === 'cookie' && (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">Cookie</span>
|
||||
<textarea
|
||||
className="input-base min-h-[60px]"
|
||||
placeholder="从浏览器 F12 → Network → Cookie 复制"
|
||||
value={cookie}
|
||||
onChange={(e) => setCookie(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{authType === 'api_key' && (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">API Key / Passkey</span>
|
||||
<input
|
||||
className="input-base"
|
||||
type="password"
|
||||
placeholder="站点生成的 API Token"
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{siteType === 'custom_rss' && (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">RSS 地址</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="https://example.com/rss.xml"
|
||||
value={rssURL}
|
||||
onChange={(e) => setRSSURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving ? '添加中…' : '添加站点'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user