mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 01:54:48 +00:00
feat: 支持使用app的搜索接口。修复app接口漫画加载失败,修复漫画切章不加载,修复用户菜单错误注入到了排行榜里,修复gzip压缩的章节无法加载
This commit is contained in:
1 parent
d77d4edfd6
commit
16e3180712
24 files changed
+3822
-505
No files matched your search
Vendored
+1515
-346
File diff suppressed because it is too large.
Load diff
+30
-1
@@ -1,11 +1,16 @@
|
||||
import apiFetch from '../utils/request'
|
||||
import { signRequest } from '../crypto/sign'
|
||||
import config from '../config'
|
||||
import config, { fetch as pageFetch } from '../config'
|
||||
import { settings } from '../settings'
|
||||
|
||||
|
||||
export const appBaseUrl = 'https://reading.snssdk.com/reading'
|
||||
export const redcandleBaseUrl = 'https://api5-sinfonlinec.jxbhmy.com/reading'
|
||||
/**
|
||||
* 番茄网页站同源挂载的 APP 接口。走页面 fetch 时浏览器会自动带上 Cookie,
|
||||
* 其中包括脚本读不到的 HttpOnly sessionid —— 个人化推荐就靠这个。
|
||||
*/
|
||||
export const webBaseUrl = 'https://fanqienovel.com/reading'
|
||||
export const appUserAgent = 'com.dragon.read'
|
||||
|
||||
|
||||
@@ -26,11 +31,35 @@ export function buildAppQuery(extra?: Record<string, string>): URLSearchParams {
|
||||
os_version: '10',
|
||||
device_type: c.device_type || 'P30',
|
||||
device_brand: c.device_brand || 'realme',
|
||||
update_version_code: '70132',
|
||||
manifest_version_code: '70132',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 同源请求。签名依然必须,但同源可以自由设置自定义请求头,
|
||||
* 且 Cookie 由浏览器附带,脚本不接触任何凭据。
|
||||
*
|
||||
* @param credentials 'include' 带登录态(个人化),'omit' 匿名
|
||||
*/
|
||||
export async function webGet(
|
||||
path: string,
|
||||
query?: Record<string, string>,
|
||||
credentials: RequestCredentials = 'omit',
|
||||
): Promise<any> {
|
||||
const url = `${webBaseUrl}${path}?${buildAppQuery(query).toString()}`
|
||||
const signed = await signRequest(url)
|
||||
// User-Agent 是浏览器保留头,这里设不了,也没必要设
|
||||
const res = await pageFetch(url, { headers: signed, credentials })
|
||||
if (!res.ok) {
|
||||
throw new Error(`请求失败(${res.status})`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
||||
function isUsable(res: any): boolean {
|
||||
if (!res || res.status !== 200) return false
|
||||
try {
|
||||
|
||||
+3
-2
@@ -71,7 +71,7 @@ export async function getChapter(itemId: string, _retry?: number): Promise<any>
|
||||
await ensureKeyinfo(parseInt(j?.key_version))
|
||||
return await getChapter(itemId, _retry + 1)
|
||||
}
|
||||
j.content = await decryptChapter(j?.content, config.currentConfig)
|
||||
j.content = await decryptChapter(j?.content, j, config.currentConfig)
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ export async function getChapters(
|
||||
results[id] = {
|
||||
...item,
|
||||
item_id: id,
|
||||
content: await decryptChapter(item.content, config.currentConfig),
|
||||
novel_data: item.novel_data,
|
||||
content: await decryptChapter(item.content, item, config.currentConfig),
|
||||
}
|
||||
} catch (e) {
|
||||
results[id] = { ...item, item_id: id, error: String(e) }
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// 搜索接口。
|
||||
//
|
||||
// 走同源的 /reading/bookapi/*,签名照旧,Cookie 由浏览器附带。
|
||||
//
|
||||
// 响应结构(已抓包核对):
|
||||
// { code, message, search_tabs: [...], selected_tab_idx, log_id }
|
||||
// 注意 search_tabs 在顶层,没有 data 包裹。
|
||||
//
|
||||
// 每个 tab:
|
||||
// { tab_type, title, data: cell[], has_more, passback, next_offset, selector? }
|
||||
// - 只有 tab_type=1(综合)带 selector
|
||||
// - has_more / passback 都在 tab 上,不在顶层
|
||||
// - passback 按 cell 计数而非按书计数(首页返回 9 而不是 10),
|
||||
// 所以翻页必须回填服务端给的 passback,不能自己 (page-1)*10
|
||||
//
|
||||
// cell 里只有 show_type=110 是真正的搜索结果(一 cell 一书)。
|
||||
// 112/191/300/539 等是「猜你喜欢」「相关搜索」「社区」这类插入位,
|
||||
// use_recommend 在结果 cell 上也是 true,不能用它来区分。
|
||||
|
||||
import { webGet } from './app'
|
||||
import type {
|
||||
SearchBook,
|
||||
SearchLandingSection,
|
||||
SearchResult,
|
||||
SearchTab,
|
||||
SelectorRow,
|
||||
} from '../types'
|
||||
import { settings } from '../settings'
|
||||
|
||||
/** 综合 tab,唯一带筛选器的 tab */
|
||||
export const BOOK_TAB_TYPE = 1
|
||||
|
||||
/** 真正的搜索结果 cell */
|
||||
const RESULT_SHOW_TYPE = 110
|
||||
|
||||
/**
|
||||
* 能渲染成书籍卡片的 tab。实测各 tab 的返回:
|
||||
* 1 综合(110) / 3 书籍(110) / 2 听书(110) / 5 全文(110) / 8 漫画(334) 有 book_data
|
||||
* 4 社区(339) / 11 短剧(532,393,369) / 19 漫剧(369) / 6 用户(322) 没有 book_data
|
||||
* 13 买书 直接返回空
|
||||
* 后面这些是帖子、短剧、用户卡,网页端渲染不了,列出来只会得到空结果页,所以过滤掉。
|
||||
*/
|
||||
const RENDERABLE_TABS = new Set([1, 2, 3, 5, 8])
|
||||
|
||||
/** 个人化推荐要带登录态,否则匿名请求 */
|
||||
function credentials(): RequestCredentials {
|
||||
return settings.searchPersonalized ? 'include' : 'omit'
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口的布尔值下发成字符串 "0"/"1"。
|
||||
* 直接 Boolean() 会把 "0" 判成 true,必须显式比较。
|
||||
*/
|
||||
function bool(v: unknown): boolean {
|
||||
if (typeof v === 'string') return v !== '' && v !== '0' && v !== 'false'
|
||||
return Boolean(v)
|
||||
}
|
||||
|
||||
/** 只保留 <em>,其余标签一律去掉后再交给 v-html */
|
||||
function sanitizeHighlight(html: unknown): string | undefined {
|
||||
if (typeof html !== 'string' || !html) return undefined
|
||||
return html.replace(/<(?!\/?em\s*\/?>)[^>]*>/gi, '')
|
||||
}
|
||||
|
||||
function normalizeBook(raw: any, cell: any): SearchBook | null {
|
||||
const bookId = String(raw?.book_id ?? '')
|
||||
if (!bookId || bookId === '0') return null
|
||||
|
||||
// 高亮挂在 cell 上而不是书上
|
||||
const hl = cell?.search_high_light
|
||||
return {
|
||||
book_id: bookId,
|
||||
title: raw.book_name || raw.original_book_name || '',
|
||||
author: raw.author || '',
|
||||
cover_url: raw.thumb_url || raw.audio_thumb_url_hd || '',
|
||||
summary: raw.abstract || '',
|
||||
status: String(raw.creation_status ?? ''),
|
||||
word_count: num(raw.word_number),
|
||||
// sub_info 是接口给的展示文案,如「404章」「10.5万人在读」
|
||||
sub_info: String(raw.sub_info ?? ''),
|
||||
read_count: num(raw.read_count),
|
||||
// 未评分的书下发 "0",视为没有评分
|
||||
score: num(raw.score) > 0 ? String(raw.score) : '',
|
||||
category: raw.category || '',
|
||||
chapter_count: num(raw.serial_count),
|
||||
last_chapter_title: raw.last_chapter_title || '',
|
||||
last_publish_time: num(raw.last_publish_time) * 1000,
|
||||
highlight_title: sanitizeHighlight(hl?.title?.rich_text),
|
||||
genre: raw.genre != null ? String(raw.genre) : undefined,
|
||||
in_bookshelf: bool(raw.in_bookshelf),
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 cell 列表里挑出搜索结果 */
|
||||
function collectBooks(cells: any[]): SearchBook[] {
|
||||
const books: SearchBook[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const take = (cell: any) => {
|
||||
for (const raw of cell?.book_data ?? []) {
|
||||
const book = normalizeBook(raw, cell)
|
||||
if (!book || seen.has(book.book_id)) continue
|
||||
seen.add(book.book_id)
|
||||
books.push(book)
|
||||
}
|
||||
}
|
||||
|
||||
const results = cells.filter(c => Number(c?.show_type) === RESULT_SHOW_TYPE)
|
||||
if (results.length) {
|
||||
results.forEach(take)
|
||||
return books
|
||||
}
|
||||
// 其他 tab(听书/漫画等)的结果 cell 编号不同,
|
||||
// 没命中 110 时退化成「带 book_data 的都要」
|
||||
cells.filter(c => Array.isArray(c?.book_data) && c.book_data.length).forEach(take)
|
||||
return books
|
||||
}
|
||||
|
||||
function collectTabs(tabs: any[]): SearchTab[] {
|
||||
return tabs
|
||||
.map((t: any) => ({
|
||||
tab_type: num(t?.tab_type),
|
||||
// 字段名是 title,不是 tab_name
|
||||
tab_name: String(t?.title ?? ''),
|
||||
has_selector: Array.isArray(t?.selector?.rows) && t.selector.rows.length > 0,
|
||||
}))
|
||||
.filter(t => t.tab_type > 0 && t.tab_name && RENDERABLE_TABS.has(t.tab_type))
|
||||
}
|
||||
|
||||
function collectSelectorRows(tabs: any[]): SelectorRow[] {
|
||||
const rows = tabs.find((t: any) => Array.isArray(t?.selector?.rows))?.selector?.rows
|
||||
if (!Array.isArray(rows)) return []
|
||||
|
||||
return rows
|
||||
.map((row: any) => ({
|
||||
name: String(row?.row_name ?? ''),
|
||||
items: (Array.isArray(row?.items) ? row.items : [])
|
||||
.map((item: any) => ({
|
||||
id: String(item?.selector_item_id ?? ''),
|
||||
name: String(item?.show_name ?? ''),
|
||||
default: Boolean(item?.is_default_selected),
|
||||
}))
|
||||
.filter((item: any) => item.id && item.name),
|
||||
}))
|
||||
.filter((row: SelectorRow) => row.name && row.items.length > 0)
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
query: string
|
||||
/** 服务端给的翻页游标,首页传 0 */
|
||||
passback?: number
|
||||
/** 选中的 selector_item_id 列表 */
|
||||
selectedItems?: string[]
|
||||
tabType?: number
|
||||
}
|
||||
|
||||
export async function search({
|
||||
query,
|
||||
passback = 0,
|
||||
selectedItems = [],
|
||||
tabType = BOOK_TAB_TYPE,
|
||||
}: SearchParams): Promise<SearchResult> {
|
||||
// 筛选只在综合 tab 生效
|
||||
const selected = tabType === BOOK_TAB_TYPE ? selectedItems.filter(Boolean).join(',') : ''
|
||||
|
||||
const j = await webGet(
|
||||
'/bookapi/search/tab/v',
|
||||
{
|
||||
query,
|
||||
passback: String(passback),
|
||||
selected_items: selected,
|
||||
tab_type: String(tabType),
|
||||
},
|
||||
credentials(),
|
||||
)
|
||||
|
||||
if (j?.code !== undefined && j.code !== 0) {
|
||||
throw new Error(j.message || `搜索失败(code=${j.code})`)
|
||||
}
|
||||
|
||||
const tabs: any[] = Array.isArray(j?.search_tabs) ? j.search_tabs : []
|
||||
const current = tabs.find((t: any) => num(t?.tab_type) === tabType)
|
||||
const cells: any[] = Array.isArray(current?.data) ? current.data : []
|
||||
const books = collectBooks(cells)
|
||||
|
||||
return {
|
||||
books,
|
||||
// 回填服务端游标;没给就按已取回的书数往前推,至少不会原地打转
|
||||
next_passback: num(current?.passback) || passback + Math.max(books.length, 1),
|
||||
has_more: Boolean(current?.has_more) && books.length > 0,
|
||||
tabs: collectTabs(tabs),
|
||||
selector_rows: collectSelectorRows(tabs),
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- 搜索中间页 ------------------------------- */
|
||||
//
|
||||
// /bookapi/plan/v 的结构:
|
||||
// data: cell[]
|
||||
// show_type=364 → search_tag_data,猜你想搜
|
||||
// show_type=161 → cell_data[] 各种榜单,其中
|
||||
// 472 番茄热搜榜 search_tag_data(带 tag_attached 热搜值)
|
||||
// 162 巅峰榜 / 332 漫画榜 book_data
|
||||
// 388/164/163 是短剧/话题/分类,走 lynx 或视频,网页端渲染不了
|
||||
|
||||
function collectWords(list: any): SearchLandingSection['words'] {
|
||||
if (!Array.isArray(list)) return []
|
||||
return list
|
||||
.map((t: any) => ({
|
||||
word: String(t?.tag_title ?? ''),
|
||||
// 「877405热搜值」之类的附加说明
|
||||
tag: t?.tag_attached ? String(t.tag_attached) : undefined,
|
||||
label: t?.label ? String(t.label) : undefined,
|
||||
}))
|
||||
.filter(w => w.word)
|
||||
}
|
||||
|
||||
function collectSugBooks(list: any): SearchLandingSection['books'] {
|
||||
if (!Array.isArray(list)) return []
|
||||
return list
|
||||
.map((b: any) => ({
|
||||
book_id: String(b?.book_id ?? ''),
|
||||
title: String(b?.book_name ?? ''),
|
||||
cover_url: String(b?.thumb_url ?? ''),
|
||||
author: b?.author ? String(b.author) : undefined,
|
||||
desc: b?.sub_info ? String(b.sub_info) : undefined,
|
||||
}))
|
||||
.filter(b => b.book_id && b.title)
|
||||
}
|
||||
|
||||
function pushSection(
|
||||
out: SearchLandingSection[],
|
||||
title: string,
|
||||
words: SearchLandingSection['words'],
|
||||
books: SearchLandingSection['books'],
|
||||
) {
|
||||
if (!words.length && !books.length) return
|
||||
out.push({
|
||||
title: title || (words.length ? '热搜' : '推荐'),
|
||||
kind: words.length ? 'hotword' : 'book',
|
||||
words,
|
||||
books,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSearchLanding(): Promise<SearchLandingSection[]> {
|
||||
const j = await webGet(
|
||||
'/bookapi/plan/v',
|
||||
{
|
||||
search_source: '1',
|
||||
scene: '10',
|
||||
new_search_middle_page: 'true',
|
||||
search_middle_page_version: '2',
|
||||
from: 'search_input_page',
|
||||
tab_name: 'store',
|
||||
bookstore_tab: '2',
|
||||
bookstore_tab_type: '2',
|
||||
hot_word_exchange: 'false',
|
||||
query_history_removed: 'false',
|
||||
user_is_login: settings.searchPersonalized ? '1' : '0',
|
||||
},
|
||||
credentials(),
|
||||
)
|
||||
|
||||
if (j?.code !== undefined && j.code !== 0) {
|
||||
throw new Error(j.message || `加载推荐失败(code=${j.code})`)
|
||||
}
|
||||
|
||||
const sections: SearchLandingSection[] = []
|
||||
for (const cell of Array.isArray(j?.data) ? j.data : []) {
|
||||
// 榜单容器,真正内容在 cell_data 里
|
||||
if (Array.isArray(cell?.cell_data)) {
|
||||
for (const sub of cell.cell_data) {
|
||||
pushSection(
|
||||
sections,
|
||||
String(sub?.cell_name ?? ''),
|
||||
collectWords(sub?.search_tag_data),
|
||||
collectSugBooks(sub?.book_data),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
pushSection(
|
||||
sections,
|
||||
String(cell?.cell_name ?? '') || '猜你想搜',
|
||||
collectWords(cell?.search_tag_data),
|
||||
collectSugBooks(cell?.book_data),
|
||||
)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
+2
-112
@@ -569,6 +569,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* keyframes fqa-shimmer 在 script.css 里全局声明 */
|
||||
#fqa-bookshelf .fqa-sk-anim::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -578,12 +579,6 @@
|
||||
animation: fqa-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes fqa-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- 空态 / 错误态 --------------------------- */
|
||||
|
||||
#fqa-bookshelf .fqa-loadmore {
|
||||
@@ -646,109 +641,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------- 右键菜单 ----------------------------- */
|
||||
|
||||
/*
|
||||
* 菜单 Teleport 到 body,拿不到 #fqa-bookshelf 上的变量,
|
||||
* 这里重新声明一份(与书架同色板),并给出深色模式回退。
|
||||
*/
|
||||
.fqa-menu {
|
||||
--fqa-menu-bg: #fff;
|
||||
--fqa-menu-text: #1f2329;
|
||||
--fqa-menu-sub: #8f959e;
|
||||
--fqa-menu-hover: rgba(31, 35, 41, 0.06);
|
||||
--fqa-menu-danger: #f5222d;
|
||||
|
||||
position: fixed;
|
||||
z-index: 2147483001;
|
||||
min-width: 132px;
|
||||
max-width: 240px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
background: var(--fqa-menu-bg);
|
||||
border: 1px solid rgba(31, 35, 41, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(31, 35, 41, 0.16);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--fqa-menu-text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 二级面板:分组数量多时可滚动 */
|
||||
.fqa-menu-sub {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fqa-menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fqa-menu-row > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fqa-menu-row:hover,
|
||||
.fqa-menu-row.fqa-menu-open {
|
||||
background: var(--fqa-menu-hover);
|
||||
}
|
||||
|
||||
.fqa-menu-arrow {
|
||||
color: var(--fqa-menu-sub);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fqa-menu-danger {
|
||||
color: var(--fqa-menu-danger);
|
||||
}
|
||||
|
||||
.fqa-menu-disabled {
|
||||
color: var(--fqa-menu-sub);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fqa-menu-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 操作结果提示 */
|
||||
.fqa-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 48px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2147483002;
|
||||
max-width: 80vw;
|
||||
padding: 10px 18px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(31, 35, 41, 0.88);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 6px 24px rgba(31, 35, 41, 0.24);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fqa-menu {
|
||||
--fqa-menu-bg: #23272e;
|
||||
--fqa-menu-text: #e5e6eb;
|
||||
--fqa-menu-sub: #8f959e;
|
||||
--fqa-menu-hover: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
/* 右键菜单与 toast 样式已移到 script.css,书架和搜索共用 */
|
||||
@@ -115,4 +115,118 @@
|
||||
|
||||
.fqa-icon-dark {
|
||||
color: #B3B3B3
|
||||
}
|
||||
|
||||
/* ----------------------------- 右键菜单 / Toast ----------------------------- */
|
||||
|
||||
/*
|
||||
* 书架与搜索共用。两者都把菜单 Teleport 到 body,
|
||||
* 拿不到各自根节点上的变量,所以在这里声明一份全局色板。
|
||||
*/
|
||||
.fqa-menu {
|
||||
--fqa-menu-bg: #fff;
|
||||
--fqa-menu-text: #1f2329;
|
||||
--fqa-menu-sub: #8f959e;
|
||||
--fqa-menu-hover: rgba(31, 35, 41, 0.06);
|
||||
--fqa-menu-danger: #f5222d;
|
||||
|
||||
position: fixed;
|
||||
z-index: 2147483001;
|
||||
min-width: 132px;
|
||||
max-width: 240px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
background: var(--fqa-menu-bg);
|
||||
border: 1px solid rgba(31, 35, 41, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(31, 35, 41, 0.16);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--fqa-menu-text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 二级面板:分组数量多时可滚动 */
|
||||
.fqa-menu-sub {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fqa-menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fqa-menu-row > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fqa-menu-row:hover,
|
||||
.fqa-menu-row.fqa-menu-open {
|
||||
background: var(--fqa-menu-hover);
|
||||
}
|
||||
|
||||
.fqa-menu-arrow {
|
||||
color: var(--fqa-menu-sub);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fqa-menu-danger {
|
||||
color: var(--fqa-menu-danger);
|
||||
}
|
||||
|
||||
.fqa-menu-disabled {
|
||||
color: var(--fqa-menu-sub);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fqa-menu-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 操作结果提示 */
|
||||
.fqa-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 48px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2147483002;
|
||||
max-width: 80vw;
|
||||
padding: 10px 18px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(31, 35, 41, 0.88);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: 0 6px 24px rgba(31, 35, 41, 0.24);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 骨架屏微光。书架与搜索共用同一个动画名 */
|
||||
@keyframes fqa-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fqa-menu {
|
||||
--fqa-menu-bg: #23272e;
|
||||
--fqa-menu-text: #e5e6eb;
|
||||
--fqa-menu-sub: #8f959e;
|
||||
--fqa-menu-hover: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
#fqa-search {
|
||||
--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);
|
||||
--fqa-card-bg: #fff;
|
||||
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
/* 顶部让开原站 fixed 顶栏 */
|
||||
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-search *,
|
||||
#fqa-search *::before,
|
||||
#fqa-search *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#fqa-search div,
|
||||
#fqa-search span,
|
||||
#fqa-search h3,
|
||||
#fqa-search p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
float: none;
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* -------------------------------- 搜索栏 -------------------------------- */
|
||||
|
||||
#fqa-search .fqa-s-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-inputwrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-input {
|
||||
width: 100%;
|
||||
padding: 11px 36px 11px 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--fqa-border);
|
||||
border-radius: 999px;
|
||||
background: var(--fqa-card-bg);
|
||||
color: var(--fqa-text);
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
line-height: 22px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-input::placeholder {
|
||||
color: var(--fqa-text-weak);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fqa-accent);
|
||||
box-shadow: 0 0 0 3px rgba(255, 111, 61, 0.12);
|
||||
}
|
||||
|
||||
/* 隐藏浏览器自带的 search 清除按钮,用自己的 */
|
||||
#fqa-search .fqa-s-input::-webkit-search-cancel-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--fqa-hover);
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-clear:hover {
|
||||
color: var(--fqa-text);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-submit {
|
||||
flex: 0 0 auto;
|
||||
padding: 11px 24px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--fqa-accent);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-submit:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* --------------------------------- tab --------------------------------- */
|
||||
|
||||
#fqa-search .fqa-s-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--fqa-border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 16px;
|
||||
color: var(--fqa-text-sub);
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-tab:hover {
|
||||
color: var(--fqa-text);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-tab-active {
|
||||
color: var(--fqa-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-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;
|
||||
}
|
||||
|
||||
/* -------------------------------- 筛选器 -------------------------------- */
|
||||
|
||||
#fqa-search .fqa-s-filterbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fbtn,
|
||||
#fqa-search .fqa-s-fclear {
|
||||
padding: 5px 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: 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fbtn:hover,
|
||||
#fqa-search .fqa-s-fclear:hover {
|
||||
border-color: var(--fqa-accent);
|
||||
color: var(--fqa-accent);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fbtn-on {
|
||||
border-color: var(--fqa-accent);
|
||||
color: var(--fqa-accent);
|
||||
background: rgba(255, 111, 61, 0.08);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fcount {
|
||||
display: inline-block;
|
||||
min-width: 16px;
|
||||
margin-left: 5px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--fqa-accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-hint {
|
||||
margin-left: auto;
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-filters {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--fqa-border);
|
||||
border-radius: 10px;
|
||||
background: var(--fqa-card-bg);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-frow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-frow-name {
|
||||
flex: 0 0 58px;
|
||||
padding-top: 5px;
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fitems {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fitem {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--fqa-hover);
|
||||
color: var(--fqa-text-sub);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fitem:hover {
|
||||
color: var(--fqa-text);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-fitem-on {
|
||||
background: rgba(255, 111, 61, 0.12);
|
||||
border-color: rgba(255, 111, 61, 0.4);
|
||||
color: var(--fqa-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ------------------------------- 结果列表 ------------------------------- */
|
||||
|
||||
#fqa-search .fqa-s-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-card:hover {
|
||||
background: var(--fqa-hover);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-card:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--fqa-accent);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-skeleton {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-skeleton:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-cover {
|
||||
position: relative;
|
||||
flex: 0 0 96px;
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--fqa-skeleton);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-cover-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-cover-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-badge {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 2px 4px;
|
||||
background: rgba(31, 35, 41, 0.7);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-title {
|
||||
color: var(--fqa-text);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 接口下发的高亮片段 */
|
||||
#fqa-search .fqa-sr-title em {
|
||||
color: var(--fqa-accent);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 3px;
|
||||
color: var(--fqa-text-sub);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-author > span:first-child {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-score {
|
||||
flex: 0 0 auto;
|
||||
color: var(--fqa-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 无评分时弱化,不跟真实分数抢注意力 */
|
||||
#fqa-search .fqa-sr-score-none {
|
||||
color: var(--fqa-text-weak);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-summary {
|
||||
display: -webkit-box;
|
||||
margin-top: 6px;
|
||||
color: var(--fqa-text-sub);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-tag {
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--fqa-hover);
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-read {
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-chapter {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-time {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#fqa-search .fqa-sr-cover {
|
||||
flex-basis: 72px;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sr-summary {
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------- 落地页 -------------------------------- */
|
||||
|
||||
#fqa-search .fqa-s-sec {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sec-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-words {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-word {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--fqa-hover);
|
||||
color: var(--fqa-text-sub);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-word:hover {
|
||||
background: rgba(255, 111, 61, 0.1);
|
||||
color: var(--fqa-accent);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-word-tag {
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 「荐」「热」这类角标 */
|
||||
#fqa-search .fqa-s-word-label {
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--fqa-accent);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sugs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 20px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#fqa-search .fqa-s-sugs {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#fqa-search .fqa-s-sugs {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sug {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sug-cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--fqa-skeleton);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sug:hover .fqa-s-sug-cover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--fqa-shadow);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sug-title {
|
||||
margin-top: 7px;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-sug-sub {
|
||||
margin-top: 2px;
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-landing-sk {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sk-chip {
|
||||
width: 84px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: var(--fqa-skeleton);
|
||||
}
|
||||
|
||||
/* ------------------------------ 骨架 / 状态 ------------------------------ */
|
||||
|
||||
#fqa-search .fqa-sk-cover {
|
||||
flex: 0 0 96px;
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 6px;
|
||||
background: var(--fqa-skeleton);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sk-line {
|
||||
height: 12px;
|
||||
margin-bottom: 9px;
|
||||
border-radius: 4px;
|
||||
background: var(--fqa-skeleton);
|
||||
}
|
||||
|
||||
#fqa-search .fqa-sk-anim {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#fqa-search .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;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-status {
|
||||
padding: 72px 16px;
|
||||
text-align: center;
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-status-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--fqa-text);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-status .fqa-s-submit {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-loadmore {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-inline-error {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
color: #f5222d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#fqa-search .fqa-s-privacy {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--fqa-border);
|
||||
color: var(--fqa-text-weak);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ------------------------------- 深色模式 ------------------------------- */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#fqa-search {
|
||||
--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-card-bg: #212125;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12 8.25C9.92894 8.25 8.25 9.92893 8.25 12C8.25 14.0711 9.92894 15.75 12 15.75C14.0711 15.75 15.75 14.0711 15.75 12C15.75 9.92893 14.0711 8.25 12 8.25ZM9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z"
|
||||
fill="#000000" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M11.9747 1.25C11.5303 1.24999 11.1592 1.24999 10.8546 1.27077C10.5375 1.29241 10.238 1.33905 9.94761 1.45933C9.27379 1.73844 8.73843 2.27379 8.45932 2.94762C8.31402 3.29842 8.27467 3.66812 8.25964 4.06996C8.24756 4.39299 8.08454 4.66251 7.84395 4.80141C7.60337 4.94031 7.28845 4.94673 7.00266 4.79568C6.64714 4.60777 6.30729 4.45699 5.93083 4.40743C5.20773 4.31223 4.47642 4.50819 3.89779 4.95219C3.64843 5.14353 3.45827 5.3796 3.28099 5.6434C3.11068 5.89681 2.92517 6.21815 2.70294 6.60307L2.67769 6.64681C2.45545 7.03172 2.26993 7.35304 2.13562 7.62723C1.99581 7.91267 1.88644 8.19539 1.84541 8.50701C1.75021 9.23012 1.94617 9.96142 2.39016 10.5401C2.62128 10.8412 2.92173 11.0602 3.26217 11.2741C3.53595 11.4461 3.68788 11.7221 3.68786 12C3.68785 12.2778 3.53592 12.5538 3.26217 12.7258C2.92169 12.9397 2.62121 13.1587 2.39007 13.4599C1.94607 14.0385 1.75012 14.7698 1.84531 15.4929C1.88634 15.8045 1.99571 16.0873 2.13552 16.3727C2.26983 16.6469 2.45535 16.9682 2.67758 17.3531L2.70284 17.3969C2.92507 17.7818 3.11058 18.1031 3.28089 18.3565C3.45817 18.6203 3.64833 18.8564 3.89769 19.0477C4.47632 19.4917 5.20763 19.6877 5.93073 19.5925C6.30717 19.5429 6.647 19.3922 7.0025 19.2043C7.28833 19.0532 7.60329 19.0596 7.8439 19.1986C8.08452 19.3375 8.24756 19.607 8.25964 19.9301C8.27467 20.3319 8.31403 20.7016 8.45932 21.0524C8.73843 21.7262 9.27379 22.2616 9.94761 22.5407C10.238 22.661 10.5375 22.7076 10.8546 22.7292C11.1592 22.75 11.5303 22.75 11.9747 22.75H12.0252C12.4697 22.75 12.8407 22.75 13.1454 22.7292C13.4625 22.7076 13.762 22.661 14.0524 22.5407C14.7262 22.2616 15.2616 21.7262 15.5407 21.0524C15.686 20.7016 15.7253 20.3319 15.7403 19.93C15.7524 19.607 15.9154 19.3375 16.156 19.1985C16.3966 19.0596 16.7116 19.0532 16.9974 19.2042C17.3529 19.3921 17.6927 19.5429 18.0692 19.5924C18.7923 19.6876 19.5236 19.4917 20.1022 19.0477C20.3516 18.8563 20.5417 18.6203 20.719 18.3565C20.8893 18.1031 21.0748 17.7818 21.297 17.3969L21.3223 17.3531C21.5445 16.9682 21.7301 16.6468 21.8644 16.3726C22.0042 16.0872 22.1135 15.8045 22.1546 15.4929C22.2498 14.7697 22.0538 14.0384 21.6098 13.4598C21.3787 13.1586 21.0782 12.9397 20.7378 12.7258C20.464 12.5538 20.3121 12.2778 20.3121 11.9999C20.3121 11.7221 20.464 11.4462 20.7377 11.2742C21.0783 11.0603 21.3788 10.8414 21.6099 10.5401C22.0539 9.96149 22.2499 9.23019 22.1547 8.50708C22.1136 8.19546 22.0043 7.91274 21.8645 7.6273C21.7302 7.35313 21.5447 7.03183 21.3224 6.64695L21.2972 6.60318C21.0749 6.21825 20.8894 5.89688 20.7191 5.64347C20.5418 5.37967 20.3517 5.1436 20.1023 4.95225C19.5237 4.50826 18.7924 4.3123 18.0692 4.4075C17.6928 4.45706 17.353 4.60782 16.9975 4.79572C16.7117 4.94679 16.3967 4.94036 16.1561 4.80144C15.9155 4.66253 15.7524 4.39297 15.7403 4.06991C15.7253 3.66808 15.686 3.2984 15.5407 2.94762C15.2616 2.27379 14.7262 1.73844 14.0524 1.45933C13.762 1.33905 13.4625 1.29241 13.1454 1.27077C12.8407 1.24999 12.4697 1.24999 12.0252 1.25H11.9747ZM10.5216 2.84515C10.5988 2.81319 10.716 2.78372 10.9567 2.76729C11.2042 2.75041 11.5238 2.75 12 2.75C12.4762 2.75 12.7958 2.75041 13.0432 2.76729C13.284 2.78372 13.4012 2.81319 13.4783 2.84515C13.7846 2.97202 14.028 3.21536 14.1548 3.52165C14.1949 3.61826 14.228 3.76887 14.2414 4.12597C14.271 4.91835 14.68 5.68129 15.4061 6.10048C16.1321 6.51968 16.9974 6.4924 17.6984 6.12188C18.0143 5.9549 18.1614 5.90832 18.265 5.89467C18.5937 5.8514 18.9261 5.94047 19.1891 6.14228C19.2554 6.19312 19.3395 6.27989 19.4741 6.48016C19.6125 6.68603 19.7726 6.9626 20.0107 7.375C20.2488 7.78741 20.4083 8.06438 20.5174 8.28713C20.6235 8.50382 20.6566 8.62007 20.6675 8.70287C20.7108 9.03155 20.6217 9.36397 20.4199 9.62698C20.3562 9.70995 20.2424 9.81399 19.9397 10.0041C19.2684 10.426 18.8122 11.1616 18.8121 11.9999C18.8121 12.8383 19.2683 13.574 19.9397 13.9959C20.2423 14.186 20.3561 14.29 20.4198 14.373C20.6216 14.636 20.7107 14.9684 20.6674 15.2971C20.6565 15.3799 20.6234 15.4961 20.5173 15.7128C20.4082 15.9355 20.2487 16.2125 20.0106 16.6249C19.7725 17.0373 19.6124 17.3139 19.474 17.5198C19.3394 17.72 19.2553 17.8068 19.189 17.8576C18.926 18.0595 18.5936 18.1485 18.2649 18.1053C18.1613 18.0916 18.0142 18.045 17.6983 17.8781C16.9973 17.5075 16.132 17.4803 15.4059 17.8995C14.68 18.3187 14.271 19.0816 14.2414 19.874C14.228 20.2311 14.1949 20.3817 14.1548 20.4784C14.028 20.7846 13.7846 21.028 13.4783 21.1549C13.4012 21.1868 13.284 21.2163 13.0432 21.2327C12.7958 21.2496 12.4762 21.25 12 21.25C11.5238 21.25 11.2042 21.2496 10.9567 21.2327C10.716 21.2163 10.5988 21.1868 10.5216 21.1549C10.2154 21.028 9.97201 20.7846 9.84514 20.4784C9.80512 20.3817 9.77195 20.2311 9.75859 19.874C9.72896 19.0817 9.31997 18.3187 8.5939 17.8995C7.86784 17.4803 7.00262 17.5076 6.30158 17.8781C5.98565 18.0451 5.83863 18.0917 5.73495 18.1053C5.40626 18.1486 5.07385 18.0595 4.81084 17.8577C4.74458 17.8069 4.66045 17.7201 4.52586 17.5198C4.38751 17.314 4.22736 17.0374 3.98926 16.625C3.75115 16.2126 3.59171 15.9356 3.4826 15.7129C3.37646 15.4962 3.34338 15.3799 3.33248 15.2971CLine truncated
|
||||
fill="#000000" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type HookEvent = 'load' | 'click' | 'onUrlChange' | 'onHashChange' | 'keyup' | 'keydown'
|
||||
export type HookEvent = 'load' | 'click' | 'onUrlChange' | 'onHashChange' | 'enter' | 'keyup' | 'keydown'
|
||||
|
||||
export interface KeyInfo {
|
||||
key?: ArrayBuffer;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defaultConfig, type DeviceConfig } from "../config";
|
||||
import { b64decode, getSubtle, unhex } from ".";
|
||||
import { gunzip } from "../utils/compress";
|
||||
|
||||
// 正文算法: AES/CBC/PKCS5Padding
|
||||
// 返回 string(xhtml/html) | 章节 JSON 对象 | undefined(解析失败)
|
||||
export async function decryptChapter(
|
||||
encrypted: string,
|
||||
rawData?: any,
|
||||
config: DeviceConfig = defaultConfig,
|
||||
): Promise<string | unknown> {
|
||||
if (!encrypted) {
|
||||
@@ -29,7 +31,10 @@ export async function decryptChapter(
|
||||
{ name: "AES-CBC", iv },
|
||||
cryptoKey,
|
||||
data,
|
||||
).then((decrypted) => {
|
||||
).then(async (decrypted) => {
|
||||
if (rawData && rawData?.compress_status === 1) {
|
||||
decrypted = await gunzip(decrypted);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
const plain = decoder.decode(decrypted);
|
||||
if (plain.trim().startsWith("<")) { // xhtml || html
|
||||
|
||||
@@ -3,12 +3,14 @@ import readerHook from "./readerHook";
|
||||
import fetchHook from "./fetchHook";
|
||||
import userHook from "./userHook";
|
||||
import bookshelfHook from "./bookshelfHook";
|
||||
import searchHook from "./searchHook";
|
||||
|
||||
const hooks: HookConfig[] = [
|
||||
...readerHook,
|
||||
...fetchHook,
|
||||
...userHook,
|
||||
...bookshelfHook,
|
||||
...searchHook,
|
||||
];
|
||||
|
||||
async function onEvent(event: HookEvent, previous?: string) {
|
||||
@@ -44,5 +46,11 @@ export async function onHashChange(previous: string) {
|
||||
}
|
||||
|
||||
export async function onLoad() {
|
||||
/* document.body ready */
|
||||
return await onEvent('load');
|
||||
}
|
||||
|
||||
export async function onEnter() {
|
||||
/* page enter(document.body may not ready) */
|
||||
return await onEvent('enter');
|
||||
}
|
||||
+45
-39
@@ -4,9 +4,9 @@ import { getBookInfoAndCatalog } from '../api/book'
|
||||
import { applyBookCss } from '../api/bookcss'
|
||||
import { processFootnotes, bindFootnoteInteraction } from '../utils/footnote'
|
||||
import { settings } from '../settings'
|
||||
import { fetch } from '../config'
|
||||
// import defaultcss from '../assets/default.css?raw';
|
||||
import { cloneElement } from '../utils';
|
||||
import { fetchArrayBuffer } from '../utils/request';
|
||||
import { type Book } from '../types'
|
||||
import { decryptComicImage } from '../crypto/content';
|
||||
// import moment from 'moment'
|
||||
@@ -15,6 +15,40 @@ let currentBook: Book | null = null
|
||||
|
||||
let latestItemId: string | null = null
|
||||
|
||||
/** 脚本接管的正文容器 id。文字章与漫画章共用同一个,切章时复用 */
|
||||
const SCRIPT_CONTAINER_ID = 'fqa-reader-content'
|
||||
|
||||
/** 漫画懒加载观察器,切章时要换掉旧的 */
|
||||
let comicObserver: IntersectionObserver | null = null
|
||||
|
||||
/**
|
||||
* 取到脚本容器:没有就克隆一个插到原容器前面,有就复用并清空。
|
||||
*
|
||||
* 必须按 id 复用。之前漫画分支查的是 'fqa-comic-content'、写的却是
|
||||
* 'fqa-reader-content',永远查不到,于是每切一章就多插一个容器,
|
||||
* 上一章的图片留在页面里,新图被挤到视口外,懒加载再也不触发。
|
||||
*/
|
||||
function ensureScriptContainer(readerContainer: Element, comic: boolean): HTMLDivElement {
|
||||
let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID) as HTMLDivElement | null
|
||||
if (!scriptContainer) {
|
||||
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
|
||||
scriptContainer.id = SCRIPT_CONTAINER_ID
|
||||
scriptContainer.classList.add('fqa')
|
||||
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
|
||||
}
|
||||
// 文字章与漫画章互相切换时同步类名
|
||||
scriptContainer.classList.toggle('fqa-comic-reader', comic)
|
||||
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
|
||||
|
||||
// 容器一清空,旧图片就从文档里消失了,观察器留着没意义
|
||||
comicObserver?.disconnect()
|
||||
comicObserver = null
|
||||
|
||||
scriptContainer.innerHTML = ''
|
||||
readerContainer.classList.add('fqa-hide')
|
||||
return scriptContainer
|
||||
}
|
||||
|
||||
async function insertContent() {
|
||||
const itemId = window.location.pathname.split('/').pop()?.substring(0, 19) || ''
|
||||
if (!itemId) {
|
||||
@@ -69,18 +103,7 @@ async function insertContent() {
|
||||
|
||||
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
|
||||
if (readerContainer) {
|
||||
// 防止插入多次
|
||||
let scriptContainer = document.getElementById('fqa-reader-content') as HTMLDivElement | null
|
||||
if (!scriptContainer) {
|
||||
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
|
||||
scriptContainer.id = 'fqa-reader-content'
|
||||
scriptContainer.classList.add('fqa')
|
||||
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
|
||||
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
|
||||
}
|
||||
scriptContainer.innerHTML = ''
|
||||
// 隐藏原来的内容div
|
||||
readerContainer.classList.add('fqa-hide')
|
||||
const scriptContainer = ensureScriptContainer(readerContainer, false)
|
||||
scriptContainer.appendChild(toProcess)
|
||||
bindFootnoteInteraction(scriptContainer)
|
||||
}
|
||||
@@ -104,17 +127,7 @@ async function insertContent() {
|
||||
|
||||
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
|
||||
if (readerContainer) {
|
||||
let scriptContainer = document.getElementById('fqa-comic-content') as HTMLDivElement | null
|
||||
if (!scriptContainer) {
|
||||
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
|
||||
scriptContainer.id = 'fqa-reader-content'
|
||||
scriptContainer.classList.add('fqa')
|
||||
scriptContainer.classList.add('fqa-comic-reader')
|
||||
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
|
||||
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
|
||||
}
|
||||
scriptContainer.innerHTML = ''
|
||||
readerContainer.classList.add('fqa-hide')
|
||||
const scriptContainer = ensureScriptContainer(readerContainer, true)
|
||||
imgs.forEach(img => scriptContainer.appendChild(img))
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -125,8 +138,11 @@ async function insertContent() {
|
||||
if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) {
|
||||
observer.unobserve(img) // 只解密一次
|
||||
try {
|
||||
const response = await fetch(img.dataset.encryptedUrl)
|
||||
const encryptedBuffer = await response.arrayBuffer()
|
||||
// 图片 CDN 多数不支持跨域,页面 fetch 读不到响应体,
|
||||
// fetchArrayBuffer 会自动退到 GM_xmlhttpRequest
|
||||
const encryptedBuffer = await fetchArrayBuffer(
|
||||
img.dataset.encryptedUrl
|
||||
)
|
||||
const decryptedBuffer = await decryptComicImage(
|
||||
encryptedBuffer,
|
||||
img.dataset.encryptKey
|
||||
@@ -153,6 +169,8 @@ async function insertContent() {
|
||||
}
|
||||
)
|
||||
|
||||
// 记下来,下次切章时断开
|
||||
comicObserver = observer
|
||||
imgs.forEach(img => observer.observe(img))
|
||||
}
|
||||
} else {
|
||||
@@ -167,19 +185,7 @@ async function insertContent() {
|
||||
}
|
||||
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)")
|
||||
if (readerContainer) {
|
||||
// 防止插入多次
|
||||
let scriptContainer = document.getElementById('fqa-comic-content') as HTMLDivElement | null
|
||||
if (!scriptContainer) {
|
||||
scriptContainer = cloneElement(readerContainer) as HTMLDivElement
|
||||
scriptContainer.id = 'fqa-reader-content'
|
||||
scriptContainer.classList.add('fqa')
|
||||
scriptContainer.classList.add('fqa-comic-reader')
|
||||
if (settings.allowCopy) scriptContainer.classList.remove('noselect')
|
||||
readerContainer.insertAdjacentElement('beforebegin', scriptContainer)
|
||||
}
|
||||
scriptContainer.innerHTML = ''
|
||||
// 隐藏原来的内容div
|
||||
readerContainer.classList.add('fqa-hide')
|
||||
const scriptContainer = ensureScriptContainer(readerContainer, true)
|
||||
imgs.forEach(img => scriptContainer.appendChild(img))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createApp, watch, type App } from 'vue'
|
||||
import type { HookConfig } from '../config'
|
||||
import SearchView from '../views/SearchView.vue'
|
||||
import searchcss from '../assets/search.css?raw'
|
||||
import { waitForElement } from '../utils'
|
||||
import { settings } from '../settings'
|
||||
import { routeQuery, syncFromUrl } from '../views/searchRoute'
|
||||
|
||||
const CONTAINER_ID = 'fqa-search-root'
|
||||
const STYLE_ID = 'fqa-search-style'
|
||||
|
||||
/** 原站搜索结果页根节点 */
|
||||
const RESULT_SELECTOR = '.muye-search'
|
||||
/** 原站 404 页根节点,/search 会命中 */
|
||||
const NOTFOUND_SELECTOR = '.muye-undefined'
|
||||
const ORIGIN_SELECTOR = `${RESULT_SELECTOR}, ${NOTFOUND_SELECTOR}`
|
||||
|
||||
let app: App | null = null
|
||||
let container: HTMLElement | null = null
|
||||
let observer: MutationObserver | null = null
|
||||
/** 标题跟随搜索词,挂载后一直监听 */
|
||||
let stopTitleWatch: (() => void) | null = null
|
||||
|
||||
function injectStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLE_ID
|
||||
style.textContent = searchcss
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
function isSearchPath(path: string): boolean {
|
||||
return path === '/search' || path === '/search/' || path.startsWith('/search/')
|
||||
}
|
||||
|
||||
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 unmount() {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
stopTitleWatch?.()
|
||||
stopTitleWatch = null
|
||||
app?.unmount()
|
||||
app = null
|
||||
container?.remove()
|
||||
container = null
|
||||
// 还原原站节点,避免返回时白屏
|
||||
document.querySelectorAll<HTMLElement>(ORIGIN_SELECTOR).forEach(el => {
|
||||
el.classList.remove('fqa-hide')
|
||||
})
|
||||
}
|
||||
|
||||
async function mainHook(_previous?: string): Promise<void> {
|
||||
const path = window.location.pathname
|
||||
if (!isSearchPath(path) || !settings.enhanceSearch) {
|
||||
unmount()
|
||||
return
|
||||
}
|
||||
|
||||
// URL 是搜索词的来源;视图 watch 这个 ref,不需要重挂
|
||||
syncFromUrl(path)
|
||||
|
||||
if (app) {
|
||||
hideOrigin()
|
||||
return
|
||||
}
|
||||
|
||||
injectStyle()
|
||||
|
||||
// 落地页命中的是 404 根节点,结果页是 .muye-search
|
||||
const origin = await waitForElement(ORIGIN_SELECTOR, 8000)
|
||||
if (!isSearchPath(window.location.pathname)) return
|
||||
if (app) return
|
||||
|
||||
hideOrigin()
|
||||
mount(origin)
|
||||
|
||||
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 mount(origin: HTMLElement | null) {
|
||||
container = document.createElement('div')
|
||||
container.id = CONTAINER_ID
|
||||
|
||||
if (origin?.parentElement) {
|
||||
origin.insertAdjacentElement('beforebegin', container)
|
||||
} else {
|
||||
const anchor = document.querySelector('#root') ?? document.body
|
||||
anchor.appendChild(container)
|
||||
}
|
||||
|
||||
app = createApp(SearchView)
|
||||
app.config.errorHandler = (err, _instance, info) => {
|
||||
console.error(`[fqa:search] Vue error (${info}):`, err)
|
||||
}
|
||||
app.mount(container)
|
||||
|
||||
stopTitleWatch = watch(
|
||||
routeQuery,
|
||||
q => {
|
||||
document.title = q ? `${q} - 搜索 - 番茄小说` : '搜索 - 番茄小说'
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
console.log('[fqa:search] 搜索视图已挂载:', routeQuery.value || '(落地页)')
|
||||
}
|
||||
|
||||
function filter(path: string, _query: URLSearchParams, _hash: string): boolean {
|
||||
// 已挂载时也要放行,否则离开搜索页时没机会卸载
|
||||
return isSearchPath(path) || !!app
|
||||
}
|
||||
|
||||
async function overloadTitle(_previous?: string): Promise<void> {
|
||||
const searchKey = routeQuery.value || '搜索'
|
||||
document.title = `${searchKey} - 番茄小说`
|
||||
}
|
||||
|
||||
const _exports: HookConfig[] = [
|
||||
{
|
||||
id: 'searchHook_onload',
|
||||
event: 'load',
|
||||
filter,
|
||||
handler: mainHook,
|
||||
},
|
||||
{
|
||||
id: 'searchHook_onurlchange',
|
||||
event: 'onUrlChange',
|
||||
filter,
|
||||
handler: mainHook,
|
||||
},
|
||||
{
|
||||
id: 'searchHook_e',
|
||||
event: 'enter',
|
||||
filter,
|
||||
handler: overloadTitle,
|
||||
}
|
||||
]
|
||||
|
||||
export default _exports
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getDetailedUserInfo, userState } from '../api/user'
|
||||
import type { HookConfig } from '../config'
|
||||
import bookshelf from '../assets/bookshelf.svg?raw'
|
||||
import settings from '../assets/settings.svg?raw'
|
||||
import { openSettings } from '../settingsPanel'
|
||||
import { cloneElement } from '../utils'
|
||||
|
||||
@@ -57,6 +58,10 @@ async function mainHook(_previous?: string): Promise<void> {
|
||||
const injected = new WeakSet<HTMLElement>()
|
||||
|
||||
const inject = (menuInner: HTMLElement) => {
|
||||
if (menuInner.querySelectorAll('div.arco-menu-inline').length > 0) {
|
||||
// 排行榜不注入
|
||||
return
|
||||
}
|
||||
if (injected.has(menuInner)) {
|
||||
return
|
||||
}
|
||||
@@ -71,7 +76,7 @@ async function mainHook(_previous?: string): Promise<void> {
|
||||
})
|
||||
secondDiv.insertAdjacentElement('afterend', thirdDiv)
|
||||
// 助手设置固定放在菜单最后
|
||||
menuInner.appendChild(createMenuItem('助手设置', undefined, openSettings))
|
||||
menuInner.appendChild(createMenuItem('助手设置', settings, openSettings))
|
||||
}
|
||||
|
||||
const scan = (root: HTMLElement) => {
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ import _config from './config'
|
||||
import injectCSS from './cssInject'
|
||||
import initFontDecrypt from './fontDecrypt'
|
||||
import initUserStyle from './userStyle'
|
||||
import { onLoad, onUrlChange, onHashChange } from './hooks'
|
||||
import { onLoad, onUrlChange, onHashChange, onEnter } from './hooks'
|
||||
import { version, name } from '../package.json'
|
||||
import initUser from './api/user'
|
||||
import { ensureDevice } from './api/provision'
|
||||
@@ -45,6 +45,8 @@ async function mainInit() {
|
||||
|
||||
installNavigationHooks()
|
||||
|
||||
void onEnter()
|
||||
|
||||
initFontDecrypt()
|
||||
|
||||
await injectCSS()
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface Settings {
|
||||
/** 自定义 CSS 内容。关闭开关时仍然保留,只是不应用 */
|
||||
customCss: string
|
||||
|
||||
/* --- 搜索 --- */
|
||||
/** 接管网页搜索界面 */
|
||||
enhanceSearch: boolean
|
||||
/** 搜索时携带登录态以获取个人化推荐 */
|
||||
searchPersonalized: boolean
|
||||
|
||||
/* --- 协议 --- */
|
||||
apiPreference: ApiPreference
|
||||
/** 用户手动指定的设备信息,留空表示用脚本自动注册的设备 */
|
||||
@@ -40,6 +46,10 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
customCssEnabled: false,
|
||||
customCss: '',
|
||||
|
||||
enhanceSearch: true,
|
||||
// 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启
|
||||
searchPersonalized: false,
|
||||
|
||||
apiPreference: 'app',
|
||||
deviceId: '',
|
||||
installId: '',
|
||||
|
||||
@@ -120,3 +120,93 @@ export interface MenuItem {
|
||||
disabled?: boolean
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
/* --------------------------------- 搜索 --------------------------------- */
|
||||
|
||||
/** 搜索 tab。tab_type=1 是综合,只有它支持筛选 */
|
||||
export interface SearchTab {
|
||||
tab_type: number
|
||||
tab_name: string
|
||||
/** 该 tab 是否有筛选器 */
|
||||
has_selector: boolean
|
||||
}
|
||||
|
||||
/** 筛选器里的一个可选项 */
|
||||
export interface SelectorItem {
|
||||
id: string
|
||||
name: string
|
||||
/** 接口标记的默认项 */
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
/** 筛选器的一行,例如「类型」「状态」 */
|
||||
export interface SelectorRow {
|
||||
name: string
|
||||
items: SelectorItem[]
|
||||
}
|
||||
|
||||
/** 搜索结果里的一本书 */
|
||||
export interface SearchBook {
|
||||
book_id: string
|
||||
title: string
|
||||
author: string
|
||||
cover_url: string
|
||||
summary: string
|
||||
/** 原始 creation_status,映射用 mappingCreationStatus */
|
||||
status: string
|
||||
/** 总字数,有声书等可能为 0 */
|
||||
word_count: number
|
||||
/** 接口下发的展示文案,如「404章」「10.5万人在读」 */
|
||||
sub_info: string
|
||||
/** 在读人数 */
|
||||
read_count: number
|
||||
score: string
|
||||
category: string
|
||||
/** 章节数 */
|
||||
chapter_count: number
|
||||
last_chapter_title: string
|
||||
/** 最近更新时间,ms */
|
||||
last_publish_time: number
|
||||
/** 命中关键词的标题,只含 <em> */
|
||||
highlight_title?: string
|
||||
genre?: string
|
||||
/** 接口标记该书已在书架 */
|
||||
in_bookshelf: boolean
|
||||
}
|
||||
|
||||
/** 一次搜索请求的结果 */
|
||||
export interface SearchResult {
|
||||
books: SearchBook[]
|
||||
/** 下一页要回填的 passback,由服务端给出 */
|
||||
next_passback: number
|
||||
has_more: boolean
|
||||
/** 接口返回的全部可用 tab */
|
||||
tabs: SearchTab[]
|
||||
/** 筛选器定义,只有综合 tab 有 */
|
||||
selector_rows: SelectorRow[]
|
||||
}
|
||||
|
||||
/** 搜索中间页(推荐词 / 热搜)*/
|
||||
export interface SearchSuggestBook {
|
||||
book_id: string
|
||||
title: string
|
||||
cover_url: string
|
||||
author?: string
|
||||
/** 榜单里的描述,如分类名 */
|
||||
desc?: string
|
||||
}
|
||||
|
||||
export interface SearchHotWord {
|
||||
word: string
|
||||
/** 附加说明,如「877405热搜值」 */
|
||||
tag?: string
|
||||
/** 角标,如「荐」 */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface SearchLandingSection {
|
||||
title: string
|
||||
kind: 'hotword' | 'book'
|
||||
words: SearchHotWord[]
|
||||
books: SearchSuggestBook[]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fetch as pageFetch } from "../config";
|
||||
|
||||
type GMRequestMethod = "GET" | "HEAD" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
export type ApiResponse = Tampermonkey.Response<unknown> & {
|
||||
@@ -89,6 +91,50 @@ export default function apiFetch(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取二进制资源。
|
||||
*
|
||||
* 部分图片 CDN 不下发 CORS 头,页面 fetch 读不到响应体(但 <img> 能显示,
|
||||
* 因为图片元素不受同源策略约束)。加密漫画必须拿到原始字节才能解密,
|
||||
* 所以这里先试页面 fetch(快、无需权限),失败再退到 GM_xmlhttpRequest —
|
||||
* 后者是特权请求,不受 CORS 限制,但要求目标域名在 @connect 列表里。
|
||||
*/
|
||||
export function fetchArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
// 用启动时存下的原始 fetch,避免被字节上报 SDK 或本脚本的拦截改写
|
||||
return pageFetch(url, { referrerPolicy: 'no-referrer' })
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.arrayBuffer()
|
||||
})
|
||||
.catch(err => {
|
||||
console.debug('[fqa:img] 页面 fetch 失败,改用 GM_xmlhttpRequest:', url, err)
|
||||
return gmArrayBuffer(url)
|
||||
})
|
||||
}
|
||||
|
||||
function gmArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
GM_xmlhttpRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
onload(response) {
|
||||
const buf = response.response as ArrayBuffer | null
|
||||
if (response.status >= 200 && response.status < 300 && buf?.byteLength) {
|
||||
resolve(buf)
|
||||
} else {
|
||||
reject(new Error(`GM 请求失败(${response.status})`))
|
||||
}
|
||||
},
|
||||
onerror() {
|
||||
// 多半是域名不在 @connect 里
|
||||
reject(new Error(`GM 请求出错,检查 @connect 是否覆盖该域名: ${url}`))
|
||||
},
|
||||
ontimeout: () => reject(new Error('GM 请求超时')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeHeaders(
|
||||
headers?: HeadersInit,
|
||||
): Record<string, string> | undefined {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { SearchBook } from '../types'
|
||||
import { mappingCreationStatus } from '../api/book'
|
||||
import { fromNow } from '../utils/time'
|
||||
|
||||
const props = defineProps<{ book: SearchBook }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open', book: SearchBook): void
|
||||
(e: 'contextmenu', payload: { book: SearchBook; x: number; y: number }): void
|
||||
}>()
|
||||
|
||||
/** 高亮片段已在 api 层清洗过,只剩 <em> */
|
||||
const titleHtml = computed(() => props.book.highlight_title ?? null)
|
||||
|
||||
const statusText = computed(() => mappingCreationStatus(props.book.status))
|
||||
|
||||
const wordText = computed(() => {
|
||||
const n = props.book.word_count
|
||||
// 有声书之类没有字数,接口给 0
|
||||
if (!n) return ''
|
||||
if (n >= 10_000) return `${(n / 10_000).toFixed(1).replace(/\.0$/, '')}万字`
|
||||
return `${n}字`
|
||||
})
|
||||
|
||||
const metaParts = computed(() =>
|
||||
[statusText.value, wordText.value, props.book.category].filter(Boolean)
|
||||
)
|
||||
|
||||
/** sub_info 是接口现成的展示文案,没有再退回自己格式化在读人数 */
|
||||
const readText = computed(() => {
|
||||
if (props.book.sub_info) return props.book.sub_info
|
||||
const n = props.book.read_count
|
||||
if (!n) return ''
|
||||
return n >= 10_000 ? `${(n / 10_000).toFixed(1).replace(/\.0$/, '')}万人在读` : `${n}人在读`
|
||||
})
|
||||
|
||||
const updateText = computed(() => {
|
||||
const t = props.book.last_publish_time
|
||||
return t ? `${fromNow(t)}更新` : ''
|
||||
})
|
||||
|
||||
const imgLoaded = ref(false)
|
||||
watch(
|
||||
() => props.book.cover_url,
|
||||
() => {
|
||||
imgLoaded.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
emit('contextmenu', { book: props.book, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fqa-sr-card"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
:aria-label="book.title"
|
||||
@click="emit('open', book)"
|
||||
@keydown.enter.prevent="emit('open', book)"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<div class="fqa-sr-cover">
|
||||
<img
|
||||
class="fqa-sr-cover-img"
|
||||
:class="{ 'fqa-sr-cover-loading': !imgLoaded }"
|
||||
crossorigin="anonymous"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
:src="book.cover_url"
|
||||
:alt="book.title"
|
||||
@load="imgLoaded = true"
|
||||
@error="imgLoaded = true"
|
||||
/>
|
||||
<span v-if="book.in_bookshelf" class="fqa-sr-badge">在书架</span>
|
||||
</div>
|
||||
|
||||
<div class="fqa-sr-body">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<h3 v-if="titleHtml" class="fqa-sr-title" v-html="titleHtml"></h3>
|
||||
<h3 v-else class="fqa-sr-title">{{ book.title }}</h3>
|
||||
|
||||
<div class="fqa-sr-author">
|
||||
<span>{{ book.author }}</span>
|
||||
<span class="fqa-sr-score" :class="{ 'fqa-sr-score-none': !book.score }">
|
||||
{{ book.score ? `${book.score}分` : '暂无评分' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="book.summary" class="fqa-sr-summary">{{ book.summary }}</p>
|
||||
|
||||
<div class="fqa-sr-meta">
|
||||
<span v-for="part in metaParts" :key="part" class="fqa-sr-tag">{{ part }}</span>
|
||||
<span v-if="readText" class="fqa-sr-read">{{ readText }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="updateText || book.last_chapter_title" class="fqa-sr-update">
|
||||
<span v-if="book.last_chapter_title" class="fqa-sr-chapter">
|
||||
{{ book.last_chapter_title }}
|
||||
</span>
|
||||
<span v-if="updateText" class="fqa-sr-time">{{ updateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
// /search 落地页。原站这个路径是 404,这里放 APP 的搜索中间页内容。
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getSearchLanding } from '../api/search'
|
||||
import type { SearchLandingSection } from '../types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'word', word: string): void
|
||||
}>()
|
||||
|
||||
const sections = ref<SearchLandingSection[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
sections.value = await getSearchLanding()
|
||||
} catch (err) {
|
||||
console.error('[fqa:search] 推荐加载失败:', err)
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openBook(bookId: string) {
|
||||
unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fqa-s-landing">
|
||||
<div v-if="loading" class="fqa-s-landing-sk">
|
||||
<div v-for="n in 12" :key="n" class="fqa-sk-chip fqa-sk-anim"></div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐失败不影响搜索本身,给个轻提示就够了 -->
|
||||
<div v-else-if="error" class="fqa-s-status">
|
||||
<div class="fqa-s-status-title">推荐内容加载失败</div>
|
||||
<div>直接在上方输入关键词也可以搜索</div>
|
||||
<button class="fqa-s-submit" @click="load">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!sections.length" class="fqa-s-status">
|
||||
<div class="fqa-s-status-title">输入关键词开始搜索</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section v-for="(section, i) in sections" :key="`${section.title}-${i}`" class="fqa-s-sec">
|
||||
<h3 class="fqa-s-sec-title">{{ section.title }}</h3>
|
||||
|
||||
<div v-if="section.words.length" class="fqa-s-words">
|
||||
<button
|
||||
v-for="(w, wi) in section.words"
|
||||
:key="`${w.word}-${wi}`"
|
||||
class="fqa-s-word"
|
||||
@click="emit('word', w.word)"
|
||||
>
|
||||
<span v-if="w.label" class="fqa-s-word-label">{{ w.label }}</span>
|
||||
{{ w.word }}
|
||||
<span v-if="w.tag" class="fqa-s-word-tag">{{ w.tag }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="section.books.length" class="fqa-s-sugs">
|
||||
<div
|
||||
v-for="b in section.books"
|
||||
:key="b.book_id"
|
||||
class="fqa-s-sug"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="openBook(b.book_id)"
|
||||
@keydown.enter.prevent="openBook(b.book_id)"
|
||||
>
|
||||
<img
|
||||
class="fqa-s-sug-cover"
|
||||
crossorigin="anonymous"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
:src="b.cover_url"
|
||||
:alt="b.title"
|
||||
/>
|
||||
<div class="fqa-s-sug-title" :title="b.title">{{ b.title }}</div>
|
||||
<div v-if="b.desc || b.author" class="fqa-s-sug-sub">
|
||||
{{ b.desc || b.author }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,341 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import SearchBookCard from './SearchBookCard.vue'
|
||||
import SearchLanding from './SearchLanding.vue'
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import { useSearch } from './useSearch'
|
||||
import { addToBookshelf } from '../api/bookshelf'
|
||||
import { settings } from '../settings'
|
||||
import { userState } from '../api/user'
|
||||
import { pushQuery, routeQuery } from './searchRoute'
|
||||
import type { MenuItem, SearchBook } from '../types'
|
||||
|
||||
const {
|
||||
query,
|
||||
tabType,
|
||||
availableTabs,
|
||||
selectorRows,
|
||||
selected,
|
||||
canFilter,
|
||||
filterCount,
|
||||
books,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
isEmpty,
|
||||
submit,
|
||||
reset,
|
||||
loadMore,
|
||||
selectTab,
|
||||
toggleFilter,
|
||||
clearFilters,
|
||||
} = useSearch()
|
||||
|
||||
/** 输入框内容,回车/点按钮时才同步到 query */
|
||||
const input = ref(routeQuery.value)
|
||||
const filterOpen = ref(false)
|
||||
|
||||
/** 无搜索词时展示落地页 */
|
||||
const showLanding = computed(() => !query.value.trim())
|
||||
|
||||
async function doSearch(q = input.value) {
|
||||
const trimmed = q.trim()
|
||||
if (!trimmed) return
|
||||
input.value = trimmed
|
||||
filterOpen.value = false
|
||||
pushQuery(trimmed)
|
||||
await submit(trimmed)
|
||||
}
|
||||
|
||||
/** 落地页/结果页的热词点击 */
|
||||
function onWord(word: string) {
|
||||
void doSearch(word)
|
||||
}
|
||||
|
||||
/* ------------------------------ 无限滚动 ------------------------------ */
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let io: IntersectionObserver | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (routeQuery.value) await doSearch(routeQuery.value)
|
||||
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
io = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries.some(e => e.isIntersecting)) void loadMore()
|
||||
},
|
||||
{ rootMargin: '400px' }
|
||||
)
|
||||
watch(
|
||||
sentinel,
|
||||
el => {
|
||||
io?.disconnect()
|
||||
if (el) io?.observe(el)
|
||||
},
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
io?.disconnect()
|
||||
io = null
|
||||
})
|
||||
|
||||
/*
|
||||
* routeQuery 是搜索词的唯一来源,输入框无条件跟随它。
|
||||
* 不管是前进/后退、点热搜词还是自己提交,框里显示的都跟 URL 一致。
|
||||
*/
|
||||
watch(
|
||||
routeQuery,
|
||||
q => {
|
||||
input.value = q
|
||||
if (q === query.value.trim()) return
|
||||
if (q) {
|
||||
void submit(q)
|
||||
} else {
|
||||
// 退回 /search:清掉上一次的结果,重新显示落地页
|
||||
filterOpen.value = false
|
||||
reset()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/* -------------------------------- 右键菜单 -------------------------------- */
|
||||
|
||||
const menuVisible = ref(false)
|
||||
const menuPos = ref({ x: 0, y: 0 })
|
||||
const menuBook = ref<SearchBook | null>(null)
|
||||
const toast = ref<string | null>(null)
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast.value = msg
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value = null
|
||||
toastTimer = undefined
|
||||
}, 2600)
|
||||
}
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => [
|
||||
{ key: 'detail', label: '查看详情' },
|
||||
{
|
||||
key: 'shelf',
|
||||
label: menuBook.value?.in_bookshelf ? '已在书架' : '加入书架',
|
||||
disabled: !userState.isLogin || Boolean(menuBook.value?.in_bookshelf),
|
||||
},
|
||||
{ key: 'author', label: '搜索该作者', disabled: !menuBook.value?.author },
|
||||
])
|
||||
|
||||
function onCardContextMenu({ book, x, y }: { book: SearchBook; x: number; y: number }) {
|
||||
menuBook.value = book
|
||||
menuPos.value = { x, y }
|
||||
menuVisible.value = true
|
||||
}
|
||||
|
||||
function openBook(book: SearchBook) {
|
||||
unsafeWindow.location.href = `https://fanqienovel.com/page/${book.book_id}`
|
||||
}
|
||||
|
||||
async function onMenuSelect(key: string) {
|
||||
const book = menuBook.value
|
||||
if (!book) return
|
||||
|
||||
if (key === 'detail') {
|
||||
openBook(book)
|
||||
return
|
||||
}
|
||||
if (key === 'author') {
|
||||
void doSearch(book.author)
|
||||
return
|
||||
}
|
||||
if (key === 'shelf') {
|
||||
try {
|
||||
await addToBookshelf(book.book_id)
|
||||
book.in_bookshelf = true
|
||||
showToast(`已把《${book.title}》加入书架`)
|
||||
} catch (err) {
|
||||
console.error('[fqa:search] 加入书架失败:', err)
|
||||
showToast(err instanceof Error ? err.message : '加入书架失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- tab 指示条 ------------------------------- */
|
||||
|
||||
const tabsRef = ref<HTMLElement | null>(null)
|
||||
const inkStyle = ref<Record<string, string>>({ left: '0px', width: '0px' })
|
||||
|
||||
function updateInk() {
|
||||
const wrap = tabsRef.value
|
||||
if (!wrap) return
|
||||
const index = availableTabs.value.findIndex(t => t.tab_type === tabType.value)
|
||||
const el = wrap.querySelectorAll<HTMLElement>('.fqa-s-tab')[index]
|
||||
if (!el) return
|
||||
inkStyle.value = { left: `${el.offsetLeft}px`, width: `${el.offsetWidth}px` }
|
||||
}
|
||||
|
||||
watch([tabType, availableTabs], () => nextTick(updateInk), { deep: true })
|
||||
watch(showLanding, () => nextTick(updateInk))
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateInk)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateInk)
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
// 接口不返回总数,只能报已加载条数
|
||||
const resultHint = computed(() => {
|
||||
if (loading.value) return '搜索中…'
|
||||
if (books.value.length) return `已加载 ${books.value.length} 条`
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="fqa-search">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="fqa-s-bar">
|
||||
<div class="fqa-s-inputwrap">
|
||||
<input
|
||||
v-model="input"
|
||||
class="fqa-s-input"
|
||||
type="search"
|
||||
placeholder="搜索书名、作者"
|
||||
aria-label="搜索"
|
||||
@keydown.enter.prevent="doSearch()"
|
||||
/>
|
||||
<button
|
||||
v-if="input"
|
||||
class="fqa-s-clear"
|
||||
aria-label="清空"
|
||||
@click="input = ''"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<button class="fqa-s-submit" :disabled="!input.trim() || loading" @click="doSearch()">
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 落地页:还没有搜索词 -->
|
||||
<SearchLanding v-if="showLanding" @word="onWord" />
|
||||
|
||||
<template v-else>
|
||||
<!-- tab -->
|
||||
<div ref="tabsRef" class="fqa-s-tabs" role="tablist">
|
||||
<div
|
||||
v-for="tab in availableTabs"
|
||||
:key="tab.tab_type"
|
||||
class="fqa-s-tab"
|
||||
:class="{ 'fqa-s-tab-active': tabType === tab.tab_type }"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="tabType === tab.tab_type"
|
||||
@click="selectTab(tab.tab_type)"
|
||||
@keydown.enter.prevent="selectTab(tab.tab_type)"
|
||||
>
|
||||
{{ tab.tab_name }}
|
||||
</div>
|
||||
<span class="fqa-s-tab-ink" :style="inkStyle"></span>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div v-if="canFilter" class="fqa-s-filterbar">
|
||||
<button
|
||||
class="fqa-s-fbtn"
|
||||
:class="{ 'fqa-s-fbtn-on': filterOpen || filterCount > 0 }"
|
||||
@click="filterOpen = !filterOpen"
|
||||
>
|
||||
筛选<span v-if="filterCount" class="fqa-s-fcount">{{ filterCount }}</span>
|
||||
</button>
|
||||
<button v-if="filterCount" class="fqa-s-fclear" @click="clearFilters">清除</button>
|
||||
<span class="fqa-s-hint">{{ resultHint }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="canFilter && filterOpen" class="fqa-s-filters">
|
||||
<div v-for="row in selectorRows" :key="row.name" class="fqa-s-frow">
|
||||
<span class="fqa-s-frow-name">{{ row.name }}</span>
|
||||
<div class="fqa-s-fitems">
|
||||
<button
|
||||
v-for="item in row.items"
|
||||
:key="item.id"
|
||||
class="fqa-s-fitem"
|
||||
:class="{ 'fqa-s-fitem-on': selected[row.name] === item.id }"
|
||||
@click="toggleFilter(row.name, item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div v-if="error && !books.length" class="fqa-s-status">
|
||||
<div class="fqa-s-status-title">搜索失败</div>
|
||||
<div>{{ error }}</div>
|
||||
<button class="fqa-s-submit" @click="doSearch()">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isEmpty" class="fqa-s-status">
|
||||
<div class="fqa-s-status-title">没有找到相关内容</div>
|
||||
<div>换个关键词或者放宽筛选条件试试</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="fqa-s-list">
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 5" :key="`sk-${n}`" class="fqa-sr-card fqa-sr-skeleton">
|
||||
<div class="fqa-sk-cover fqa-sk-anim"></div>
|
||||
<div class="fqa-sr-body">
|
||||
<div class="fqa-sk-line fqa-sk-anim" style="width: 40%"></div>
|
||||
<div class="fqa-sk-line fqa-sk-anim" style="width: 24%"></div>
|
||||
<div class="fqa-sk-line fqa-sk-anim" style="width: 92%"></div>
|
||||
<div class="fqa-sk-line fqa-sk-anim" style="width: 76%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SearchBookCard
|
||||
v-else
|
||||
v-for="book in books"
|
||||
:key="book.book_id"
|
||||
:book="book"
|
||||
@open="openBook"
|
||||
@contextmenu="onCardContextMenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && hasMore" ref="sentinel" class="fqa-s-loadmore">
|
||||
{{ loadingMore ? '加载中…' : '滚动加载更多' }}
|
||||
</div>
|
||||
<div v-else-if="!loading && books.length" class="fqa-s-loadmore">没有更多了</div>
|
||||
|
||||
<!-- 追加失败时不清结果,单独提示 -->
|
||||
<div v-if="error && books.length" class="fqa-s-inline-error">{{ error }}</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<p v-if="!settings.searchPersonalized" class="fqa-s-privacy">
|
||||
当前使用匿名搜索。如需按你的阅读偏好排序,可在助手设置里开启个人化推荐。
|
||||
</p>
|
||||
|
||||
<Teleport to="body">
|
||||
<ContextMenu
|
||||
:visible="menuVisible"
|
||||
:x="menuPos.x"
|
||||
:y="menuPos.y"
|
||||
:items="menuItems"
|
||||
@select="onMenuSelect"
|
||||
@close="menuVisible = false"
|
||||
/>
|
||||
<div v-if="toast" class="fqa-toast">{{ toast }}</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,11 +7,12 @@ import { version } from '../../package.json'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
type SectionKey = 'general' | 'ui' | 'protocol' | 'about'
|
||||
type SectionKey = 'general' | 'ui' | 'search' | 'protocol' | 'about'
|
||||
|
||||
const SECTIONS: Array<{ key: SectionKey; label: string }> = [
|
||||
{ key: 'general', label: '常规' },
|
||||
{ key: 'ui', label: '界面' },
|
||||
{ key: 'search', label: '搜索' },
|
||||
{ key: 'protocol', label: '协议' },
|
||||
{ key: 'about', label: '关于' },
|
||||
]
|
||||
@@ -143,6 +144,31 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant'
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<template v-else-if="active === 'search'">
|
||||
<h3 class="fqa-set-h">搜索</h3>
|
||||
|
||||
<label class="fqa-set-row">
|
||||
<span class="fqa-set-label">接管搜索界面</span>
|
||||
<input v-model="settings.enhanceSearch" type="checkbox" class="fqa-set-switch" />
|
||||
</label>
|
||||
|
||||
<div class="fqa-set-row fqa-set-row-col">
|
||||
<label class="fqa-set-row" style="padding-top: 0; border-bottom: none">
|
||||
<span class="fqa-set-label">个人化推荐</span>
|
||||
<input
|
||||
v-model="settings.searchPersonalized"
|
||||
type="checkbox"
|
||||
class="fqa-set-switch"
|
||||
/>
|
||||
</label>
|
||||
<p class="fqa-set-note">
|
||||
开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。
|
||||
凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 协议 -->
|
||||
<template v-else-if="active === 'protocol'">
|
||||
<h3 class="fqa-set-h">协议</h3>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 搜索词的唯一来源。
|
||||
//
|
||||
// URL 和视图都要能改搜索词,各自持有一份必然打架:hook 收到 onUrlChange 后
|
||||
// 重挂视图,会把视图刚发起的搜索冲掉。所以把搜索词提到这里,
|
||||
// hook 只负责 URL -> ref,视图只负责 ref -> URL,两边都跳过与当前值相同的更新。
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 当前搜索词,空串表示落地页 */
|
||||
export const routeQuery = ref('')
|
||||
|
||||
/** /search/<词> 里的词,落地页返回空串 */
|
||||
export function parseQuery(path: string): string {
|
||||
const rest = path.replace(/^\/search\/?/, '')
|
||||
if (!rest) return ''
|
||||
try {
|
||||
return decodeURIComponent(rest)
|
||||
} catch {
|
||||
// 非法百分号编码时按原文处理
|
||||
return rest
|
||||
}
|
||||
}
|
||||
|
||||
/** URL -> 状态。hook 在 load / onUrlChange 时调用 */
|
||||
export function syncFromUrl(path: string): void {
|
||||
const q = parseQuery(path)
|
||||
if (routeQuery.value !== q) routeQuery.value = q
|
||||
}
|
||||
|
||||
/** 状态 -> URL。视图在用户提交搜索时调用 */
|
||||
export function pushQuery(q: string): void {
|
||||
routeQuery.value = q
|
||||
const next = q ? `/search/${encodeURIComponent(q)}` : '/search'
|
||||
if (unsafeWindow.location.pathname === next) return
|
||||
// pushState 保留前进后退;脚本自身的导航钩子会收到通知,
|
||||
// 那边比对后发现与 routeQuery 一致,不会重复触发搜索
|
||||
unsafeWindow.history.pushState(null, '', next)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { BOOK_TAB_TYPE, search } from '../api/search'
|
||||
import type { SearchBook, SearchTab, SelectorRow } from '../types'
|
||||
|
||||
/** 兜底 tab,接口没返回 search_tabs 时至少能搜书 */
|
||||
const FALLBACK_TABS: SearchTab[] = [{ tab_type: BOOK_TAB_TYPE, tab_name: '综合', has_selector: true }]
|
||||
|
||||
export function useSearch() {
|
||||
const query = ref('')
|
||||
const tabType = ref<number>(BOOK_TAB_TYPE)
|
||||
const tabs = ref<SearchTab[]>([])
|
||||
const selectorRows = ref<SelectorRow[]>([])
|
||||
/** 行名 -> 选中的 selector_item_id。每行单选 */
|
||||
const selected = ref<Record<string, string>>({})
|
||||
|
||||
const books = ref<SearchBook[]>([])
|
||||
/** 服务端翻页游标。按 cell 计数,必须回填它给的值 */
|
||||
const passback = ref(0)
|
||||
const hasMore = ref(false)
|
||||
|
||||
/** 首次加载(换词/换 tab/换筛选)*/
|
||||
const loading = ref(false)
|
||||
/** 追加下一页 */
|
||||
const loadingMore = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** 已经搜过至少一次,用于区分「空结果」和「还没搜」*/
|
||||
const searched = ref(false)
|
||||
|
||||
/** 并发请求时丢弃过期响应 */
|
||||
let token = 0
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
Object.values(selected.value).filter(Boolean)
|
||||
)
|
||||
|
||||
const availableTabs = computed(() => (tabs.value.length ? tabs.value : FALLBACK_TABS))
|
||||
|
||||
/** 只有书籍 tab 支持筛选 */
|
||||
const canFilter = computed(() => tabType.value === BOOK_TAB_TYPE && selectorRows.value.length > 0)
|
||||
|
||||
const filterCount = computed(() => selectedItems.value.length)
|
||||
|
||||
async function run(reset: boolean): Promise<void> {
|
||||
const q = query.value.trim()
|
||||
if (!q) return
|
||||
|
||||
const current = ++token
|
||||
if (reset) {
|
||||
passback.value = 0
|
||||
loading.value = true
|
||||
error.value = null
|
||||
} else {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await search({
|
||||
query: q,
|
||||
passback: passback.value,
|
||||
selectedItems: selectedItems.value,
|
||||
tabType: tabType.value,
|
||||
})
|
||||
if (current !== token) return
|
||||
|
||||
// 同一本书可能在相邻页重复出现,按 book_id 去重
|
||||
if (reset) {
|
||||
books.value = result.books
|
||||
} else {
|
||||
const seen = new Set(books.value.map(b => b.book_id))
|
||||
books.value = [...books.value, ...result.books.filter(b => !seen.has(b.book_id))]
|
||||
}
|
||||
passback.value = result.next_passback
|
||||
hasMore.value = result.has_more
|
||||
// tab 列表每次都会回传,第一次拿到后就固定下来
|
||||
if (result.tabs.length) tabs.value = result.tabs
|
||||
if (result.selector_rows.length) selectorRows.value = result.selector_rows
|
||||
searched.value = true
|
||||
} catch (err) {
|
||||
if (current !== token) return
|
||||
console.error('[fqa:search] 搜索失败:', err)
|
||||
// 追加失败不清空已有结果,只提示
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
if (reset) books.value = []
|
||||
searched.value = true
|
||||
} finally {
|
||||
if (current === token) {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 换词或换条件,从第一页重搜 */
|
||||
async function submit(q?: string): Promise<void> {
|
||||
if (q !== undefined) query.value = q
|
||||
await run(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 回到「还没搜过」的状态,用于返回落地页。
|
||||
* 光清 query 不够:结果、游标、筛选都得一起清掉,
|
||||
* 否则下次搜索会带上上一次的筛选条件和翻页位置。
|
||||
*/
|
||||
function reset(): void {
|
||||
// 递增 token,丢弃仍在途的响应,避免它回来又把结果写回去
|
||||
token++
|
||||
query.value = ''
|
||||
books.value = []
|
||||
passback.value = 0
|
||||
hasMore.value = false
|
||||
searched.value = false
|
||||
error.value = null
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
selected.value = {}
|
||||
tabType.value = BOOK_TAB_TYPE
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (!hasMore.value || loading.value || loadingMore.value) return
|
||||
await run(false)
|
||||
}
|
||||
|
||||
function selectTab(next: number): void {
|
||||
if (tabType.value === next) return
|
||||
tabType.value = next
|
||||
// 筛选是书籍 tab 专属,切走时清掉避免误带
|
||||
if (next !== BOOK_TAB_TYPE) selected.value = {}
|
||||
void run(true)
|
||||
}
|
||||
|
||||
function toggleFilter(rowName: string, itemId: string): void {
|
||||
const cur = selected.value[rowName]
|
||||
// 点已选中的项等于取消
|
||||
if (cur === itemId) {
|
||||
const next = { ...selected.value }
|
||||
delete next[rowName]
|
||||
selected.value = next
|
||||
} else {
|
||||
selected.value = { ...selected.value, [rowName]: itemId }
|
||||
}
|
||||
void run(true)
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
if (!filterCount.value) return
|
||||
selected.value = {}
|
||||
void run(true)
|
||||
}
|
||||
|
||||
const isEmpty = computed(
|
||||
() => searched.value && !loading.value && !error.value && books.value.length === 0
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
tabType,
|
||||
availableTabs,
|
||||
selectorRows,
|
||||
selected,
|
||||
canFilter,
|
||||
filterCount,
|
||||
books,
|
||||
hasMore,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
searched,
|
||||
isEmpty,
|
||||
submit,
|
||||
reset,
|
||||
loadMore,
|
||||
selectTab,
|
||||
toggleFilter,
|
||||
clearFilters,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user