feat: 书架页面替换,优化展示并允许跳转至网页不允许查看的书籍

This commit is contained in:
naiy committed 2026-07-31 04:13:47 +08:00
1 parent 2efb8e78bb
commit 84604e5ff7
13 files changed
+1956 -1

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
import { fetch } from '../config'
import type { BookShelfBookInfo, BookShelfItem } from '../types'
export async function getBookshelf(): Promise<BookShelfItem[]> {
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<any> = data.data.book_shelf_info
const data_ = await response2.json()
const data2: Array<any> = 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<any> = 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<BookShelfBookInfo[]> {
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
}
+647
View File
@@ -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);
}
}
+4
View File
@@ -42,4 +42,8 @@
.fqa-menu-item,
.arco-menu-item {
width: 100%!important;
}
#dynamic-el {
display: none!important;
}
+123
View File
@@ -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<HTMLElement>(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<HTMLElement>(ORIGIN_SELECTOR).forEach(el => {
el.classList.remove('fqa-hide')
})
}
async function mainHook(_previous?: string): Promise<void> {
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
+80 -1
View File
@@ -31,4 +31,83 @@ export interface CatalogResult {
volume_list: VolumeItem[];
chapter_list: ChapterItem[];
all_item_ids: string[];
}
}
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 }
+43
View File
@@ -4,4 +4,47 @@ export async function sleep(ms: number): Promise<void> {
export function cloneElement<T extends Element>(element: T): T {
return element.cloneNode(true) as T;
}
/**
* 等待选择器命中的元素出现。
*/
export function waitForElement<T extends Element = HTMLElement>(
selector: string,
timeout = 15_000
): Promise<T | null> {
const existing = document.querySelector<T>(selector);
if (existing) return Promise.resolve(existing);
return new Promise<T | null>(resolve => {
let timer: ReturnType<typeof setTimeout> | undefined;
const observer = new MutationObserver(() => {
const el = document.querySelector<T>(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<T>(selector));
}, timeout);
};
if (document.documentElement) {
start();
} else {
document.addEventListener('DOMContentLoaded', start, { once: true });
}
});
}
export function chunk<T>(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;
}
+31
View File
@@ -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())}`
}
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { BookShelfEntry } from '../types'
const props = defineProps<{ entry: BookShelfEntry }>()
const emit = defineEmits<{
(e: 'hover', payload: { entry: BookShelfEntry; el: HTMLElement }): void
(e: 'leave'): void
(e: 'open', entry: BookShelfEntry): void
(e: 'visible', entry: BookShelfEntry): void
}>()
const detail = computed(() => props.entry.detail)
const title = computed(() => detail.value?.title ?? '')
/**
* 角标优先级:有更新 > 书籍状态。
* 只有“更新”是高亮橙,其余状态一律灰底。
*/
const tag = computed(() => {
const d = detail.value
if (!d) return null
if (d.update_status === '1') return { text: '更新', cls: '' }
if (d.status === '4') return { text: '断更', cls: 'fqa-cover-tag-gray' }
if (d.status === '0') return { text: '完结', cls: 'fqa-cover-tag-gray' }
if (d.status === '1') return { text: '连载', cls: 'fqa-cover-tag-gray' }
return null
})
const progressText = computed(() => {
const d = detail.value
if (!d) return ''
const total = d.total_chapter_count || 0
// 没有阅读记录时 real_chapter_order 为 0
const order = Math.max(0, Math.min(d.current_chapter_order || 0, total))
if (!order) return total ? `未读 · 共${total}章` : '未读'
return `${order}章/${total}章`
})
const progressPercent = computed(() => {
const d = detail.value
if (!d || !d.total_chapter_count) return 0
const ratio = (d.current_chapter_order || 0) / d.total_chapter_count
return Math.max(0, Math.min(1, ratio)) * 100
})
function onEnter(event: MouseEvent) {
emit('hover', { entry: props.entry, el: event.currentTarget as HTMLElement })
}
/** 封面淡入。用状态而非直接改 class,避免重渲染后卡在透明态 */
const imgLoaded = ref(false)
watch(
() => detail.value?.cover_url,
() => {
imgLoaded.value = false
}
)
/* 进入视口(含 200px 预取边距)时通知父组件拉详情 */
const cardRef = ref<HTMLElement | null>(null)
let io: IntersectionObserver | null = null
onMounted(() => {
if (typeof IntersectionObserver === 'undefined') {
emit('visible', props.entry)
return
}
io = new IntersectionObserver(
entries => {
if (entries.some(e => e.isIntersecting)) {
emit('visible', props.entry)
}
},
{ rootMargin: '200px' }
)
if (cardRef.value) io.observe(cardRef.value)
})
onBeforeUnmount(() => {
io?.disconnect()
io = null
})
</script>
<template>
<div
ref="cardRef"
class="fqa-card"
role="link"
tabindex="0"
:aria-label="title"
@mouseenter="onEnter"
@mouseleave="emit('leave')"
@click="emit('open', entry)"
@keydown.enter.prevent="emit('open', entry)"
@keydown.space.prevent="emit('open', entry)"
>
<!-- 详情未到达前先占位,避免高度跳动 -->
<template v-if="!detail">
<div class="fqa-sk-cover fqa-sk-anim"></div>
<div class="fqa-sk-line fqa-sk-anim" style="width: 90%"></div>
<div class="fqa-sk-line fqa-sk-anim" style="width: 55%"></div>
</template>
<template v-else>
<div class="fqa-cover">
<img
class="fqa-cover-img"
:class="{ 'fqa-cover-img-loading': !imgLoaded }"
crossorigin="anonymous"
loading="lazy"
referrerpolicy="no-referrer"
:src="detail.cover_url"
:alt="title"
@load="imgLoaded = true"
@error="imgLoaded = true"
/>
<span v-if="tag" class="fqa-cover-tag" :class="tag.cls">{{ tag.text }}</span>
<div v-if="progressPercent > 0" class="fqa-cover-progress">
<span class="fqa-cover-progress-bar" :style="{ width: progressPercent + '%' }"></span>
</div>
</div>
<div class="fqa-card-title" :title="title">{{ title }}</div>
<div class="fqa-card-sub" :title="detail.current_chapter_title">{{ progressText }}</div>
</template>
</div>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { BookShelfEntry, BookShelfGroup } from '../types'
const props = defineProps<{ group: BookShelfGroup }>()
const emit = defineEmits<{
(e: 'open', group: BookShelfGroup): void
(e: 'visible', entries: BookShelfEntry[]): void
}>()
/** 封面只用前四本,没必要为整组拉详情 */
const previewBooks = computed(() => props.group.books.slice(0, 4))
const covers = computed(() =>
previewBooks.value
.map(entry => entry.detail)
.filter((detail): detail is NonNullable<typeof detail> => !!detail)
)
/*
* 分组卡片同样要驱动详情加载:封面取自成员书的 detail,
* 而成员书本身不在网格里渲染,没有 BookCard 替它请求。
*/
const cardRef = ref<HTMLElement | null>(null)
let io: IntersectionObserver | null = null
function request() {
const pending = previewBooks.value.filter(entry => !entry.detail)
if (pending.length) emit('visible', pending)
}
onMounted(() => {
if (typeof IntersectionObserver === 'undefined') {
request()
return
}
io = new IntersectionObserver(
entries => {
if (entries.some(e => e.isIntersecting)) request()
},
{ rootMargin: '200px' }
)
if (cardRef.value) io.observe(cardRef.value)
})
onBeforeUnmount(() => {
io?.disconnect()
io = null
})
</script>
<template>
<div
ref="cardRef"
class="fqa-card"
role="button"
tabindex="0"
:aria-label="`分组 ${group.name}`"
@click="emit('open', group)"
@keydown.enter.prevent="emit('open', group)"
@keydown.space.prevent="emit('open', group)"
>
<div class="fqa-group-cover">
<div class="fqa-group-grid">
<div v-for="detail in covers" :key="detail.book_id" class="fqa-group-cell">
<img
crossorigin="anonymous"
loading="lazy"
referrerpolicy="no-referrer"
:src="detail.cover_url"
:alt="detail.title"
/>
</div>
</div>
</div>
<div class="fqa-card-title" :title="group.name">{{ group.name }}</div>
<div class="fqa-card-sub">共{{ group.books.length }}本书</div>
</div>
</template>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { BookShelfEntry } from '../types'
import { fromNow, formatDateTime } from '../utils/time'
const props = defineProps<{
entry: BookShelfEntry | null
/** 视口坐标,由父组件按封面位置算好 */
x: number
y: number
/** 与封面等高;0 表示尚未测量,退回自适应高度 */
height: number
visible: boolean
}>()
const emit = defineEmits<{
(e: 'panel-enter'): void
(e: 'panel-leave'): void
}>()
const detail = computed(() => props.entry?.detail ?? null)
const item = computed(() => props.entry?.item ?? null)
const readAt = computed(() => fromNow(item.value?.last_read_timestamp))
const addedAt = computed(() => fromNow(item.value?.add_shelf_time))
const updatedAt = computed(() => fromNow(detail.value?.last_chapter_update_time))
const updatedAtFull = computed(() => formatDateTime(detail.value?.last_chapter_update_time))
/** 悬停“最新章”时改显更新时间 */
const showUpdateTime = ref(false)
const latestChapter = computed(() => {
const d = detail.value
if (!d) return '—'
return d.total_chapter_count ? `${d.total_chapter_count}章` : '—'
})
/* ------------------------------ 梗概 / 简介 ------------------------------ */
type AbstractTab = 'chapter' | 'book'
/** null 表示未手动切换过,跟随默认规则 */
const pickedTab = ref<AbstractTab | null>(null)
const chapterText = computed(() => detail.value?.current_chapter_summary?.trim() ?? '')
const bookText = computed(() => detail.value?.summary?.trim() ?? '')
/** 默认梗概;没有梗概时落到简介 */
const activeTab = computed<AbstractTab>(() => {
if (pickedTab.value) return pickedTab.value
return chapterText.value ? 'chapter' : 'book'
})
/** 用户主动切到空的一栏时,如实显示暂无数据 */
const abstractText = computed(() => {
const text = activeTab.value === 'chapter' ? chapterText.value : bookText.value
return text || '暂无数据'
})
// 换书时重置为默认栏,避免上一本的选择粘住
watch(
() => props.entry?.item.book_id,
() => {
pickedTab.value = null
showUpdateTime.value = false
}
)
const root = ref<HTMLElement | null>(null)
const supportsPopover =
typeof HTMLElement !== 'undefined' && typeof HTMLElement.prototype.showPopover === 'function'
/**
* 进入/退出浏览器顶层。顶层元素永远绘制在普通内容之上,
* 相邻卡片和原站的层叠上下文都盖不住它。
*/
watch(
() => props.visible && !!detail.value,
show => {
const el = root.value
if (!el || !supportsPopover) return
try {
if (show) el.showPopover()
else el.hidePopover()
} catch {
// 反复切换时可能重复调用,忽略即可
}
}
)
</script>
<template>
<div
id="fqa-bookshelf-hover"
ref="root"
popover="manual"
:class="{ 'fqa-visible': visible && !!detail }"
:style="{ left: x + 'px', top: y + 'px' }"
@mouseenter="emit('panel-enter')"
@mouseleave="emit('panel-leave')"
>
<div
v-if="detail"
class="fqa-hover-inner"
:style="height ? { height: height + 'px' } : undefined"
>
<div class="fqa-hover-title" :title="detail.title">{{ detail.title }}</div>
<div v-if="detail.author" class="fqa-hover-author">{{ detail.author }}</div>
<div class="fqa-hover-stats">
<div
class="fqa-hover-stat"
@mouseenter="showUpdateTime = true"
@mouseleave="showUpdateTime = false"
>
<div class="fqa-hover-stat-v" :title="updatedAtFull">
{{ showUpdateTime ? updatedAt : latestChapter }}
</div>
<div class="fqa-hover-stat-k">{{ showUpdateTime ? '更新于' : '最新章' }}</div>
</div>
<div class="fqa-hover-stat">
<div class="fqa-hover-stat-v">{{ readAt }}</div>
<div class="fqa-hover-stat-k">阅读过</div>
</div>
<div class="fqa-hover-stat">
<div class="fqa-hover-stat-v">{{ addedAt }}</div>
<div class="fqa-hover-stat-k">已加入书架</div>
</div>
</div>
<div class="fqa-hover-seg">
<button
class="fqa-hover-seg-btn"
:class="{ 'fqa-hover-seg-active': activeTab === 'chapter' }"
@click="pickedTab = 'chapter'"
>
本章梗概
</button>
<button
class="fqa-hover-seg-btn"
:class="{ 'fqa-hover-seg-active': activeTab === 'book' }"
@click="pickedTab = 'book'"
>
全书简介
</button>
</div>
<div class="fqa-hover-abstract">
<span
v-if="activeTab === 'chapter' && detail.current_chapter_title"
class="fqa-hover-chapter"
>
{{ detail.current_chapter_title }}
</span>
{{ abstractText }}
</div>
</div>
</div>
</template>
+387
View File
@@ -0,0 +1,387 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BookCard from './BookCard.vue'
import BookGroupCard from './BookGroupCard.vue'
import BookHoverCard from './BookHoverCard.vue'
import { TABS, useBookshelf } from './useBookshelf'
import type { BookShelfEntry, BookShelfGroup, BookShelfTabKey } from '../types'
/** 持续悬停多久后展示详情 */
const HOVER_DELAY = 300
/** 移出封面后给用户留出移进浮层的时间 */
const HIDE_DELAY = 160
const HOVER_WIDTH = 280
const HOVER_GAP = 12
const VIEWPORT_MARGIN = 8
const { loading, detailLoading, error, counts, load, ensureDetails, cellsOf, findGroup, PAGE_SIZE } =
useBookshelf()
const activeTab = ref<BookShelfTabKey>('all')
/** 非空时展示该分组内部,返回后恢复列表 */
const openedGroupName = ref<string | null>(null)
const tabsRef = ref<HTMLElement | null>(null)
const inkStyle = ref<Record<string, string>>({ left: '0px', width: '0px' })
const hoverEntry = ref<BookShelfEntry | null>(null)
const hoverVisible = ref(false)
const hoverPos = ref({ x: 0, y: 0 })
/** 浮层高度跟随封面,保证与封面顶/底对齐 */
const hoverHeight = ref(0)
let hoverTimer: ReturnType<typeof setTimeout> | undefined
let hideTimer: ReturnType<typeof setTimeout> | undefined
/** 当前悬停的封面元素,滚动时据此重算位置 */
let hoverAnchor: HTMLElement | null = null
/** 指针是否停在浮层内,为真时不收起 */
const pointerInPanel = ref(false)
const openedGroup = computed<BookShelfGroup | null>(() =>
openedGroupName.value ? findGroup(openedGroupName.value) : null
)
const allCells = computed(() => {
const group = openedGroup.value
if (group) {
return group.books.map(entry => ({
kind: 'book' as const,
key: `book:${entry.item.book_id}`,
entry
}))
}
return cellsOf(activeTab.value)
})
/* 只渲染前 visibleCount 个,滚到底部再追加,避免一次性挂载上千张卡片 */
const visibleCount = ref(PAGE_SIZE)
const cells = computed(() => allCells.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < allCells.value.length)
const sentinel = ref<HTMLElement | null>(null)
let pageObserver: IntersectionObserver | null = null
function resetPaging() {
visibleCount.value = PAGE_SIZE
}
/** 手动刷新:清空详情缓存重新拉,区别于挂载时的增量加载 */
async function refresh() {
resetPaging()
await load(true)
}
watch([activeTab, openedGroupName], resetPaging)
const isEmpty = computed(() => !loading.value && !error.value && allCells.value.length === 0)
const emptyText = computed(() => {
if (openedGroup.value) return '这个分组还没有书'
if (activeTab.value === 'group') return '还没有创建任何分组'
if (activeTab.value === 'publish') return '书架里还没有出版物'
return '书架空空如也,去首页找几本书看看吧'
})
/* ------------------------------ tab 指示条 ------------------------------ */
function updateInk() {
const wrap = tabsRef.value
if (!wrap) return
const index = TABS.findIndex(tab => tab.key === activeTab.value)
// 按 DOM 顺序取:v-for 的 ref 数组不保证与源数组同序
const el = wrap.querySelectorAll<HTMLElement>('.fqa-tab')[index]
if (!el) return
inkStyle.value = {
left: `${el.offsetLeft}px`,
width: `${el.offsetWidth}px`
}
}
function selectTab(key: BookShelfTabKey) {
openedGroupName.value = null
activeTab.value = key
hideHover(true)
}
watch(activeTab, () => nextTick(updateInk))
watch(counts, () => nextTick(updateInk), { deep: true })
/* -------------------------------- hover -------------------------------- */
function computePosition(el: HTMLElement) {
// 以封面而非整张卡片为基准:卡片还含标题/进度两行文字
const cover = el.querySelector<HTMLElement>('.fqa-cover') ?? el
const rect = cover.getBoundingClientRect()
// 默认贴封面右侧,放不下时翻到左侧
let x = rect.right + HOVER_GAP
if (x + HOVER_WIDTH > window.innerWidth - VIEWPORT_MARGIN) {
x = rect.left - HOVER_WIDTH - HOVER_GAP
}
x = Math.max(VIEWPORT_MARGIN, Math.min(x, window.innerWidth - HOVER_WIDTH - VIEWPORT_MARGIN))
// 浮层允许盖住顶栏,只夹取视口上下边缘
const topLimit = VIEWPORT_MARGIN
const bottomLimit = window.innerHeight - VIEWPORT_MARGIN
// 可用高度不足时压缩浮层,避免溢出
const height = Math.min(rect.height, bottomLimit - topLimit)
const y = Math.max(topLimit, Math.min(rect.top, bottomLimit - height))
hoverHeight.value = height
hoverPos.value = { x, y }
}
function onCardHover({ entry, el }: { entry: BookShelfEntry; el: HTMLElement }) {
if (hoverTimer) clearTimeout(hoverTimer)
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = undefined
}
// 详情尚未加载时不弹空浮层
if (!entry.detail) return
hoverAnchor = el
// 已经显示时直接换书,不必再等 300ms
if (hoverVisible.value && hoverEntry.value !== entry) {
computePosition(el)
hoverEntry.value = entry
return
}
hoverTimer = setTimeout(() => {
hoverTimer = undefined
computePosition(el)
hoverEntry.value = entry
hoverVisible.value = true
}, HOVER_DELAY)
}
/** 移出封面:延迟收起,留出时间让指针移进浮层 */
function scheduleHide() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = undefined
}
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
hideTimer = undefined
if (!pointerInPanel.value) hideHover(true)
}, HIDE_DELAY)
}
function hideHover(immediate = false) {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = undefined
}
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = undefined
}
pointerInPanel.value = false
hoverAnchor = null
hoverVisible.value = false
if (immediate) hoverEntry.value = null
}
function onPanelEnter() {
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = undefined
}
pointerInPanel.value = true
}
function onPanelLeave() {
pointerInPanel.value = false
scheduleHide()
}
/* ------------------------------ 详情按需加载 ------------------------------ */
/*
* 卡片进入视口时并不立刻发请求,先攒进队列,
* 下一帧统一提交,把一屏内的十几张卡片合并成一次 multidetail。
*/
let pendingItems: BookShelfEntry['item'][] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
function queueDetails(entries: BookShelfEntry[]) {
const pending = entries.filter(entry => !entry.detail)
if (!pending.length) return
pendingItems.push(...pending.map(entry => entry.item))
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = undefined
const batch = pendingItems
pendingItems = []
void ensureDetails(batch)
}, 50)
}
function onCardVisible(entry: BookShelfEntry) {
queueDetails([entry])
}
/** 分组卡片一次性提交它要展示的四本封面书 */
function onGroupVisible(entries: BookShelfEntry[]) {
queueDetails(entries)
}
/* -------------------------------- 交互 -------------------------------- */
function openBook(entry: BookShelfEntry) {
hideHover(true)
const chapterId = entry.detail?.current_chapter_id || entry.item.last_read_chapter_id
// 没有阅读记录时退回书籍详情页
const url =
chapterId && chapterId !== '0'
? `https://fanqienovel.com/reader/${chapterId}`
: `https://fanqienovel.com/page/${entry.item.book_id}`
unsafeWindow.location.href = url
}
function openGroup(group: BookShelfGroup) {
hideHover(true)
openedGroupName.value = group.name
}
function backToList() {
openedGroupName.value = null
hideHover(true)
}
function onScrollOrResize() {
// 浮层是 fixed 的,滚动后跟着封面重算位置,而不是收起
if (hoverAnchor && (hoverVisible.value || hoverTimer)) {
if (hoverAnchor.isConnected) {
computePosition(hoverAnchor)
} else {
// 卡片被分页/切 tab 移除了
hideHover(true)
}
}
updateInk()
}
onMounted(async () => {
await load()
await nextTick()
updateInk()
window.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
if (typeof IntersectionObserver !== 'undefined') {
pageObserver = new IntersectionObserver(
entries => {
if (entries.some(e => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' }
)
watch(
sentinel,
el => {
pageObserver?.disconnect()
if (el) pageObserver?.observe(el)
},
{ immediate: true }
)
}
})
onBeforeUnmount(() => {
if (hoverTimer) clearTimeout(hoverTimer)
if (hideTimer) clearTimeout(hideTimer)
if (flushTimer) clearTimeout(flushTimer)
pageObserver?.disconnect()
pageObserver = null
window.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div id="fqa-bookshelf">
<div class="fqa-bs-header">
<div class="fqa-bs-title">我的书架</div>
<div class="fqa-bs-actions">
<span v-if="detailLoading">正在补全详情…</span>
<button class="fqa-btn" :disabled="loading" @click="refresh">
{{ loading ? '刷新中…' : '刷新' }}
</button>
</div>
</div>
<div ref="tabsRef" class="fqa-tabs" role="tablist">
<div
v-for="tab in TABS"
:key="tab.key"
class="fqa-tab"
:class="{ 'fqa-tab-active': activeTab === tab.key }"
role="tab"
tabindex="0"
:aria-selected="activeTab === tab.key"
@click="selectTab(tab.key)"
@keydown.enter.prevent="selectTab(tab.key)"
>
{{ tab.label }}<span class="fqa-tab-count">{{ counts[tab.key] }}</span>
</div>
<span class="fqa-tab-ink" :style="inkStyle"></span>
</div>
<div v-if="openedGroup" class="fqa-groupbar">
<button class="fqa-btn" @click="backToList">← 返回</button>
<span class="fqa-groupbar-name">{{ openedGroup.name }}</span>
<span class="fqa-groupbar-count">共{{ openedGroup.books.length }}本书</span>
</div>
<div v-if="error" class="fqa-status">
<div class="fqa-status-title">书架加载失败</div>
<div>{{ error }}</div>
<button class="fqa-btn" @click="refresh">重试</button>
</div>
<div v-else-if="isEmpty" class="fqa-status">
<div class="fqa-status-title">{{ emptyText }}</div>
</div>
<template v-else>
<div class="fqa-grid">
<template v-if="loading">
<div v-for="n in 8" :key="`sk-${n}`" class="fqa-card">
<div class="fqa-sk-cover fqa-sk-anim"></div>
<div class="fqa-sk-line fqa-sk-anim" style="width: 90%"></div>
<div class="fqa-sk-line fqa-sk-anim" style="width: 55%"></div>
</div>
</template>
<template v-else v-for="cell in cells" :key="cell.key">
<BookCard
v-if="cell.kind === 'book'"
:entry="cell.entry"
@hover="onCardHover"
@leave="scheduleHide"
@open="openBook"
@visible="onCardVisible"
/>
<BookGroupCard v-else :group="cell.group" @open="openGroup" @visible="onGroupVisible" />
</template>
</div>
<!-- 滚动哨兵:进入视口即追加下一页 -->
<div v-if="!loading && hasMore" ref="sentinel" class="fqa-loadmore">加载中…</div>
</template>
<Teleport to="body">
<BookHoverCard
:entry="hoverEntry"
:x="hoverPos.x"
:y="hoverPos.y"
:height="hoverHeight"
:visible="hoverVisible"
@panel-enter="onPanelEnter"
@panel-leave="onPanelLeave"
/>
</Teleport>
</div>
</template>
+182
View File
@@ -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<BookShelfEntry[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const error = ref<string | null>(null)
/** 并发/重复加载时用于丢弃过期响应 */
let loadToken = 0
/** 已拿到的详情,跨 tab 切换复用,避免重复请求 */
let detailCache = new Map<string, BookShelfBookInfo>()
/** 正在请求中的 book_id,防止同一本书被并发拉两次 */
let inflight = new Set<string>()
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<BookShelfGroup[]>(() => {
const map = new Map<string, BookShelfGroup>()
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<BookShelfCell[]>(() => {
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<BookShelfCell[]>(() =>
groups.value.map(group => ({ kind: 'group', key: `group:${group.name}`, group }))
)
const publishCells = computed<BookShelfCell[]>(() =>
entries.value
.filter(entry => entry.item.is_publish)
.map(entry => ({ kind: 'book', key: `book:${entry.item.book_id}`, entry }))
)
const counts = computed<Record<BookShelfTabKey, number>>(() => ({
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
}
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<any, any, any>
export default component
}