mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 12:36:37 +08:00
feat(reader): P2 收尾——冒烟 CLI、书架正文链路、替换净化规则
- SmokeChain 结构化全链路冒烟(搜索→详情→目录→正文,分阶段失败定位), SmokeSource 支持不落库直测;Debug 接口改为结构化日志输出 - cmd/reader-smoke:批量书源冒烟 CLI(-file/-url 导入、并发、 各阶段通过率汇总、-json 完整报告) - GetContentForBook:书架维度正文(章节缓存 + 书源 replaceRegex + 用户替换净化规则),前端阅读器切换到该接口 - 替换净化规则 CRUD(后端 + /reader/replace 前端页),用户规则 按 scope/excludeScope 书名过滤、正则带匹配超时防回溯挂死 - rule.ApplyUserReplace 导出(regexp2 MatchTimeout)
This commit is contained in:
+25
-1
@@ -152,8 +152,32 @@ export const readerAPI = {
|
||||
listChapters: (id: string) =>
|
||||
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||
bookContent: (id: string, chapter: number) =>
|
||||
api
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 替换规则 ──
|
||||
// ── 替换净化规则 ──
|
||||
listReplaceRules: () =>
|
||||
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
|
||||
createReplaceRule: (body: ReplaceRuleInput) =>
|
||||
api.post<ReaderReplaceRule>('/reader/replace-rules', body).then((r) => r.data),
|
||||
updateReplaceRule: (id: string, body: ReplaceRuleInput) => api.patch(`/reader/replace-rules/${id}`, body),
|
||||
deleteReplaceRule: (id: string) => api.delete(`/reader/replace-rules/${id}`),
|
||||
}
|
||||
|
||||
export interface ReplaceRuleInput {
|
||||
name: string
|
||||
group: string
|
||||
pattern: string
|
||||
replacement: string
|
||||
scope: string
|
||||
scope_title: boolean
|
||||
scope_content: boolean
|
||||
exclude_scope: string
|
||||
is_enabled: boolean
|
||||
is_regex: boolean
|
||||
timeout_millisecond: number
|
||||
order: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderReplaceRule, type ReplaceRuleInput } from '../../api/reader'
|
||||
|
||||
// 替换净化规则页(仿 legado ReplaceRuleActivity:列表 + 启停 + 编辑)。
|
||||
|
||||
const emptyInput: ReplaceRuleInput = {
|
||||
name: '',
|
||||
group: '',
|
||||
pattern: '',
|
||||
replacement: '',
|
||||
scope: '',
|
||||
scope_title: false,
|
||||
scope_content: true,
|
||||
exclude_scope: '',
|
||||
is_enabled: true,
|
||||
is_regex: true,
|
||||
timeout_millisecond: 3000,
|
||||
order: 0,
|
||||
}
|
||||
|
||||
export default function ReaderReplacePage() {
|
||||
const navigate = useNavigate()
|
||||
const [rules, setRules] = useState<ReaderReplaceRule[] | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState<ReplaceRuleInput>(emptyInput)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const load = () => {
|
||||
readerAPI
|
||||
.listReplaceRules()
|
||||
.then(setRules)
|
||||
.catch(() => toast.error('加载替换规则失败'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [])
|
||||
|
||||
const save = async () => {
|
||||
if (!form.pattern.trim()) {
|
||||
toast.error('替换规则不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await readerAPI.createReplaceRule(form)
|
||||
toast.success('已添加')
|
||||
setForm(emptyInput)
|
||||
setShowForm(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (r: ReaderReplaceRule) => {
|
||||
try {
|
||||
await readerAPI.updateReplaceRule(r.id, {
|
||||
name: r.name,
|
||||
group: r.group,
|
||||
pattern: r.pattern,
|
||||
replacement: r.replacement,
|
||||
scope: r.scope,
|
||||
scope_title: r.scope_title,
|
||||
scope_content: r.scope_content,
|
||||
exclude_scope: r.exclude_scope,
|
||||
is_enabled: !r.is_enabled,
|
||||
is_regex: r.is_regex,
|
||||
timeout_millisecond: 3000,
|
||||
order: r.order,
|
||||
})
|
||||
setRules((prev) => prev?.map((x) => (x.id === r.id ? { ...x, is_enabled: !x.is_enabled } : x)) ?? null)
|
||||
} catch {
|
||||
toast.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (r: ReaderReplaceRule) => {
|
||||
if (!window.confirm(`删除规则「${r.name || r.pattern}」?`)) return
|
||||
try {
|
||||
await readerAPI.deleteReplaceRule(r.id)
|
||||
setRules((prev) => prev?.filter((x) => x.id !== r.id) ?? null)
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<button type="button" onClick={() => setShowForm((v) => !v)} className="btn-primary text-xs">
|
||||
<Plus size={13} className="mr-1 inline" /> 添加规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mt-4 space-y-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="规则名(可选)"
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
value={form.group}
|
||||
onChange={(e) => setForm({ ...form, group: e.target.value })}
|
||||
placeholder="分组(可选)"
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-xs text-[var(--app-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={form.pattern}
|
||||
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="替换规则(正则或原文)"
|
||||
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"
|
||||
/>
|
||||
<textarea
|
||||
value={form.replacement}
|
||||
onChange={(e) => setForm({ ...form, replacement: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="替换为(留空即删除匹配内容)"
|
||||
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"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-[var(--app-muted)]">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={form.is_regex} onChange={(e) => setForm({ ...form, is_regex: e.target.checked })} />
|
||||
正则
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={form.scope_content} onChange={(e) => setForm({ ...form, scope_content: e.target.checked })} />
|
||||
作用于正文
|
||||
</label>
|
||||
<input
|
||||
value={form.scope}
|
||||
onChange={(e) => setForm({ ...form, scope: e.target.value })}
|
||||
placeholder="作用范围(书名包含,可选)"
|
||||
className="w-44 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"
|
||||
/>
|
||||
<input
|
||||
value={form.exclude_scope}
|
||||
onChange={(e) => setForm({ ...form, exclude_scope: e.target.value })}
|
||||
placeholder="排除范围(可选)"
|
||||
className="w-44 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={save} disabled={saving} className="btn-primary ml-auto text-xs disabled:opacity-50">
|
||||
{saving ? <Loader2 size={13} className="inline animate-spin" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-2">
|
||||
{rules === null && (
|
||||
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={22} />
|
||||
</div>
|
||||
)}
|
||||
{rules !== null && rules.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>
|
||||
)}
|
||||
{rules?.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)]">
|
||||
{r.name || '(未命名规则)'}
|
||||
{r.is_regex && <span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">正则</span>}
|
||||
{r.group && <span className="ml-2 text-2xs font-normal text-[var(--app-muted)]">{r.group}</span>}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-mono text-2xs text-[var(--app-muted)]">
|
||||
{r.pattern} → {r.replacement || '(删除)'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
onClick={() => remove(r)}
|
||||
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-red-500"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={r.is_enabled}
|
||||
onClick={() => toggle(r)}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition ${r.is_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 ${
|
||||
r.is_enabled ? 'left-[18px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import ReaderBookPage from './ReaderBookPage'
|
||||
import ReaderHomePage from './ReaderHomePage'
|
||||
import ReaderReplacePage from './ReaderReplacePage'
|
||||
import ReaderSearchPage from './ReaderSearchPage'
|
||||
import ReaderSourcesPage from './ReaderSourcesPage'
|
||||
import ReaderViewPage from './ReaderViewPage'
|
||||
@@ -13,6 +14,7 @@ export default function ReaderRoutes() {
|
||||
<Route index element={<ReaderHomePage />} />
|
||||
<Route path="search" element={<ReaderSearchPage />} />
|
||||
<Route path="sources" element={<ReaderSourcesPage />} />
|
||||
<Route path="replace" element={<ReaderReplacePage />} />
|
||||
<Route path="book" element={<ReaderBookPage />} />
|
||||
<Route path="view/:bookId" element={<ReaderViewPage />} />
|
||||
<Route path="*" element={<Navigate to="/reader" replace />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Bug, Download, Loader2, Play, Trash2 } from 'lucide-react'
|
||||
|
||||
@@ -102,6 +102,9 @@ export default function ReaderSourcesPage() {
|
||||
<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>
|
||||
|
||||
@@ -112,14 +112,11 @@ export default function ReaderViewPage() {
|
||||
setContent(null)
|
||||
setLoadingStage('content')
|
||||
try {
|
||||
let ct = contentCache.current.get(ch.url)
|
||||
const cacheKey = String(chapterIndex)
|
||||
let ct = contentCache.current.get(cacheKey)
|
||||
if (!ct) {
|
||||
ct = await readerAPI.content({
|
||||
source_url: book.origin,
|
||||
book_url: book.book_url,
|
||||
chapter_url: ch.url,
|
||||
})
|
||||
contentCache.current.set(ch.url, ct)
|
||||
ct = await readerAPI.bookContent(book.id, chapterIndex)
|
||||
contentCache.current.set(cacheKey, ct)
|
||||
}
|
||||
if (cancelled) return
|
||||
setContentType(ct.type)
|
||||
@@ -131,11 +128,10 @@ export default function ReaderViewPage() {
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
|
||||
.catch(() => undefined)
|
||||
// 预取下一章
|
||||
const next = chapters[chapterIndex + 1]
|
||||
if (next && !contentCache.current.has(next.url)) {
|
||||
if (!contentCache.current.has(String(chapterIndex + 1))) {
|
||||
readerAPI
|
||||
.content({ source_url: book.origin, book_url: book.book_url, chapter_url: next.url })
|
||||
.then((c) => contentCache.current.set(next.url, c))
|
||||
.bookContent(book.id, chapterIndex + 1)
|
||||
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -359,8 +355,7 @@ export default function ReaderViewPage() {
|
||||
onClick={() => {
|
||||
setError('')
|
||||
if (chapterIndex !== null) {
|
||||
const ch = chapters[chapterIndex]
|
||||
if (ch) contentCache.current.delete(ch.url)
|
||||
contentCache.current.delete(String(chapterIndex))
|
||||
setChapterIndex(chapterIndex)
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user