mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
import toast from 'react-hot-toast'
|
||
import { ArrowLeft, Bug, Download, FileUp, KeyRound, Loader2, Play, Trash2, X } from 'lucide-react'
|
||
|
||
import { readerAPI, type ReaderSource } from '../../api/reader'
|
||
import { READER_SOURCE_TYPES } from './sourceTypes'
|
||
import SourceLoginDialog from './SourceLoginDialog'
|
||
|
||
// 书源管理页(仿 legado 书源列表:启停开关、快速调试、导入)。
|
||
|
||
// 导入书源文件大小上限(legado 全量导出通常 < 10MB)
|
||
const MAX_IMPORT_FILE_BYTES = 32 << 20
|
||
|
||
function hostOf(url: string): string {
|
||
try {
|
||
return new URL(url).host
|
||
} catch {
|
||
return url
|
||
}
|
||
}
|
||
|
||
function fmtSize(bytes: number): string {
|
||
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`
|
||
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`
|
||
return `${bytes}B`
|
||
}
|
||
|
||
export default function ReaderSourcesPage() {
|
||
const navigate = useNavigate()
|
||
const [sources, setSources] = useState<ReaderSource[] | null>(null)
|
||
const [importText, setImportText] = useState('')
|
||
const [importFile, setImportFile] = useState<{ name: string; text: string; size: number } | null>(null)
|
||
const [dragOver, setDragOver] = useState(false)
|
||
const [importing, setImporting] = useState(false)
|
||
const [showImport, setShowImport] = useState(false)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const [debugId, setDebugId] = useState('')
|
||
const [debugKey, setDebugKey] = useState('')
|
||
const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
|
||
const [debugging, setDebugging] = useState(false)
|
||
const [batchKey, setBatchKey] = useState('')
|
||
const [batchTesting, setBatchTesting] = useState(false)
|
||
const [batchResults, setBatchResults] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||
// 登录面板:记录正在登录的书源
|
||
const [loginSource, setLoginSource] = useState<ReaderSource | null>(null)
|
||
const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({})
|
||
|
||
const load = () => {
|
||
readerAPI
|
||
.listSources()
|
||
.then(setSources)
|
||
.catch(() => toast.error('加载书源失败'))
|
||
}
|
||
useEffect(load, [])
|
||
|
||
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
|
||
const readFile = async (file: File) => {
|
||
if (file.size > MAX_IMPORT_FILE_BYTES) {
|
||
toast.error('文件过大(超过 32MB)')
|
||
return
|
||
}
|
||
try {
|
||
const text = (await file.text()).replace(/^\uFEFF/, '')
|
||
setImportFile({ name: file.name, text, size: file.size })
|
||
} catch {
|
||
toast.error('读取文件失败')
|
||
}
|
||
}
|
||
|
||
const doImport = async () => {
|
||
const payload = importFile?.text ?? importText
|
||
if (!payload.trim() || importing) return
|
||
setImporting(true)
|
||
try {
|
||
const imported = await readerAPI.importSources(payload)
|
||
toast.success(`成功导入 ${imported} 个书源`)
|
||
setImportText('')
|
||
setImportFile(null)
|
||
setShowImport(false)
|
||
load()
|
||
} catch (e) {
|
||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败'
|
||
toast.error(msg)
|
||
} finally {
|
||
setImporting(false)
|
||
}
|
||
}
|
||
|
||
const toggleEnabled = async (src: ReaderSource) => {
|
||
try {
|
||
await readerAPI.setSourceEnabled(src.id, !src.enabled)
|
||
setSources((prev) => prev?.map((s) => (s.id === src.id ? { ...s, enabled: !s.enabled } : s)) ?? null)
|
||
} catch {
|
||
toast.error('更新失败')
|
||
}
|
||
}
|
||
|
||
const removeSource = async (src: ReaderSource) => {
|
||
if (!window.confirm(`删除书源「${src.name}」?`)) return
|
||
try {
|
||
await readerAPI.deleteSource(src.id)
|
||
setSources((prev) => prev?.filter((s) => s.id !== src.id) ?? null)
|
||
toast.success('已删除')
|
||
} catch {
|
||
toast.error('删除失败')
|
||
}
|
||
}
|
||
|
||
const doDebug = async (src: ReaderSource) => {
|
||
if (!debugKey.trim()) {
|
||
toast.error('先填一个搜索关键词')
|
||
return
|
||
}
|
||
setDebugging(true)
|
||
setDebugLogs(null)
|
||
try {
|
||
const logs = await readerAPI.debugSource(src.id, debugKey.trim())
|
||
setDebugLogs(logs)
|
||
} catch (e) {
|
||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '调试失败'
|
||
toast.error(msg)
|
||
} finally {
|
||
setDebugging(false)
|
||
}
|
||
}
|
||
|
||
const testEnabledSources = async () => {
|
||
const key = batchKey.trim()
|
||
const enabled = (sources ?? []).filter((source) => source.enabled)
|
||
if (!key) { toast.error('先填写用于检测的书名关键词'); return }
|
||
if (!enabled.length) { toast.error('没有已启用的书源'); return }
|
||
setBatchTesting(true)
|
||
setBatchResults({})
|
||
for (let offset = 0; offset < enabled.length; offset += 3) {
|
||
const batch = enabled.slice(offset, offset + 3)
|
||
const results = await Promise.all(batch.map(async (source) => {
|
||
try {
|
||
const logs = await readerAPI.smokeSource(source.id, key)
|
||
const error = logs.find((line) => line.startsWith('[错误]'))
|
||
return [source.id, { ok: !error, message: error ? error.replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]') : '搜索、目录和正文链路通过' }] as const
|
||
} catch (error) {
|
||
const message = ((error as { response?: { data?: { error?: string } } })?.response?.data?.error || '检测超时或请求失败').replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]')
|
||
return [source.id, { ok: false, message }] as const
|
||
}
|
||
}))
|
||
setBatchResults((current) => ({ ...current, ...Object.fromEntries(results) }))
|
||
}
|
||
setBatchTesting(false)
|
||
}
|
||
|
||
return (
|
||
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate(-1)}
|
||
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||
>
|
||
<ArrowLeft size={18} />
|
||
</button>
|
||
<h1 className="flex-1 font-display text-lg text-ink-600">书源管理</h1>
|
||
<Link to="/reader/replace" className="btn-outline mr-2 text-xs">
|
||
替换净化
|
||
</Link>
|
||
<button type="button" onClick={() => setShowImport((v) => !v)} className="btn-primary text-xs">
|
||
<Download size={13} className="mr-1 inline" /> 导入书源
|
||
</button>
|
||
</div>
|
||
|
||
{showImport && (
|
||
<div
|
||
onDragOver={(e) => {
|
||
e.preventDefault()
|
||
setDragOver(true)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(false)
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault()
|
||
setDragOver(false)
|
||
const file = e.dataTransfer.files?.[0]
|
||
if (file) readFile(file)
|
||
}}
|
||
className={`mt-4 rounded-2xl border bg-[var(--app-panel)] p-4 transition ${
|
||
dragOver ? 'border-brand-500 bg-brand-500/5' : 'border-[var(--app-border)]'
|
||
}`}
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".json,.txt,application/json,text/plain"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) readFile(file)
|
||
e.target.value = ''
|
||
}}
|
||
/>
|
||
<textarea
|
||
value={importText}
|
||
onChange={(e) => setImportText(e.target.value)}
|
||
rows={6}
|
||
placeholder={'粘贴书源 JSON / Base64,或填一个书源链接(https://...)\n也可选择 / 拖入书源文件(.json / .txt),支持数组批量导入'}
|
||
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
|
||
/>
|
||
{importFile && (
|
||
<div className="mt-2 flex items-center gap-2 rounded-xl border border-brand-500/30 bg-brand-500/10 px-3 py-1.5 text-xs font-bold text-brand-600">
|
||
<FileUp size={13} className="shrink-0" />
|
||
<span className="min-w-0 flex-1 truncate">
|
||
{importFile.name}({fmtSize(importFile.size)})
|
||
</span>
|
||
<button type="button" title="移除文件" onClick={() => setImportFile(null)} className="shrink-0 hover:text-red-500">
|
||
<X size={13} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="mt-2 flex items-center justify-between">
|
||
<button type="button" onClick={() => fileInputRef.current?.click()} className="btn-outline text-xs">
|
||
<FileUp size={13} className="mr-1 inline" /> 选择文件
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={doImport}
|
||
disabled={importing || !(importFile || importText.trim())}
|
||
className="btn-primary text-xs disabled:opacity-50"
|
||
>
|
||
{importing ? <Loader2 size={13} className="inline animate-spin" /> : '开始导入'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
|
||
<input value={batchKey} onChange={(event) => setBatchKey(event.target.value)} placeholder="全链路检测关键词,例如:斗破苍穹" className="min-w-48 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none" />
|
||
<button type="button" className="btn-outline text-xs" disabled={batchTesting || !sources?.some((source) => source.enabled)} onClick={() => void testEnabledSources()}>
|
||
{batchTesting ? <><Loader2 size={12} className="mr-1 inline animate-spin" />检测中</> : '检测全部已启用书源'}
|
||
</button>
|
||
<span className="text-[10px] text-[var(--app-muted)]">每源检查搜索、详情、目录和一章正文,并发最多 3 个</span>
|
||
</div>
|
||
|
||
<div className="mt-6 space-y-2">
|
||
{sources === null && (
|
||
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
|
||
<Loader2 className="animate-spin" size={22} />
|
||
</div>
|
||
)}
|
||
{sources !== null && sources.length === 0 && (
|
||
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center text-xs text-[var(--app-muted)]">
|
||
还没有书源,点右上「导入书源」开始
|
||
</div>
|
||
)}
|
||
{sources?.map((src) => (
|
||
<div key={src.id} className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
|
||
<div className="flex items-center gap-3 px-4 py-3">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="flex items-center gap-2 truncate text-sm font-bold text-[var(--app-text)]">
|
||
{src.name}
|
||
<span className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
|
||
{READER_SOURCE_TYPES[src.type] ?? '文本'}
|
||
</span>
|
||
{src.group && <span className="truncate text-2xs font-normal text-[var(--app-muted)]">{src.group}</span>}
|
||
</p>
|
||
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p>
|
||
{batchResults[src.id] && <p className={`mt-1 truncate text-[10px] ${batchResults[src.id].ok ? 'text-emerald-500' : 'text-red-400'}`}>{batchResults[src.id].message}</p>}
|
||
</div>
|
||
{/* 登录入口:仅对声明了 loginUrl/loginUi 的书源显示 */}
|
||
{src.has_login && (
|
||
<button
|
||
type="button"
|
||
title={loggedInIds[src.id] ? '已登录,点击管理' : '登录'}
|
||
onClick={() => setLoginSource(src)}
|
||
className={`rounded-xl p-2 hover:bg-[var(--app-hover)] ${
|
||
loggedInIds[src.id] ? 'text-emerald-500' : 'text-[var(--app-muted)] hover:text-brand-600'
|
||
}`}
|
||
>
|
||
<KeyRound size={16} />
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
title="快速调试"
|
||
onClick={() => {
|
||
setDebugId(debugId === src.id ? '' : src.id)
|
||
setDebugLogs(null)
|
||
}}
|
||
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-brand-600"
|
||
>
|
||
<Bug size={16} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="删除"
|
||
onClick={() => removeSource(src)}
|
||
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-red-500"
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
{/* 启停开关(仿 legado Switch) */}
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={src.enabled}
|
||
onClick={() => toggleEnabled(src)}
|
||
className={`relative h-5 w-9 shrink-0 rounded-full transition ${
|
||
src.enabled ? 'bg-brand-500' : 'bg-[var(--app-hover)]'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||
src.enabled ? 'left-[18px]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{debugId === src.id && (
|
||
<div className="border-t border-[var(--app-border)] px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
value={debugKey}
|
||
onChange={(e) => setDebugKey(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && doDebug(src)}
|
||
placeholder="搜索关键词(如:斗破苍穹)"
|
||
className="flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
|
||
/>
|
||
<button type="button" onClick={() => doDebug(src)} disabled={debugging} className="btn-outline text-2xs disabled:opacity-50">
|
||
{debugging ? <Loader2 size={12} className="inline animate-spin" /> : <Play size={12} className="inline" />}
|
||
运行
|
||
</button>
|
||
</div>
|
||
{debugLogs !== null && (
|
||
<pre className="mt-3 max-h-72 overflow-auto rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs leading-5 text-[var(--app-text)]">
|
||
{debugLogs.length === 0 ? '(无日志)' : debugLogs.join('\n')}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{loginSource && (
|
||
<SourceLoginDialog
|
||
sourceId={loginSource.id}
|
||
sourceName={loginSource.name}
|
||
onClose={() => {
|
||
setLoginSource(null)
|
||
// 关闭时回读登录态,更新列表上的登录标记
|
||
readerAPI
|
||
.sourceLogin(loginSource.id)
|
||
.then((info) => setLoggedInIds((prev) => ({ ...prev, [loginSource.id]: info.logged_in })))
|
||
.catch(() => undefined)
|
||
}}
|
||
onLoggedInChange={(loggedIn) =>
|
||
setLoggedInIds((prev) => ({ ...prev, [loginSource.id]: loggedIn }))
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|