import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { ExternalLink, KeyRound, Loader2, LogOut, RefreshCw, Save, X } from 'lucide-react' import { readerAPI, type ReaderBrowserPage, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader' import BrowserPanel from './BrowserPanel' // 书源登录面板(仿 legado SourceLoginDialog)。 // // 后端按 legado 的登录模型执行:loginUi 是表单描述,loginUrl 是登录逻辑, // 点按钮时不区分类型,统统把 action 拼在 loginUrl 之后执行。 // 服务端没有弹窗,java.toast/longToast 的提示通过返回值回传展示; // java.startBrowser / startBrowserAwait 则把页面登记成「待办」,这里轮询到后 // 用内嵌 iframe 承载(见 BrowserPanel),用户的「√」会把 DOM 回传给书源。 interface Props { sourceId: string sourceName: string onClose: () => void onLoggedInChange?: (loggedIn: boolean) => void } // formValues 取非按钮控件的初始值(已保存值优先,其次 default)。 function initialFormValues(info: ReaderSourceLogin): Record { const out: Record = {} for (const f of info.fields) { if (f.type === 'button') continue out[f.name] = info.values?.[f.name] ?? f.default ?? '' } return out } export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLoggedInChange }: Props) { const [info, setInfo] = useState(null) const [loading, setLoading] = useState(true) const [running, setRunning] = useState('') const [form, setForm] = useState>({}) const [toasts, setToasts] = useState([]) const [error, setError] = useState('') const [variable, setVariable] = useState('') const [showVariable, setShowVariable] = useState(false) const [showLoginJS, setShowLoginJS] = useState(false) // 书源交给宿主浏览器承载的页面(java.startBrowser / startBrowserAwait) const [browserPage, setBrowserPage] = useState(null) // 已处理过的页面 ID:避免轮询把刚关掉的页面又弹出来 const handledPages = useRef>(new Set()) const load = useCallback(async () => { setLoading(true) try { const data = await readerAPI.sourceLogin(sourceId) setInfo(data) setForm(initialFormValues(data)) setVariable(data.variable ?? '') setError(data.error ?? '') } catch (e) { setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '读取登录信息失败') } finally { setLoading(false) } }, [sourceId]) useEffect(() => { void load() }, [load]) // 轮询书源登记的待办页面。 // java.startBrowserAwait 会阻塞在服务端,前端必须在动作执行期间去取页面, // 否则用户永远看不到「切换线路」「用户后台」这些按钮真正要展示的东西。 const pollBrowserPages = useCallback(async () => { try { const pages = await readerAPI.browserPending(sourceId) // 只取本次还没处理过的页面,并按 seq 取最新登记的那个。 // 不能直接拿数组第一个:服务端待办表可能同时存着多个页面,且返回顺序 // 是随机的(Go map 遍历),拿第一个会把旧页面顶到正在点的按钮上 // (光遇聚合「更新书源」残留后串页就是这么来的)。 const next = pages .filter((p) => !handledPages.current.has(p.id)) .reduce((acc, p) => (!acc || p.seq > acc.seq ? p : acc), null) if (!next) return // 已经在展示的页面不要被更旧的页面替换掉 setBrowserPage((cur) => (cur && cur.seq >= next.seq ? cur : next)) } catch { // 轮询失败不打断正在执行的动作 } }, [sourceId]) // 按钮分为「登录动作」与「其他工具按钮」两类,便于排版。 const buttons = useMemo(() => info?.fields.filter((f) => f.type === 'button') ?? [], [info]) const inputs = useMemo(() => info?.fields.filter((f) => f.type !== 'button') ?? [], [info]) const selects = useMemo(() => info?.fields.filter((f) => f.type === 'toggle' || f.type === 'select') ?? [], [info]) const runAction = async (action: string, opts: { fieldsOverride?: Record } = {}) => { const key = action || '__login__' // 注意用 fieldsOverride 而不是闭包里的 form:select/toggle 的 onPick 里 // setForm 是异步的,同一轮事件里读 form 拿到的还是旧值, // 会让书源按旧线路执行(表现为「切了但没生效」)。 const fields = opts.fieldsOverride ?? form setRunning(key) setToasts([]) setError('') // 动作可能阻塞等待人工操作,期间持续轮询待办页面 const poll = window.setInterval(() => void pollBrowserPages(), 600) try { const res = await readerAPI.runSourceLogin(sourceId, { action, fields }) const messages = [...(res.toasts ?? [])] if (res.error) messages.push(res.error) // 服务端未注入宿主浏览器时(非登录链路),这里退化为提示 + 可打开的地址 for (const b of res.browsers ?? []) { messages.push(`需要浏览器操作:${b.title || ''} ${b.url}`.trim()) } setToasts(messages) // 同步回最新登录信息(书源可能在动作里回填字段) if (res.values) setForm((prev) => ({ ...prev, ...res.values })) onLoggedInChange?.(res.logged_in) const nextInfo = await readerAPI.sourceLogin(sourceId) setInfo(nextInfo) setVariable(nextInfo.variable ?? '') if (res.logged_in) toast.success('登录成功') else if (!res.ok) toast.error('登录失败') } catch (e) { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '登录执行失败' setError(msg) toast.error(msg) } finally { window.clearInterval(poll) setRunning('') // 收尾再取一次,避免最后一个待办落在轮询间隙里 void pollBrowserPages() } } const closeBrowserPage = useCallback(() => { setBrowserPage((cur) => { if (cur) handledPages.current.add(cur.id) return null }) }, []) const saveLoginInfo = async () => { try { await readerAPI.saveSourceLoginInfo(sourceId, form) toast.success('已保存登录信息') } catch { toast.error('保存失败') } } const doLogout = async () => { if (!window.confirm('退出登录并清除该源已保存的 Cookie?')) return try { await readerAPI.logoutSource(sourceId) toast.success('已退出登录') onLoggedInChange?.(false) await load() } catch { toast.error('退出失败') } } const saveVariable = async () => { try { await readerAPI.setSourceVariable(sourceId, variable) toast.success('源变量已保存') const next = await readerAPI.sourceLogin(sourceId) setInfo(next) } catch (e) { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败' toast.error(msg) } } const openExternal = (url: string) => { window.open(url, '_blank', 'noopener,noreferrer') } // 已保存 Cookie 条目(domain → cookie),给用户一个登录态的直观依据 const cookieEntries = Object.entries(info?.cookies ?? {}) return (
{ if (!browserPage) onClose() }} >
e.stopPropagation()} >

{sourceName}

{info?.logged_in ? '已登录' : '未登录'} {info?.has_login_js ? ' · 含登录逻辑' : ' · 未配置登录逻辑'}

{loading && (
)} {!loading && ( <> {error && (
{error}
)} {/* 没有 loginUi 的表单时,只有 loginUrl:按 legado 语义当作登录页地址 */} {!info?.fields.length && info?.has_login_js && (
该书源没有表单(loginUi),登录需要打开网页完成。
)} {/* 表单输入项 */} {inputs.length > 0 && (
{inputs.map((f) => ( ))}
)} {/* 选择项(toggle / select):点击后按 action 交由书源处理 */} {selects.length > 0 && (
{selects.map((f) => ( { // 必须把新值显式带进 action:setForm 是异步的, // runAction 读闭包里的 form 会拿到切换前的旧值。 const next = { ...form, [f.name]: v } setForm(next) if (f.action) void runAction(f.action, { fieldsOverride: next }) }} /> ))}
)} {/* 按钮区 */} {buttons.length > 0 && (
{buttons.map((f) => ( ))}
)} {/* 提示与待打开地址 */} {toasts.length > 0 && (
                  {toasts.filter(Boolean).join('\n')}
                
)} {/* 源变量编辑 */}
{showVariable && (
{info?.variable_comment && (

{info.variable_comment}

)}