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(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(null) const [debugId, setDebugId] = useState('') const [debugKey, setDebugKey] = useState('') const [debugLogs, setDebugLogs] = useState(null) const [debugging, setDebugging] = useState(false) const [batchKey, setBatchKey] = useState('') const [batchTesting, setBatchTesting] = useState(false) const [batchResults, setBatchResults] = useState>({}) // 登录面板:记录正在登录的书源 const [loginSource, setLoginSource] = useState(null) const [loggedInIds, setLoggedInIds] = useState>({}) 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 (

书源管理

替换净化
{showImport && (
{ 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)]' }`} > { const file = e.target.files?.[0] if (file) readFile(file) e.target.value = '' }} />