mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 10:24:47 +00:00
feat: 书架页面替换,优化展示并允许跳转至网页不允许查看的书籍
This commit is contained in:
1 parent
2efb8e78bb
commit
84604e5ff7
13 files changed
+1956
-1
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,8 @@
|
||||
.fqa-menu-item,
|
||||
.arco-menu-item {
|
||||
width: 100%!important;
|
||||
}
|
||||
|
||||
#dynamic-el {
|
||||
display: none!important;
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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())}`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<any, any, any>
|
||||
export default component
|
||||
}
|
||||
Reference in new issue
Block a user