From 84604e5ff751323474caf39feef4f248aa083e19 Mon Sep 17 00:00:00 2001 From: naiy Date: Fri, 31 Jul 2026 04:13:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B9=A6=E6=9E=B6=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=EF=BC=8C=E4=BC=98=E5=8C=96=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E5=85=81=E8=AE=B8=E8=B7=B3=E8=BD=AC=E8=87=B3=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E4=B8=8D=E5=85=81=E8=AE=B8=E6=9F=A5=E7=9C=8B=E7=9A=84?= =?UTF-8?q?=E4=B9=A6=E7=B1=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/bookshelf.ts | 85 +++++ src/assets/bookshelf.css | 647 ++++++++++++++++++++++++++++++++++++ src/assets/script.css | 4 + src/hooks/bookshelfHook.ts | 123 +++++++ src/types.ts | 81 ++++- src/utils/index.ts | 43 +++ src/utils/time.ts | 31 ++ src/views/BookCard.vue | 130 ++++++++ src/views/BookGroupCard.vue | 80 +++++ src/views/BookHoverCard.vue | 159 +++++++++ src/views/BookshelfView.vue | 387 +++++++++++++++++++++ src/views/useBookshelf.ts | 182 ++++++++++ src/vue-shim.d.ts | 5 + 13 files changed, 1956 insertions(+), 1 deletion(-) create mode 100644 src/api/bookshelf.ts create mode 100644 src/assets/bookshelf.css create mode 100644 src/hooks/bookshelfHook.ts create mode 100644 src/utils/time.ts create mode 100644 src/views/BookCard.vue create mode 100644 src/views/BookGroupCard.vue create mode 100644 src/views/BookHoverCard.vue create mode 100644 src/views/BookshelfView.vue create mode 100644 src/views/useBookshelf.ts create mode 100644 src/vue-shim.d.ts diff --git a/src/api/bookshelf.ts b/src/api/bookshelf.ts new file mode 100644 index 0000000..758b7d9 --- /dev/null +++ b/src/api/bookshelf.ts @@ -0,0 +1,85 @@ +import { fetch } from '../config' +import type { BookShelfBookInfo, BookShelfItem } from '../types' + +export async function getBookshelf(): Promise { + const responses = await Promise.all([ + fetch('https://fanqienovel.com/reading/bookapi/bookshelf/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700'), + fetch('https://fanqienovel.com/api/reader/book/progress') + ]) + const response = responses[0] + const response2 = responses[1] + const data = await response.json() + const info: Array = data.data.book_shelf_info + const data_ = await response2.json() + const data2: Array = data_.data + const all_items = info.map(item => item.book_id) + const response3 = await fetch('https://fanqienovel.com/api/book/simple/info', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + book_ids: all_items + }) + }) + const data__ = await response3.json() + const data3: Array = data__.data.bookList + const results = [] + for (let i of data.data.book_shelf_info) { + const progress = data2.find(it => it.book_id === i.book_id) + const simple = data3.find(it => it.book_id === i.book_id) + const item: BookShelfItem = { + book_id: i.book_id, + last_operate_time: progress ? progress.read_timestamp : i.last_operate_time, + add_shelf_time: i.add_shelf_time, + group_name: i.group_name, + last_read_timestamp: progress ? progress.read_timestamp : 0, + last_read_chapter_id: progress ? progress.item_id : '0', + is_publish: simple?.genre === '6' + } + results.push(item) + } + return results +} + +export async function multidetail(books: BookShelfItem[]): Promise { + const response = await fetch('https://fanqienovel.com/api/bookshelf/multidetail', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + books: books.map(book => { + return { + book_id: book.book_id, + item_id: book.last_read_chapter_id, + } + }) + }) + }) + const data = await response.json() + + const list = data.data.detail_list + const results = [] + for (let i of list) { + const item: BookShelfBookInfo = { + book_id: i.book_id, + summary: i.abstract, + title: i.book_name, + author: i.author_name, + cover_url: i.thumb_url, + current_chapter_title: i.item_show_title, + current_chapter_id: i.item_id, + current_chapter_order: i.real_chapter_order, + total_chapter_count: i.serial_count, + last_chapter_update_time: i.last_chapter_update_time * 1000, + last_chapter_id: i.last_chapter_item_id, + current_chapter_summary: i.item_abstract, + // last_chapter_title: i.last_chapter_show_title, + status: i.update_stop === '1' ? 4 : i.creation_status, + update_status: i.update_status + } + results.push(item) + } + return results +} \ No newline at end of file diff --git a/src/assets/bookshelf.css b/src/assets/bookshelf.css new file mode 100644 index 0000000..107e5ce --- /dev/null +++ b/src/assets/bookshelf.css @@ -0,0 +1,647 @@ +#fqa-bookshelf { + --fqa-text: #1f2329; + --fqa-text-sub: #646a73; + --fqa-text-weak: #8f959e; + --fqa-border: rgba(31, 35, 41, 0.08); + --fqa-hover: rgba(31, 35, 41, 0.04); + --fqa-accent: #ff6f3d; + --fqa-skeleton: rgba(31, 35, 41, 0.06); + --fqa-skeleton-hl: rgba(31, 35, 41, 0.12); + --fqa-shadow: 0 4px 16px rgba(31, 35, 41, 0.08); + + display: block; + box-sizing: border-box; + width: 100%; + max-width: 1100px; + margin: 0 auto; + /* 顶部留出原站 fixed 顶栏(80px)的高度,否则标题和 tab 会被压在下面 */ + padding: calc(80px + 24px) 16px 64px; + color: var(--fqa-text); + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, + sans-serif; +} + +#fqa-bookshelf *, +#fqa-bookshelf *::before, +#fqa-bookshelf *::after { + box-sizing: border-box; +} + +#fqa-bookshelf div, +#fqa-bookshelf span, +#fqa-bookshelf h1, +#fqa-bookshelf ul, +#fqa-bookshelf li { + margin: 0; + padding: 0; + border: 0; + list-style: none; + float: none; + position: static; +} + +/* ------------------------------ 顶部 / Tabs ------------------------------ */ + +#fqa-bookshelf .fqa-bs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; +} + +#fqa-bookshelf .fqa-bs-title { + font-size: 24px; + font-weight: 600; + line-height: 1.4; +} + +#fqa-bookshelf .fqa-bs-actions { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: var(--fqa-text-weak); +} + +#fqa-bookshelf .fqa-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 14px; + border: 1px solid var(--fqa-border); + border-radius: 999px; + background: transparent; + color: var(--fqa-text-sub); + font-size: 13px; + font-family: inherit; + line-height: 1.4; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +#fqa-bookshelf .fqa-btn:hover:not(:disabled) { + border-color: var(--fqa-accent); + color: var(--fqa-accent); + background: rgba(255, 111, 61, 0.06); +} + +#fqa-bookshelf .fqa-btn:disabled { + opacity: 0.5; + cursor: default; +} + +#fqa-bookshelf .fqa-tabs { + position: relative; + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid var(--fqa-border); +} + +#fqa-bookshelf .fqa-tab { + padding: 10px 16px; + color: var(--fqa-text-sub); + font-size: 15px; + line-height: 22px; + cursor: pointer; + user-select: none; + transition: color 0.15s ease; +} + +#fqa-bookshelf .fqa-tab:hover { + color: var(--fqa-text); +} + +#fqa-bookshelf .fqa-tab-active { + color: var(--fqa-accent); + font-weight: 600; +} + +#fqa-bookshelf .fqa-tab-count { + margin-left: 4px; + font-size: 12px; + font-weight: 400; + color: var(--fqa-text-weak); +} + +#fqa-bookshelf .fqa-tab-ink { + position: absolute; + bottom: -1px; + left: 0; + width: 0; + height: 2px; + border-radius: 2px; + background: var(--fqa-accent); + transition: left 0.25s ease, width 0.25s ease; +} + +/* ------------------------------- 书架网格 ------------------------------- */ + +/* 原站一排最多 4 本;窄屏逐级降到 3 / 2 */ +#fqa-bookshelf .fqa-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 28px 24px; + align-items: start; +} + +@media (max-width: 900px) { + #fqa-bookshelf .fqa-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 600px) { + #fqa-bookshelf .fqa-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +#fqa-bookshelf .fqa-card { + display: block; + border-radius: 8px; + cursor: pointer; + outline: none; +} + +#fqa-bookshelf .fqa-card:focus-visible { + box-shadow: 0 0 0 2px var(--fqa-accent); +} + +/* 封面:3:4,靠 aspect-ratio 定高,内部元素绝对定位 */ +#fqa-bookshelf .fqa-cover { + position: relative; + display: block; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: 6px; + overflow: hidden; + background: var(--fqa-skeleton); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +#fqa-bookshelf .fqa-card:hover .fqa-cover { + transform: translateY(-4px); + box-shadow: var(--fqa-shadow); +} + +#fqa-bookshelf .fqa-cover-img { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.25s ease; +} + +#fqa-bookshelf .fqa-cover-img-loading { + opacity: 0; +} + +#fqa-bookshelf .fqa-cover-tag { + position: absolute; + top: 0; + right: 0; + z-index: 2; + padding: 2px 6px; + border-radius: 0 6px 0 6px; + background: var(--fqa-accent); + color: #fff; + font-size: 11px; + line-height: 16px; + font-weight: 500; + white-space: nowrap; +} + +/* 连载 / 完结 / 断更共用:灰底,弱化于“更新”角标 */ +#fqa-bookshelf .fqa-cover-tag-gray { + background: rgba(31, 35, 41, 0.55); +} + +@media (prefers-color-scheme: dark) { + #fqa-bookshelf .fqa-cover-tag-gray { + background: rgba(0, 0, 0, 0.6); + } +} + +#fqa-bookshelf .fqa-cover-progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + height: 3px; + background: rgba(255, 255, 255, 0.35); +} + +#fqa-bookshelf .fqa-cover-progress-bar { + display: block; + height: 100%; + background: var(--fqa-accent); + transition: width 0.3s ease; +} + +/* 文字区:与封面同为普通流元素,不会重叠 */ +#fqa-bookshelf .fqa-card-title { + display: -webkit-box; + margin-top: 8px; + color: var(--fqa-text); + font-size: 14px; + line-height: 20px; + font-weight: 500; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; +} + +#fqa-bookshelf .fqa-card-sub { + margin-top: 4px; + color: var(--fqa-text-weak); + font-size: 12px; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ------------------------------- 分组卡片 ------------------------------- */ + +#fqa-bookshelf .fqa-group-cover { + position: relative; + display: block; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: 6px; + overflow: hidden; + background: linear-gradient(135deg, rgba(255, 111, 61, 0.12), rgba(78, 131, 253, 0.12)); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +#fqa-bookshelf .fqa-card:hover .fqa-group-cover { + transform: translateY(-4px); + box-shadow: var(--fqa-shadow); +} + +#fqa-bookshelf .fqa-group-grid { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 4px; + padding: 6px; +} + +#fqa-bookshelf .fqa-group-cell { + position: relative; + border-radius: 3px; + overflow: hidden; + background: rgba(31, 35, 41, 0.06); +} + +#fqa-bookshelf .fqa-group-cell img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 分组详情返回条 */ + +#fqa-bookshelf .fqa-groupbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +#fqa-bookshelf .fqa-groupbar-name { + font-size: 16px; + font-weight: 600; +} + +#fqa-bookshelf .fqa-groupbar-count { + color: var(--fqa-text-weak); + font-size: 13px; +} + +/* --------------------------- hover 详情浮层 --------------------------- */ + +/* + * 用 popover 进入浏览器顶层,不参与页面 z-index 竞争, + * 因此不会被相邻卡片或原站的层叠上下文盖住。z-index 仅作降级保险。 + */ +#fqa-bookshelf-hover { + position: fixed; + z-index: 2147483000; + /* 容器本身透传,只有内部卡片接收事件,避免空白区挡住下层 */ + pointer-events: none; + opacity: 0; + transform: translateY(4px); + /* allow-discrete:顶层元素从 display:none 切入时也能播放淡入 */ + transition: opacity 0.16s ease, transform 0.16s ease, display 0.16s allow-discrete; + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, + sans-serif; +} + +/* popover 默认带边框/内边距/居中定位,全部清掉,改由 left/top 控制 */ +#fqa-bookshelf-hover:popover-open, +#fqa-bookshelf-hover[popover] { + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: visible; + inset: auto; + width: auto; + height: auto; + max-width: none; + max-height: none; + color: inherit; +} + +#fqa-bookshelf-hover::backdrop { + background: transparent; +} + +#fqa-bookshelf-hover.fqa-visible { + opacity: 1; + transform: translateY(0); +} + +@starting-style { + #fqa-bookshelf-hover.fqa-visible { + opacity: 0; + transform: translateY(4px); + } +} + +/* 高度由 JS 按封面尺寸设定;纵向 flex 让简介吃掉剩余空间 */ +#fqa-bookshelf-hover .fqa-hover-inner { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 280px; + padding: 12px 14px; + border-radius: 10px; + background: #fff; + box-shadow: 0 8px 32px rgba(31, 35, 41, 0.16); + color: #1f2329; + overflow: hidden; + /* 卡片可交互:鼠标可以移进来而不触发收起 */ + pointer-events: auto; +} + +#fqa-bookshelf-hover .fqa-hover-title { + flex: none; + margin: 0; + font-size: 14px; + line-height: 20px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#fqa-bookshelf-hover .fqa-hover-author { + flex: none; + margin-top: 2px; + color: #8f959e; + font-size: 12px; + line-height: 17px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#fqa-bookshelf-hover .fqa-hover-stats { + display: flex; + flex: none; + margin-top: 10px; +} + +#fqa-bookshelf-hover .fqa-hover-stat { + flex: 1 1 0; + min-width: 0; + padding: 0 6px; + text-align: center; +} + +#fqa-bookshelf-hover .fqa-hover-stat:first-child { + padding-left: 0; +} + +#fqa-bookshelf-hover .fqa-hover-stat:last-child { + padding-right: 0; +} + +#fqa-bookshelf-hover .fqa-hover-stat + .fqa-hover-stat { + border-left: 1px solid rgba(31, 35, 41, 0.08); +} + +/* 第一栏可悬停切换为更新时间,给个可交互提示 */ +#fqa-bookshelf-hover .fqa-hover-stat:first-child { + border-radius: 4px; + cursor: default; + transition: background 0.15s ease; +} + +#fqa-bookshelf-hover .fqa-hover-stat:first-child:hover { + background: rgba(31, 35, 41, 0.05); +} + +#fqa-bookshelf-hover .fqa-hover-stat-v { + font-size: 13px; + line-height: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#fqa-bookshelf-hover .fqa-hover-stat-k { + margin-top: 1px; + color: #8f959e; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 梗概 / 简介 双栏切换 */ +#fqa-bookshelf-hover .fqa-hover-seg { + display: flex; + flex: none; + gap: 4px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(31, 35, 41, 0.08); +} + +#fqa-bookshelf-hover .fqa-hover-seg-btn { + flex: 1 1 0; + padding: 4px 0; + border: 0; + border-radius: 5px; + background: rgba(31, 35, 41, 0.05); + color: #646a73; + font-family: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +#fqa-bookshelf-hover .fqa-hover-seg-btn:hover { + color: #1f2329; +} + +#fqa-bookshelf-hover .fqa-hover-seg-active { + background: rgba(255, 111, 61, 0.12); + color: #ff6f3d; + font-weight: 500; +} + +#fqa-bookshelf-hover .fqa-hover-seg-active:hover { + color: #ff6f3d; +} + +/* + * 撑满剩余高度。行数不再写死,由容器高度自然裁切; + * min-height:0 让 flex 子项允许被压缩,否则 overflow 不生效。 + */ +#fqa-bookshelf-hover .fqa-hover-abstract { + flex: 1 1 auto; + min-height: 0; + margin-top: 8px; + color: #646a73; + font-size: 12px; + line-height: 18px; + overflow-y: auto; + overscroll-behavior: contain; +} + +#fqa-bookshelf-hover .fqa-hover-abstract::-webkit-scrollbar { + width: 4px; +} + +#fqa-bookshelf-hover .fqa-hover-abstract::-webkit-scrollbar-thumb { + border-radius: 2px; + background: rgba(31, 35, 41, 0.18); +} + + +#fqa-bookshelf-hover .fqa-hover-chapter { + display: block; + margin-bottom: 1px; + color: #1f2329; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ------------------------------- 骨架屏 ------------------------------- */ + +#fqa-bookshelf .fqa-sk-cover { + display: block; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: 6px; + background: var(--fqa-skeleton); +} + +#fqa-bookshelf .fqa-sk-line { + height: 12px; + margin-top: 8px; + border-radius: 4px; + background: var(--fqa-skeleton); +} + +#fqa-bookshelf .fqa-sk-anim { + position: relative; + overflow: hidden; +} + +#fqa-bookshelf .fqa-sk-anim::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, var(--fqa-skeleton-hl), transparent); + animation: fqa-shimmer 1.4s infinite; +} + +@keyframes fqa-shimmer { + 100% { + transform: translateX(100%); + } +} + +/* --------------------------- 空态 / 错误态 --------------------------- */ + +#fqa-bookshelf .fqa-loadmore { + padding: 24px 0; + text-align: center; + color: var(--fqa-text-weak); + font-size: 13px; +} + +#fqa-bookshelf .fqa-status { + padding: 80px 16px; + text-align: center; + color: var(--fqa-text-weak); + font-size: 14px; + line-height: 22px; +} + +#fqa-bookshelf .fqa-status-title { + margin-bottom: 8px; + color: var(--fqa-text); + font-size: 16px; + font-weight: 500; +} + +#fqa-bookshelf .fqa-status .fqa-btn { + margin-top: 16px; +} + +/* ------------------------------- 深色模式 ------------------------------- */ + +@media (prefers-color-scheme: dark) { + #fqa-bookshelf { + --fqa-text: #e6e6e6; + --fqa-text-sub: #a6a6a6; + --fqa-text-weak: #7a7a7a; + --fqa-border: rgba(255, 255, 255, 0.1); + --fqa-hover: rgba(255, 255, 255, 0.06); + --fqa-skeleton: rgba(255, 255, 255, 0.08); + --fqa-skeleton-hl: rgba(255, 255, 255, 0.14); + --fqa-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + } + + #fqa-bookshelf-hover .fqa-hover-inner { + background: #212125; + color: #e6e6e6; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + } + + #fqa-bookshelf-hover .fqa-hover-chapter { + color: #e6e6e6; + } + + #fqa-bookshelf-hover .fqa-hover-abstract { + color: #a6a6a6; + border-top-color: rgba(255, 255, 255, 0.1); + } + + #fqa-bookshelf-hover .fqa-hover-stat + .fqa-hover-stat { + border-left-color: rgba(255, 255, 255, 0.1); + } +} diff --git a/src/assets/script.css b/src/assets/script.css index 84d123c..98a1f50 100644 --- a/src/assets/script.css +++ b/src/assets/script.css @@ -42,4 +42,8 @@ .fqa-menu-item, .arco-menu-item { width: 100%!important; +} + +#dynamic-el { + display: none!important; } \ No newline at end of file diff --git a/src/hooks/bookshelfHook.ts b/src/hooks/bookshelfHook.ts new file mode 100644 index 0000000..a6f06f4 --- /dev/null +++ b/src/hooks/bookshelfHook.ts @@ -0,0 +1,123 @@ +import { createApp, type App } from 'vue' +import type { HookConfig } from '../config' +import BookshelfView from '../views/BookshelfView.vue' +import bookshelfcss from '../assets/bookshelf.css?raw' +import { waitForElement } from '../utils' + +const CONTAINER_ID = 'fqa-bookshelf-root' +const STYLE_ID = 'fqa-bookshelf-style' +// 原站书架根节点,命中其一即视为页面已渲染 +const ORIGIN_SELECTOR = '.muye-bookshelf, .muye-bookshelf-home-page, .bookshelf-tabs' + +let app: App | null = null +let container: HTMLElement | null = null +let observer: MutationObserver | null = null + +function injectStyle() { + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = bookshelfcss + document.head.appendChild(style) +} + +// 防止白屏 +function hideOrigin(root: ParentNode = document) { + root.querySelectorAll(ORIGIN_SELECTOR).forEach(el => { + if (el.id === CONTAINER_ID || el.closest(`#${CONTAINER_ID}`)) return + el.classList.add('fqa-hide') + }) +} + +function isBookshelfPath(path: string) { + return path.startsWith('/bookshelf') +} + +function unmount() { + observer?.disconnect() + observer = null + app?.unmount() + app = null + container?.remove() + container = null + document.getElementById('fqa-bookshelf-hover')?.remove() + // 还原原站书架,避免用户离开后又回到 /bookshelf 时白屏 + document.querySelectorAll(ORIGIN_SELECTOR).forEach(el => { + el.classList.remove('fqa-hide') + }) +} + +async function mainHook(_previous?: string): Promise { + if (!isBookshelfPath(window.location.pathname)) { + unmount() + return + } + if (app) { + // 已挂载,重复触发(例如 tab 内的 replaceState)时无需重建 + hideOrigin() + return + } + + injectStyle() + + const origin = await waitForElement(ORIGIN_SELECTOR) + // 等待期间可能已经离开书架页 + if (!isBookshelfPath(window.location.pathname)) return + if (app) return + + hideOrigin() + + container = document.createElement('div') + container.id = CONTAINER_ID + const anchor = origin ?? document.querySelector('#root') ?? document.body + if (origin?.parentElement) { + origin.insertAdjacentElement('beforebegin', container) + } else { + anchor.appendChild(container) + } + + app = createApp(BookshelfView) + app.config.errorHandler = (err, _instance, info) => { + console.error(`[fqa:bookshelf] Vue error (${info}):`, err) + } + app.mount(container) + console.log('[fqa:bookshelf] 书架视图已挂载') + + document.title = '我的书架 - 番茄小说' + + observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (!(node instanceof HTMLElement)) continue + if (node.id === CONTAINER_ID || node.closest(`#${CONTAINER_ID}`)) continue + if (node.matches?.(ORIGIN_SELECTOR)) { + node.classList.add('fqa-hide') + } else { + hideOrigin(node) + } + } + } + }) + observer.observe(document.body, { childList: true, subtree: true }) +} + +function filter(path: string, _query: URLSearchParams, _hash: string) { + return isBookshelfPath(path) || !!app +} + +const _exports: HookConfig[] = [ + { + id: 'bookshelfHook_onload', + event: 'load', + filter, + handler: mainHook + }, + { + id: 'bookshelfHook_onurlchange', + event: 'onUrlChange', + filter, + handler: mainHook + } +] + +export default _exports diff --git a/src/types.ts b/src/types.ts index ed8334e..d737934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,4 +31,83 @@ export interface CatalogResult { volume_list: VolumeItem[]; chapter_list: ChapterItem[]; all_item_ids: string[]; -} \ No newline at end of file +} + +export interface UserTitle { + title: string + desc: string +} + +export interface UserInfo { + id: string + username: string + avatar: string + desc: string + age: number + // detailed + gender?: number // 0: 女 | 1: 男 | 2: 未知/没设置 + recommend_gender?: number // 0: 女频 | 1: 男频 | 2: 我都爱看 + fans_num?: number + following_num?: number + is_author?: boolean + author_desc?: string + read_book_num?: number + read_book_time?: bigint // ms +} + +export interface UserState { + isLogin: boolean + userInfo: UserInfo | null +} + +export interface BookShelfItem { + book_id: string + last_operate_time: number // ms + add_shelf_time: number // ms + // 没有时说明没有分组 + group_name?: string + last_read_timestamp: number // ms + last_read_chapter_id: string + is_publish: boolean +} + +export interface BookShelfBookInfo { + book_id: string + title: string + author: string + cover_url: string + current_chapter_title: string + current_chapter_id: string + current_chapter_order: number + total_chapter_count: number + last_chapter_update_time: number + last_chapter_id: string + current_chapter_summary: string + // last_chapter_title: string + summary: string + status: string // 书籍总体状态。应该先看这个,再看 update_status | 需要特殊判断一下,断更通过 $.update_stop === '1' 判断。根据与详情相同的 API 约束,断更时这个值应该是 4 + update_status: string // 书籍相较于用户的状态 | 0 - 连载 | 1 - 有更新 +} + +/** + * 书架条目 = 列表项(快) + 详情(慢,分批补齐)。 + * detail 为 null 时视图渲染骨架屏。 + */ +export interface BookShelfEntry { + item: BookShelfItem + detail: BookShelfBookInfo | null +} + +export interface BookShelfGroup { + name: string + books: BookShelfEntry[] + /** 组内最近操作时间,用于和散书一起按时间排序 */ + last_operate_time: number +} + +export type BookShelfTabKey = 'all' | 'group' | 'publish' + +/** 全部 tab 里分组卡片与散书混排 */ +export type BookShelfCell = + | { kind: 'book'; key: string; entry: BookShelfEntry } + | { kind: 'group'; key: string; group: BookShelfGroup } \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index aaf8a7a..3c0fbdf 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,4 +4,47 @@ export async function sleep(ms: number): Promise { export function cloneElement(element: T): T { return element.cloneNode(true) as T; +} + +/** + * 等待选择器命中的元素出现。 + */ +export function waitForElement( + selector: string, + timeout = 15_000 +): Promise { + const existing = document.querySelector(selector); + if (existing) return Promise.resolve(existing); + + return new Promise(resolve => { + let timer: ReturnType | undefined; + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + if (timer) clearTimeout(timer); + observer.disconnect(); + resolve(el); + } + }); + const start = () => { + observer.observe(document.documentElement, { childList: true, subtree: true }); + timer = setTimeout(() => { + observer.disconnect(); + resolve(document.querySelector(selector)); + }, timeout); + }; + if (document.documentElement) { + start(); + } else { + document.addEventListener('DOMContentLoaded', start, { once: true }); + } + }); +} + +export function chunk(list: T[], size: number): T[][] { + const result: T[][] = []; + for (let i = 0; i < list.length; i += size) { + result.push(list.slice(i, i + size)); + } + return result; } \ No newline at end of file diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..63c9010 --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,31 @@ +const SECOND = 1_000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR +const MONTH = 30 * DAY +const YEAR = 365 * DAY + +export function normalizeTimestamp(ts?: number | null): number { + if (!ts || ts <= 0) return 0 + return ts < 1e12 ? ts * 1000 : ts +} + +export function fromNow(ts?: number | null): string { + const time = normalizeTimestamp(ts) + if (!time) return '从未' + const diff = Date.now() - time + if (diff < MINUTE) return '刚刚' + if (diff < HOUR) return `${Math.floor(diff / MINUTE)}分钟前` + if (diff < DAY) return `${Math.floor(diff / HOUR)}小时前` + if (diff < MONTH) return `${Math.floor(diff / DAY)}天前` + if (diff < YEAR) return `${Math.floor(diff / MONTH)}个月前` + return `${Math.floor(diff / YEAR)}年前` +} + +export function formatDateTime(ts?: number | null): string { + const time = normalizeTimestamp(ts) + if (!time) return '未知' + const d = new Date(time) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} diff --git a/src/views/BookCard.vue b/src/views/BookCard.vue new file mode 100644 index 0000000..3e34a03 --- /dev/null +++ b/src/views/BookCard.vue @@ -0,0 +1,130 @@ + + + diff --git a/src/views/BookGroupCard.vue b/src/views/BookGroupCard.vue new file mode 100644 index 0000000..bbb0f01 --- /dev/null +++ b/src/views/BookGroupCard.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/views/BookHoverCard.vue b/src/views/BookHoverCard.vue new file mode 100644 index 0000000..f52bd3c --- /dev/null +++ b/src/views/BookHoverCard.vue @@ -0,0 +1,159 @@ + + + diff --git a/src/views/BookshelfView.vue b/src/views/BookshelfView.vue new file mode 100644 index 0000000..46bf705 --- /dev/null +++ b/src/views/BookshelfView.vue @@ -0,0 +1,387 @@ + + + diff --git a/src/views/useBookshelf.ts b/src/views/useBookshelf.ts new file mode 100644 index 0000000..9542169 --- /dev/null +++ b/src/views/useBookshelf.ts @@ -0,0 +1,182 @@ +import { computed, ref } from 'vue' +import { getBookshelf, multidetail } from '../api/bookshelf' +import type { + BookShelfBookInfo, + BookShelfCell, + BookShelfEntry, + BookShelfGroup, + BookShelfItem, + BookShelfTabKey +} from '../types' +import { chunk } from '../utils' + +/** multidetail 一次请求的书籍数,避免书架过大时单请求超时 */ +const DETAIL_BATCH_SIZE = 20 + +/** 首屏先渲染的卡片数,其余滚动到底部时再追加 */ +const PAGE_SIZE = 24 + +export interface TabDef { + key: BookShelfTabKey + label: string +} + +export const TABS: TabDef[] = [ + { key: 'all', label: '全部' }, + { key: 'group', label: '分组' }, + { key: 'publish', label: '出版' } +] + +export function useBookshelf() { + const entries = ref([]) + const loading = ref(false) + const detailLoading = ref(false) + const error = ref(null) + + /** 并发/重复加载时用于丢弃过期响应 */ + let loadToken = 0 + /** 已拿到的详情,跨 tab 切换复用,避免重复请求 */ + let detailCache = new Map() + /** 正在请求中的 book_id,防止同一本书被并发拉两次 */ + let inflight = new Set() + + function applyDetails(details: BookShelfBookInfo[]) { + if (!details.length) return + const byId = new Map(details.map(d => [d.book_id, d])) + entries.value = entries.value.map(entry => { + const detail = byId.get(entry.item.book_id) + return detail ? { ...entry, detail } : entry + }) + } + + /** + * 按需拉取详情:只请求传入的、尚未加载且不在飞行中的书。 + * 由可见卡片驱动,滚动到哪拉到哪。 + */ + async function ensureDetails(items: BookShelfItem[]) { + const token = loadToken + const pending = items.filter( + item => !detailCache.has(item.book_id) && !inflight.has(item.book_id) + ) + if (!pending.length) return + + pending.forEach(item => inflight.add(item.book_id)) + detailLoading.value = true + try { + for (const batch of chunk(pending, DETAIL_BATCH_SIZE)) { + if (token !== loadToken) return + try { + const details = await multidetail(batch) + if (token !== loadToken) return + details.forEach(d => detailCache.set(d.book_id, d)) + applyDetails(details) + } catch (err) { + // 单批失败不影响其余批次,对应的卡片保持骨架态 + console.error('[fqa:bookshelf] 加载书籍详情失败:', err) + } + } + } finally { + pending.forEach(item => inflight.delete(item.book_id)) + if (token === loadToken && inflight.size === 0) detailLoading.value = false + } + } + + async function load(force = false) { + const token = ++loadToken + inflight.clear() + if (force) detailCache = new Map() + loading.value = true + error.value = null + try { + const items = await getBookshelf() + if (token !== loadToken) return + items.sort((a, b) => b.last_operate_time - a.last_operate_time) + // 命中缓存的直接带上详情,其余留空由可见性驱动拉取 + entries.value = items.map(item => ({ + item, + detail: detailCache.get(item.book_id) ?? null + })) + loading.value = false + } catch (err) { + if (token !== loadToken) return + console.error('[fqa:bookshelf] 加载书架失败:', err) + error.value = err instanceof Error ? err.message : String(err) + loading.value = false + } + } + + const groups = computed(() => { + const map = new Map() + for (const entry of entries.value) { + const name = entry.item.group_name + if (!name) continue + let group = map.get(name) + if (!group) { + group = { name, books: [], last_operate_time: 0 } + map.set(name, group) + } + group.books.push(entry) + group.last_operate_time = Math.max(group.last_operate_time, entry.item.last_operate_time) + } + return [...map.values()].sort((a, b) => b.last_operate_time - a.last_operate_time) + }) + + /** 全部 tab:散书与分组卡片按最近操作时间混排 */ + const allCells = computed(() => { + const sortable: Array<{ time: number; cell: BookShelfCell }> = [] + for (const entry of entries.value) { + if (entry.item.group_name) continue + sortable.push({ + time: entry.item.last_operate_time, + cell: { kind: 'book', key: `book:${entry.item.book_id}`, entry } + }) + } + for (const group of groups.value) { + sortable.push({ + time: group.last_operate_time, + cell: { kind: 'group', key: `group:${group.name}`, group } + }) + } + return sortable.sort((a, b) => b.time - a.time).map(s => s.cell) + }) + + const groupCells = computed(() => + groups.value.map(group => ({ kind: 'group', key: `group:${group.name}`, group })) + ) + + const publishCells = computed(() => + entries.value + .filter(entry => entry.item.is_publish) + .map(entry => ({ kind: 'book', key: `book:${entry.item.book_id}`, entry })) + ) + + const counts = computed>(() => ({ + all: entries.value.length, + group: groups.value.length, + publish: entries.value.filter(entry => entry.item.is_publish).length + })) + + function cellsOf(tab: BookShelfTabKey): BookShelfCell[] { + if (tab === 'group') return groupCells.value + if (tab === 'publish') return publishCells.value + return allCells.value + } + + function findGroup(name: string): BookShelfGroup | null { + return groups.value.find(group => group.name === name) ?? null + } + + return { + entries, + loading, + detailLoading, + error, + groups, + counts, + load, + ensureDetails, + cellsOf, + findGroup, + PAGE_SIZE + } +} diff --git a/src/vue-shim.d.ts b/src/vue-shim.d.ts new file mode 100644 index 0000000..30bd4da --- /dev/null +++ b/src/vue-shim.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +}