Files
MeBox/web/src/pages/reader/ReaderSourcesPage.tsx
T

364 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}