mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
开发
This commit is contained in:
@@ -42,6 +42,8 @@ export interface ReaderSource {
|
||||
comment: string
|
||||
last_update_time: number
|
||||
respond_time: number
|
||||
// 书源声明了登录能力(loginUrl / loginUi)
|
||||
has_login: boolean
|
||||
}
|
||||
|
||||
export interface ReaderBook {
|
||||
@@ -115,6 +117,47 @@ export interface ReaderReplaceRule {
|
||||
order: number
|
||||
}
|
||||
|
||||
// ── 书源登录 ──
|
||||
|
||||
export interface ReaderLoginField {
|
||||
name: string
|
||||
type: 'text' | 'password' | 'button' | 'toggle' | 'select'
|
||||
action?: string
|
||||
chars?: string[]
|
||||
default?: string
|
||||
viewName?: string
|
||||
style?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ReaderSourceLogin {
|
||||
source_id: string
|
||||
source_name: string
|
||||
has_login_js: boolean
|
||||
login_js?: string
|
||||
fields: ReaderLoginField[]
|
||||
values: Record<string, string>
|
||||
cookies: Record<string, string>
|
||||
variable: string
|
||||
variable_comment?: string
|
||||
logged_in: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ReaderBrowserRequest {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface ReaderLoginResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
toasts?: string[]
|
||||
browsers?: ReaderBrowserRequest[]
|
||||
values: Record<string, string>
|
||||
cookies: Record<string, string>
|
||||
logged_in: boolean
|
||||
}
|
||||
|
||||
const longOpts = { timeout: LONG_REQUEST_TIMEOUT } as const
|
||||
|
||||
export const readerAPI = {
|
||||
@@ -127,6 +170,18 @@ export const readerAPI = {
|
||||
debugSource: (id: string, key: string) =>
|
||||
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs),
|
||||
|
||||
// ── 书源登录与源变量 ──
|
||||
// 取登录界面描述(loginUi 控件 + 已保存值 + 当前 Cookie 状态)
|
||||
sourceLogin: (id: string) => api.get<ReaderSourceLogin>(`/reader/sources/${id}/login`).then((r) => r.data),
|
||||
// 执行登录动作:action 为空表示执行 loginUrl 的 login()(确认登录)
|
||||
runSourceLogin: (id: string, body: { action?: string; fields?: Record<string, string> }) =>
|
||||
api.post<ReaderLoginResult>(`/reader/sources/${id}/login`, body, longOpts).then((r) => r.data),
|
||||
// 仅保存表单值,不触发登录
|
||||
saveSourceLoginInfo: (id: string, fields: Record<string, string>) =>
|
||||
api.put(`/reader/sources/${id}/login-info`, { fields }),
|
||||
logoutSource: (id: string) => api.delete(`/reader/sources/${id}/login`),
|
||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
api
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Bug, Download, Loader2, Play, Trash2 } from 'lucide-react'
|
||||
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
|
||||
@@ -16,16 +20,28 @@ function hostOf(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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 [loginSource, setLoginSource] = useState<ReaderSource | null>(null)
|
||||
const [loggedInIds, setLoggedInIds] = useState<Record<string, boolean>>({})
|
||||
|
||||
const load = () => {
|
||||
readerAPI
|
||||
@@ -36,13 +52,29 @@ export default function ReaderSourcesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
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 () => {
|
||||
if (!importText.trim() || importing) return
|
||||
const payload = importFile?.text ?? importText
|
||||
if (!payload.trim() || importing) return
|
||||
setImporting(true)
|
||||
try {
|
||||
const imported = await readerAPI.importSources(importText)
|
||||
const imported = await readerAPI.importSources(payload)
|
||||
toast.success(`成功导入 ${imported} 个书源`)
|
||||
setImportText('')
|
||||
setImportFile(null)
|
||||
setShowImport(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
@@ -111,16 +143,63 @@ export default function ReaderSourcesPage() {
|
||||
</div>
|
||||
|
||||
{showImport && (
|
||||
<div className="mt-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4">
|
||||
<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支持数组批量导入'}
|
||||
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)]"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button type="button" onClick={doImport} disabled={importing || !importText.trim()} className="btn-primary text-xs disabled:opacity-50">
|
||||
{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>
|
||||
@@ -151,6 +230,19 @@ export default function ReaderSourcesPage() {
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</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="快速调试"
|
||||
@@ -213,6 +305,24 @@ export default function ReaderSourcesPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ExternalLink, KeyRound, Loader2, LogOut, RefreshCw, Save, X } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader'
|
||||
|
||||
// 书源登录面板(仿 legado SourceLoginDialog)。
|
||||
//
|
||||
// 后端按 legado 的登录模型执行:loginUi 是表单描述,loginUrl 是登录逻辑,
|
||||
// 点按钮时不区分类型,统统把 action 拼在 loginUrl 之后执行。
|
||||
// 服务端没有弹窗,java.toast/longToast 的提示与 java.startBrowser 的地址
|
||||
// 都通过返回值回传,这里负责展示并代为打开浏览器。
|
||||
|
||||
interface Props {
|
||||
sourceId: string
|
||||
sourceName: string
|
||||
onClose: () => void
|
||||
onLoggedInChange?: (loggedIn: boolean) => void
|
||||
}
|
||||
|
||||
// formValues 取非按钮控件的初始值(已保存值优先,其次 default)。
|
||||
function initialFormValues(info: ReaderSourceLogin): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
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<ReaderSourceLogin | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [running, setRunning] = useState('')
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
const [toasts, setToasts] = useState<string[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [variable, setVariable] = useState('')
|
||||
const [showVariable, setShowVariable] = useState(false)
|
||||
const [showLoginJS, setShowLoginJS] = useState(false)
|
||||
|
||||
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])
|
||||
|
||||
// 按钮分为「登录动作」与「其他工具按钮」两类,便于排版。
|
||||
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: { saveFields?: boolean } = {}) => {
|
||||
const key = action || '__login__'
|
||||
setRunning(key)
|
||||
setToasts([])
|
||||
setError('')
|
||||
try {
|
||||
const res = await readerAPI.runSourceLogin(sourceId, { action, fields: form })
|
||||
const messages = [...(res.toasts ?? [])]
|
||||
if (res.error) messages.push(res.error)
|
||||
setToasts(messages)
|
||||
if (res.browsers?.length) {
|
||||
// 服务端无法弹窗:把需要人工操作的页面提示给用户
|
||||
for (const b of res.browsers) {
|
||||
messages.push(`需要浏览器操作:${b.title || b.url}`)
|
||||
}
|
||||
setToasts([...messages])
|
||||
}
|
||||
// 同步回最新登录信息(书源可能在动作里回填字段)
|
||||
if (res.values) setForm((prev) => ({ ...prev, ...res.values }))
|
||||
if (opts.saveFields && !res.ok) {
|
||||
// 动作失败也保留用户输入,便于改完重试
|
||||
}
|
||||
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 {
|
||||
setRunning('')
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-[var(--app-border)] px-4 py-3">
|
||||
<KeyRound size={16} className="shrink-0 text-brand-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)]">{sourceName}</p>
|
||||
<p className="text-2xs text-[var(--app-muted)]">
|
||||
{info?.logged_in ? '已登录' : '未登录'}
|
||||
{info?.has_login_js ? ' · 含登录逻辑' : ' · 未配置登录逻辑'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} title="刷新" className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-3 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有 loginUi 的表单时,只有 loginUrl:按 legado 语义当作登录页地址 */}
|
||||
{!info?.fields.length && info?.has_login_js && (
|
||||
<div className="mb-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-muted)]">
|
||||
该书源没有表单(loginUi),登录需要打开网页完成。
|
||||
<button type="button" onClick={() => openExternal(info.login_js ?? '')} className="btn-outline ml-2 text-2xs">
|
||||
<ExternalLink size={12} className="mr-1 inline" />
|
||||
打开登录页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表单输入项 */}
|
||||
{inputs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{inputs.map((f) => (
|
||||
<label key={f.name} className="block">
|
||||
<span className="mb-1 block text-2xs font-bold text-[var(--app-muted)]">{f.name}</span>
|
||||
<input
|
||||
type={f.type === 'password' ? 'password' : 'text'}
|
||||
value={form[f.name] ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, [f.name]: e.target.value }))}
|
||||
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选择项(toggle / select):点击后按 action 交由书源处理 */}
|
||||
{selects.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{selects.map((f) => (
|
||||
<SelectField
|
||||
key={f.name}
|
||||
field={f}
|
||||
value={form[f.name] ?? f.default ?? ''}
|
||||
disabled={!!running}
|
||||
onPick={(v) => {
|
||||
setForm((prev) => ({ ...prev, [f.name]: v }))
|
||||
if (f.action) void runAction(f.action)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮区 */}
|
||||
{buttons.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{buttons.map((f) => (
|
||||
<button
|
||||
key={`${f.name}-${f.action}`}
|
||||
type="button"
|
||||
disabled={!!running}
|
||||
onClick={() => void runAction(f.action ?? '')}
|
||||
className="btn-outline text-xs disabled:opacity-50"
|
||||
>
|
||||
{running === (f.action || '__login__') && <Loader2 size={12} className="mr-1 inline animate-spin" />}
|
||||
{f.viewName || f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示与待打开地址 */}
|
||||
{toasts.length > 0 && (
|
||||
<pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs leading-5 text-[var(--app-text)]">
|
||||
{toasts.filter(Boolean).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* 源变量编辑 */}
|
||||
<div className="mt-4 border-t border-[var(--app-border)] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVariable((v) => !v)}
|
||||
className="text-2xs font-bold text-[var(--app-muted)] hover:text-brand-600"
|
||||
>
|
||||
{showVariable ? '▾' : '▸'} 源变量(bookSourceUrl 变量的 JSON)
|
||||
</button>
|
||||
{showVariable && (
|
||||
<div className="mt-2">
|
||||
{info?.variable_comment && (
|
||||
<p className="mb-2 whitespace-pre-wrap rounded-xl bg-[var(--app-panel-soft)] p-2 text-2xs text-[var(--app-muted)]">
|
||||
{info.variable_comment}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={variable}
|
||||
onChange={(e) => setVariable(e.target.value)}
|
||||
rows={6}
|
||||
placeholder='{"线路":"https://v1.example.com"}'
|
||||
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 font-mono text-2xs text-[var(--app-text)] outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => void saveVariable()} className="btn-primary mt-2 text-2xs">
|
||||
<Save size={12} className="mr-1 inline" /> 保存变量
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前 Cookie:登录态的实际凭证 */}
|
||||
{cookieEntries.length > 0 && (
|
||||
<div className="mt-3 border-t border-[var(--app-border)] pt-3">
|
||||
<p className="text-2xs font-bold text-[var(--app-muted)]">已保存 Cookie</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{cookieEntries.map(([domain, cookie]) => (
|
||||
<p key={domain} className="truncate font-mono text-2xs text-[var(--app-muted)]" title={cookie}>
|
||||
{domain}: {cookie}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* loginUrl 原文(书源作者写的登录脚本) */}
|
||||
{info?.login_js && (
|
||||
<div className="mt-3 border-t border-[var(--app-border)] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLoginJS((v) => !v)}
|
||||
className="text-2xs font-bold text-[var(--app-muted)] hover:text-brand-600"
|
||||
>
|
||||
{showLoginJS ? '▾' : '▸'} 登录脚本(loginUrl)
|
||||
</button>
|
||||
{showLoginJS && (
|
||||
<>
|
||||
<button type="button" onClick={() => openExternal(info.login_js ?? '')} className="mt-2 mr-2 text-2xs text-brand-600">
|
||||
<ExternalLink size={11} className="mr-1 inline" />
|
||||
若为登录页地址可打开
|
||||
</button>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs text-[var(--app-text)]">
|
||||
{info.login_js}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--app-border)] px-4 py-3">
|
||||
<button type="button" onClick={() => void saveLoginInfo()} className="btn-outline text-xs" disabled={loading}>
|
||||
<Save size={13} className="mr-1 inline" /> 仅保存信息
|
||||
</button>
|
||||
<button type="button" onClick={() => void doLogout()} className="btn-outline text-xs text-red-500" disabled={loading}>
|
||||
<LogOut size={13} className="mr-1 inline" /> 退出登录
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn-primary text-xs">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SelectField 渲染 toggle / select 控件。
|
||||
function SelectField({
|
||||
field,
|
||||
value,
|
||||
disabled,
|
||||
onPick,
|
||||
}: {
|
||||
field: ReaderLoginField
|
||||
value: string
|
||||
disabled: boolean
|
||||
onPick: (v: string) => void
|
||||
}) {
|
||||
const options = field.chars ?? []
|
||||
if (field.type === 'toggle' || options.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-[var(--app-muted)]">{field.name}</span>
|
||||
<button type="button" disabled={disabled} onClick={() => onPick(value ? '' : '1')} className="btn-outline text-2xs">
|
||||
{value ? '已开启' : '未开启'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-2xs font-bold text-[var(--app-muted)]">{field.name}</span>
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPick(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user