diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index e5fc7ff..ecd2e6e 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -137,7 +137,7 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { info, err := svc.Reader.GetBookInfo( c.Request.Context(), - c.Query("source_id"), c.Query("book_url"), + c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), ) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -151,7 +151,7 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { chapters, err := svc.Reader.GetToc( c.Request.Context(), - c.Query("source_id"), c.Query("book_url"), c.Query("toc_url"), + c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"), ) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -165,7 +165,7 @@ func readerContentHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { content, err := svc.Reader.GetContent( c.Request.Context(), - c.Query("source_id"), c.Query("book_url"), c.Query("chapter_url"), + c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("chapter_url"), ) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 09c9ee1..8132a81 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -552,8 +552,8 @@ type BookInfo struct { } // GetBookInfo 抓取书籍详情。 -func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, bookURL string) (*BookInfo, error) { - src, bs, err := s.loadSource(ctx, sourceID) +func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, sourceURL, bookURL string) (*BookInfo, error) { + src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL) if err != nil { return nil, err } @@ -637,8 +637,8 @@ type TocChapter struct { } // GetToc 抓取目录。 -func (s *ReaderService) GetToc(ctx context.Context, sourceID, bookURL, tocURL string) ([]TocChapter, error) { - src, bs, err := s.loadSource(ctx, sourceID) +func (s *ReaderService) GetToc(ctx context.Context, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) { + src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL) if err != nil { return nil, err } @@ -702,8 +702,8 @@ type ChapterContent struct { } // GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。 -func (s *ReaderService) GetContent(ctx context.Context, sourceID, bookURL, chapterURL string) (*ChapterContent, error) { - src, bs, err := s.loadSource(ctx, sourceID) +func (s *ReaderService) GetContent(ctx context.Context, sourceID, sourceURL, bookURL, chapterURL string) (*ChapterContent, error) { + src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL) if err != nil { return nil, err } @@ -886,14 +886,14 @@ func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]stri } first := books[0] logf("访问详情页: %s", first.BookURL) - info, err := s.GetBookInfo(ctx, sourceID, first.BookURL) + info, err := s.GetBookInfo(ctx, sourceID, "", first.BookURL) if err != nil { logf("详情失败: %v", err) return logs, nil } logf("书名: %s 作者: %s 最新章节: %s", info.Name, info.Author, info.LatestChapter) logf("访问目录页: %s", info.TocURL) - chapters, err := s.GetToc(ctx, sourceID, first.BookURL, info.TocURL) + chapters, err := s.GetToc(ctx, sourceID, "", first.BookURL, info.TocURL) if err != nil { logf("目录失败: %v", err) return logs, nil @@ -911,7 +911,7 @@ func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]stri continue } logf("访问正文: %s", c.URL) - content, err := s.GetContent(ctx, sourceID, first.BookURL, c.URL) + content, err := s.GetContent(ctx, sourceID, "", first.BookURL, c.URL) if err != nil { logf("正文失败: %v", err) return logs, nil @@ -940,6 +940,26 @@ func (s *ReaderService) loadSource(ctx context.Context, sourceID string) (*model return src, bs, nil } +// loadSourceFlexible 按 ID 或 URL 加载书源(书架上只存 origin URL, +// 前端阅读链路用 source_url 定位书源)。 +func (s *ReaderService) loadSourceFlexible(ctx context.Context, sourceID, sourceURL string) (*model.ReaderBookSource, *BookSource, error) { + if sourceID != "" { + return s.loadSource(ctx, sourceID) + } + if sourceURL == "" { + return nil, nil, fmt.Errorf("缺少书源标识(source_id 或 source_url)") + } + src, err := s.repo.GetSourceByURL(ctx, sourceURL) + if err != nil { + return nil, nil, fmt.Errorf("书源不存在或已被删除") + } + bs, err := ParseBookSource(src.RawJSON) + if err != nil { + return nil, nil, fmt.Errorf("书源 JSON 解析失败: %w", err) + } + return src, bs, nil +} + func applySourceHeaders(req *rule.Request, src *model.ReaderBookSource) { if src.Header == "" { return diff --git a/web/src/App.tsx b/web/src/App.tsx index bef1f1a..8956856 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { appRoutes, type AppRoute } from './appRoutes' import { Layout } from './components/Layout' import { RequireAdmin, RequireAuth } from './components/RequireAuth' const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage }))) +const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default }))) const Loading = () =>

加载中…

@@ -62,6 +63,17 @@ export default function App() { }> } /> + {/* 阅读独立布局(不套影视 Layout,全屏沉浸) */} + + }> + + + + } + /> api.get<{ sources: ReaderSource[] }>('/reader/sources').then((r) => r.data.sources), + importSources: (text: string) => + api.post<{ imported: number }>('/reader/sources/import', { text }, longOpts).then((r) => r.data.imported), + setSourceEnabled: (id: string, enabled: boolean) => api.patch(`/reader/sources/${id}`, { enabled }), + deleteSource: (id: string) => api.delete(`/reader/sources/${id}`), + debugSource: (id: string, key: string) => + api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs), + + // ── 搜索 / 详情 / 目录 / 正文 ── + search: (key: string) => + api + .post<{ books: ReaderSearchBook[]; skipped: ReaderSearchSkipped[] }>('/reader/search', { key }, longOpts) + .then((r) => r.data), + bookInfo: (params: { source_id?: string; source_url?: string; book_url: string }) => + api.get('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data), + toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) => + api + .get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT }) + .then((r) => r.data.chapters), + content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) => + api + .get('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT }) + .then((r) => r.data), + + // ── 书架 ── + listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books), + addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) => + api.post('/reader/books', body).then((r) => r.data), + removeBook: (id: string) => api.delete(`/reader/books/${id}`), + saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) => + api.put(`/reader/books/${id}/progress`, body), + 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 }), + + // ── 替换规则 ── + listReplaceRules: () => + api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules), +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 58c2c9f..e54e02b 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -23,6 +23,9 @@ import { HomeLibraryRowSection, HomeLoadingState, } from './HomePageSections' +import { ReaderHomeContent } from './reader/ReaderHomeContent' +import { ReaderModeSwitch } from './reader/ReaderModeSwitch' +import { useReaderSettingsStore } from '../stores/readerSettings' const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) @@ -37,6 +40,7 @@ export function HomePage() { const { pinnedIds } = usePinnedLibraries() const { field: sortField, order: sortOrder } = useLibraryListSort() const libraryTags = useLibraryTags() + const homeMode = useReaderSettingsStore((s) => s.homeMode) // 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。 // 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。 @@ -315,6 +319,14 @@ export function HomePage() { // 库列表还没回来先展示整页 loading;库为空时再等一下播放记录, // 以免在"空站点"和"有观看记录"两个终态之间闪空白。 + // 阅读模式下跳过影视内容的加载门槛,直接渲染书架。 + if (homeMode === 'reading') { + return ( +
+ +
+ ) + } if (librariesLoading || (libraries.length === 0 && historyLoading)) { return } @@ -328,6 +340,7 @@ export function HomePage() { return (
+ {/* 1. 顶部海报轮播区 */} {carouselItems.length > 0 && ( diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx new file mode 100644 index 0000000..c842d46 --- /dev/null +++ b/web/src/pages/reader/ReaderBookPage.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import toast from 'react-hot-toast' +import { ArrowLeft, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react' + +import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderTocChapter } from '../../api/reader' + +// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。 + +export default function ReaderBookPage() { + const navigate = useNavigate() + const [params] = useSearchParams() + const sourceURL = params.get('source_url') ?? '' + const bookURL = params.get('book_url') ?? '' + const [qName, qAuthor] = [params.get('name') ?? '', params.get('author') ?? ''] + const [qCover] = [params.get('cover_url') ?? ''] + const [originID] = [params.get('origin_id') ?? ''] + const [originName] = [params.get('origin_name') ?? ''] + + const [info, setInfo] = useState(null) + const [infoError, setInfoError] = useState('') + const [chapters, setChapters] = useState(null) + const [shelfBook, setShelfBook] = useState(null) + const [busy, setBusy] = useState(false) + const [tocExpanded, setTocExpanded] = useState(false) + const [introExpanded, setIntroExpanded] = useState(false) + + const loadInfo = () => { + if (!bookURL) return + setInfoError('') + readerAPI + .bookInfo({ source_url: sourceURL, book_url: bookURL }) + .then(setInfo) + .catch((e) => setInfoError(e?.response?.data?.error ?? '详情加载失败')) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(loadInfo, [bookURL]) + + // 书架状态 + 目录 + useEffect(() => { + if (!bookURL) return + readerAPI + .listBooks() + .then((books) => { + const hit = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null + setShelfBook(hit) + }) + .catch(() => undefined) + readerAPI + .toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL }) + .then(setChapters) + .catch(() => setChapters([])) + }, [bookURL, sourceURL]) + + // 详情返回的 tocUrl 更准确,拿到后重新拉目录 + useEffect(() => { + const tocURL = info?.toc_url + if (!tocURL || tocURL === bookURL) return + readerAPI + .toc({ source_url: sourceURL, book_url: bookURL, toc_url: tocURL }) + .then(setChapters) + .catch(() => undefined) + }, [info?.toc_url, sourceURL, bookURL]) + + const name = info?.name || qName + const author = info?.author || qAuthor + const cover = info?.cover_url || qCover + + const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters]) + + const startReading = async () => { + setBusy(true) + try { + let book = shelfBook + if (!book) { + book = await readerAPI.addBook({ + origin: { + source_id: originID, + origin: sourceURL, + origin_name: originName || info?.name || '', + origin_type: 0, + book_url: bookURL, + latest_chapter: info?.latest_chapter ?? '', + }, + name, + author, + cover_url: cover, + }) + } + navigate(`/reader/view/${book.id}`) + } catch (e) { + toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败') + } finally { + setBusy(false) + } + } + + const removeFromShelf = async () => { + if (!shelfBook) return + try { + await readerAPI.removeBook(shelfBook.id) + toast.success('已移出书架') + setShelfBook(null) + } catch { + toast.error('移出失败') + } + } + + const refreshToc = async () => { + if (!info?.toc_url && !bookURL) return + setBusy(true) + try { + const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: info?.toc_url || bookURL }) + setChapters(list) + toast.success(`目录已刷新,共 ${list.length} 章`) + } catch { + toast.error('目录刷新失败') + } finally { + setBusy(false) + } + } + + if (!bookURL) { + return ( +
缺少书籍参数
+ ) + } + + return ( +
+
+ +

{name}

+ +
+ + {/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */} +
+
+ {cover ? ( + {name} + ) : ( +
+ +
+ )} +
+
+

{name || '未知书名'}

+

{author || '佚名'}

+ {info?.kind && ( +

+ {info.kind.split(/[,,]/).filter(Boolean).slice(0, 5).map((k) => ( + + {k} + + ))} +

+ )} +

+ 最新:{info?.latest_chapter || '加载中…'} +

+

书源:{originName || sourceURL}

+ {(info?.intro || infoError) && ( +
+

+ {infoError || info?.intro} +

+ {!infoError && ( + + )} +
+ )} +
+
+ + {/* 动作区 */} +
+ {shelfBook ? ( + + ) : ( + + )} + +
+ + {/* 目录 */} +
+
+

+ 目录 {chapters !== null && ({chapters.length} 章)} +

+ {chapters !== null && chapters.length > 20 && ( + + )} +
+ {chapters === null ? ( +
+ +
+ ) : ( +
+ {chapters.map((c) => ( +
+ {c.is_volume ? ( +

{c.title}

+ ) : ( + c.title + )} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/web/src/pages/reader/ReaderHomeContent.tsx b/web/src/pages/reader/ReaderHomeContent.tsx new file mode 100644 index 0000000..657330d --- /dev/null +++ b/web/src/pages/reader/ReaderHomeContent.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { BookOpen, Loader2, RefreshCw, Search, Settings2 } from 'lucide-react' + +import { readerAPI, type ReaderBook } from '../../api/reader' +import { ReaderModeSwitch } from './ReaderModeSwitch' + +// 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。 +// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上搜索/书源管理入口。 + +export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) { + const navigate = useNavigate() + const [books, setBooks] = useState(null) + const [error, setError] = useState('') + + const load = () => { + setError('') + readerAPI + .listBooks() + .then(setBooks) + .catch((e) => setError(e?.response?.data?.error ?? '加载书架失败')) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(load, []) + + return ( +
+ {!embedded && } + +
+

书架

+
+ + 书源管理 + + + 搜索 + +
+
+ + {books === null && !error && ( +
+ +
+ )} + + {error && books === null && ( +
+

{error}

+ +
+ )} + + {books !== null && books.length === 0 && ( +
+
+ +
+

书架空空如也

+

先到「书源管理」导入书源,再搜索加入书架

+
+ 导入书源 + 去搜索 +
+
+ )} + + {books !== null && books.length > 0 && ( +
+ {books.map((book) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/reader/ReaderHomePage.tsx b/web/src/pages/reader/ReaderHomePage.tsx new file mode 100644 index 0000000..e4b3c9c --- /dev/null +++ b/web/src/pages/reader/ReaderHomePage.tsx @@ -0,0 +1,10 @@ +import { ReaderHomeContent } from './ReaderHomeContent' + +// 阅读主页(/reader):书架 + 搜索/书源入口。 +export default function ReaderHomePage() { + return ( +
+ +
+ ) +} diff --git a/web/src/pages/reader/ReaderModeSwitch.tsx b/web/src/pages/reader/ReaderModeSwitch.tsx new file mode 100644 index 0000000..d0a932f --- /dev/null +++ b/web/src/pages/reader/ReaderModeSwitch.tsx @@ -0,0 +1,35 @@ +import { BookOpen, Clapperboard } from 'lucide-react' + +import { useReaderSettingsStore } from '../../stores/readerSettings' + +// 首页「影视 / 阅读」分段切换(样式对齐 LibraryTagBar 页签)。 +export function ReaderModeSwitch() { + const homeMode = useReaderSettingsStore((s) => s.homeMode) + const setHomeMode = useReaderSettingsStore((s) => s.setHomeMode) + + const itemClass = (active: boolean) => + `flex items-center gap-1.5 rounded-xl border px-4 py-1.5 text-xs font-bold transition ${ + active + ? 'border-brand-500/60 bg-brand-500/10 text-brand-600' + : 'border-[var(--app-border)] text-[var(--app-muted)] hover:text-[var(--app-text)]' + }` + + return ( +
+ + +
+ ) +} diff --git a/web/src/pages/reader/ReaderRoutes.tsx b/web/src/pages/reader/ReaderRoutes.tsx new file mode 100644 index 0000000..d5e0be4 --- /dev/null +++ b/web/src/pages/reader/ReaderRoutes.tsx @@ -0,0 +1,21 @@ +import { Navigate, Route, Routes } from 'react-router-dom' + +import ReaderBookPage from './ReaderBookPage' +import ReaderHomePage from './ReaderHomePage' +import ReaderSearchPage from './ReaderSearchPage' +import ReaderSourcesPage from './ReaderSourcesPage' +import ReaderViewPage from './ReaderViewPage' + +// 阅读独立布局路由(不套影视 Layout,全屏沉浸;挂在 /reader/* 下)。 +export default function ReaderRoutes() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + + ) +} diff --git a/web/src/pages/reader/ReaderSearchPage.tsx b/web/src/pages/reader/ReaderSearchPage.tsx new file mode 100644 index 0000000..97863dd --- /dev/null +++ b/web/src/pages/reader/ReaderSearchPage.tsx @@ -0,0 +1,201 @@ +import { useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import toast from 'react-hot-toast' +import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react' + +import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader' + +// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。 + +export default function ReaderSearchPage() { + const navigate = useNavigate() + const [key, setKey] = useState('') + const [searching, setSearching] = useState(false) + const [books, setBooks] = useState(null) + const [skipped, setSkipped] = useState([]) + const [showSkipped, setShowSkipped] = useState(false) + const [adding, setAdding] = useState('') + const inputRef = useRef(null) + + const doSearch = async () => { + const kw = key.trim() + if (!kw || searching) return + setSearching(true) + setShowSkipped(false) + try { + const res = await readerAPI.search(kw) + setBooks(res.books) + setSkipped(res.skipped) + if (res.books.length === 0) toast.error('所有书源都没有找到结果') + } catch (e) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败' + toast.error(msg) + } finally { + setSearching(false) + } + } + + const addToShelf = async (book: ReaderSearchBook) => { + const origin = book.origins[0] + if (!origin) return + setAdding(book.book_url) + try { + await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url }) + toast.success(`《${book.name}》已加入书架`) + } catch (e) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败' + toast.error(msg) + } finally { + setAdding('') + } + } + + const openBook = async (book: ReaderSearchBook) => { + const origin = book.origins[0] + if (!origin) return + navigate( + `/reader/book?${new URLSearchParams({ + source_url: origin.origin, + book_url: origin.book_url, + name: book.name, + author: book.author, + cover_url: book.cover_url, + origin_id: origin.source_id, + origin_name: origin.origin_name, + }).toString()}`, + ) + } + + return ( +
+
+ +
+ + setKey(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && doSearch()} + placeholder="搜索书名或作者" + className="w-full bg-transparent text-sm text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]" + /> +
+ +
+ + {searching && ( +
+ +

正在并发搜索启用的书源(单源最长 30 秒)…

+
+ )} + + {!searching && books !== null && ( +
+ {books.map((book) => ( +
+ +
+ +

+ {book.author || '佚名'} + {book.kind ? ` · ${book.kind}` : ''} + {book.word_count ? ` · ${book.word_count}` : ''} +

+

最新:{book.latest_chapter || '未知'}

+ {book.intro &&

{book.intro}

} +
+
+ +
+
+ ))} + + {skipped.length > 0 && ( +
+ + {showSkipped && ( +
+ {skipped.map((s) => ( +
+ {s.origin_name} + {s.reason} +
+ ))} + + 去书源管理排查 → + +
+ )} +
+ )} +
+ )} + + {!searching && books === null && ( +
+ 输入关键词开始搜索 · 需要 先导入书源 +
+ )} +
+ ) +} diff --git a/web/src/pages/reader/ReaderSourcesPage.tsx b/web/src/pages/reader/ReaderSourcesPage.tsx new file mode 100644 index 0000000..014e871 --- /dev/null +++ b/web/src/pages/reader/ReaderSourcesPage.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import toast from 'react-hot-toast' +import { ArrowLeft, Bug, Download, Loader2, Play, Trash2 } from 'lucide-react' + +import { readerAPI, type ReaderSource } from '../../api/reader' +import { READER_SOURCE_TYPES } from './sourceTypes' + +// 书源管理页(仿 legado 书源列表:启停开关、快速调试、导入)。 + +function hostOf(url: string): string { + try { + return new URL(url).host + } catch { + return url + } +} + +export default function ReaderSourcesPage() { + const navigate = useNavigate() + const [sources, setSources] = useState(null) + const [importText, setImportText] = useState('') + const [importing, setImporting] = useState(false) + const [showImport, setShowImport] = useState(false) + const [debugId, setDebugId] = useState('') + const [debugKey, setDebugKey] = useState('') + const [debugLogs, setDebugLogs] = useState(null) + const [debugging, setDebugging] = useState(false) + + const load = () => { + readerAPI + .listSources() + .then(setSources) + .catch(() => toast.error('加载书源失败')) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(load, []) + + const doImport = async () => { + if (!importText.trim() || importing) return + setImporting(true) + try { + const imported = await readerAPI.importSources(importText) + toast.success(`成功导入 ${imported} 个书源`) + setImportText('') + 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) + } + } + + return ( +
+
+ +

书源管理

+ +
+ + {showImport && ( +
+