mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
feat(discovery): 完善书籍搜索推荐与日志记录
This commit is contained in:
1 parent
aee8c58b04
commit
61d3951905
4 files changed
+261
-25
No files matched your search
@@ -172,6 +172,82 @@ def query_one(conn, sql, params=None):
|
|||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_recommended_book(conn, values):
|
||||||
|
"""按 book_id 更新推荐缓存,避免同一本书每次推荐都新增一行。"""
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
columns = (
|
||||||
|
"book_id, title, author, cover, intro, category, reason, reading_count, "
|
||||||
|
"new_rating, new_rating_count, rating_title, deep_link, search_idx, price, "
|
||||||
|
"pay_type, book_type, batch_id"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM recommended_books WHERE book_id=%s ORDER BY id LIMIT 1", (values[0],))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE recommended_books SET title=%s, author=%s, cover=%s, intro=%s, category=%s, "
|
||||||
|
"reason=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, rating_title=%s, "
|
||||||
|
"deep_link=%s, search_idx=%s, price=%s, pay_type=%s, book_type=%s, batch_id=%s, "
|
||||||
|
"updated_at=NOW() WHERE id=%s",
|
||||||
|
(*values[1:], row[0]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
f"INSERT INTO recommended_books ({columns}) VALUES ({','.join(['%s'] * 17)})",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO content_logs "
|
||||||
|
"(event_type, batch_id, book_id, search_idx) VALUES ('recommendation',%s,%s,%s)",
|
||||||
|
(values[16], values[0], values[12] or 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_search_result(conn, session_id, values):
|
||||||
|
"""按 keyword + book_id 更新搜索缓存,避免重复搜索产生重复行。"""
|
||||||
|
if not conn or not session_id:
|
||||||
|
return
|
||||||
|
keyword, book_id = values[0], values[1]
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM search_results WHERE keyword=%s AND book_id=%s ORDER BY id LIMIT 1",
|
||||||
|
(keyword, book_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE search_results SET session_id=%s, title=%s, author=%s, cover=%s, intro=%s, "
|
||||||
|
"publisher=%s, category=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, "
|
||||||
|
"rating_title=%s, deep_link=%s, pay_type=%s, price=%s, soldout=%s, search_idx=%s, "
|
||||||
|
"result_scope=%s, result_title=%s, scope_count=%s, current_count=%s, updated_at=NOW() "
|
||||||
|
"WHERE id=%s",
|
||||||
|
(session_id, *values[2:], row[0]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO search_results "
|
||||||
|
"(session_id, keyword, book_id, title, author, cover, intro, publisher, category, "
|
||||||
|
"reading_count, new_rating, new_rating_count, rating_title, deep_link, pay_type, price, "
|
||||||
|
"soldout, search_idx, result_scope, result_title, scope_count, current_count) "
|
||||||
|
f"VALUES ({','.join(['%s'] * 22)})",
|
||||||
|
(session_id, *values),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO content_logs "
|
||||||
|
"(event_type, session_id, keyword, book_id, search_idx) VALUES ('search',%s,%s,%s,%s)",
|
||||||
|
(session_id, keyword, book_id, values[16] or 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# 格式化与展示辅助
|
# 格式化与展示辅助
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
@@ -1368,14 +1444,41 @@ def api_v2_archives(conn):
|
|||||||
def api_v2_recommend(conn):
|
def api_v2_recommend(conn):
|
||||||
"""为你推荐(实时)。Query: maxIdx"""
|
"""为你推荐(实时)。Query: maxIdx"""
|
||||||
max_idx = request.args.get("maxIdx", type=int, default=0)
|
max_idx = request.args.get("maxIdx", type=int, default=0)
|
||||||
|
refresh = request.args.get("refresh", "0") == "1"
|
||||||
try:
|
try:
|
||||||
data = weapi("/book/recommend", ttl=600, count=12, maxIdx=max_idx)
|
data = weapi("/book/recommend", ttl=0 if refresh else 600, count=12, maxIdx=max_idx)
|
||||||
except WeReadError as e:
|
except WeReadError as e:
|
||||||
return jsonify({"error": str(e)}), 502
|
return jsonify({"error": str(e)}), 502
|
||||||
|
|
||||||
books = []
|
books = []
|
||||||
for book in data.get("books") or []:
|
batch_id = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
raw_books = data.get("books") or []
|
||||||
|
|
||||||
|
def enrich_recommendation(book):
|
||||||
|
info = weapi_safe("/book/info", ttl=1800, bookId=str(book.get("bookId") or "")) or {}
|
||||||
|
merged = dict(book)
|
||||||
|
for key in ("cover", "newRating", "newRatingCount", "newRatingDetail", "intro", "category", "deepLink"):
|
||||||
|
if not merged.get(key) and info.get(key):
|
||||||
|
merged[key] = info[key]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||||
|
enriched_books = list(pool.map(enrich_recommendation, raw_books))
|
||||||
|
|
||||||
|
for book in enriched_books:
|
||||||
rating_detail = book.get("newRatingDetail") or {}
|
rating_detail = book.get("newRatingDetail") or {}
|
||||||
|
if conn:
|
||||||
|
upsert_recommended_book(
|
||||||
|
conn,
|
||||||
|
(
|
||||||
|
book.get("bookId", ""), book.get("title", ""), book.get("author", ""),
|
||||||
|
book.get("cover", ""), book.get("intro", ""), book.get("category", ""),
|
||||||
|
book.get("reason", ""), book.get("readingCount", 0), book.get("newRating", 0),
|
||||||
|
book.get("newRatingCount", 0), (rating_detail or {}).get("title", ""),
|
||||||
|
book.get("deepLink", ""), book.get("searchIdx", 0), book.get("price"),
|
||||||
|
book.get("payType"), book.get("type", 0), batch_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
books.append(
|
books.append(
|
||||||
{
|
{
|
||||||
"id": str(book.get("bookId") or ""),
|
"id": str(book.get("bookId") or ""),
|
||||||
@@ -1436,16 +1539,28 @@ def api_v2_search(conn):
|
|||||||
return jsonify({"error": str(e), "keyword": keyword, "scope": scope_name}), 502
|
return jsonify({"error": str(e), "keyword": keyword, "scope": scope_name}), 502
|
||||||
|
|
||||||
books, authors, lists, groups = [], [], [], []
|
books, authors, lists, groups = [], [], [], []
|
||||||
|
session_id = None
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO search_sessions (sid, keyword, scope, has_more, max_idx) VALUES (%s,%s,%s,%s,%s)",
|
||||||
|
(data.get("sid", ""), keyword, scope, data.get("hasMore", 0), 0),
|
||||||
|
)
|
||||||
|
session_id = cur.lastrowid
|
||||||
|
except Exception:
|
||||||
|
session_id = None
|
||||||
seen_books, seen_authors, seen_groups = set(), set(), set()
|
seen_books, seen_authors, seen_groups = set(), set(), set()
|
||||||
for group in data.get("results") or []:
|
for group in data.get("results") or []:
|
||||||
title = group.get("title") or ""
|
title = group.get("title") or ""
|
||||||
gkey = (title, group.get("scope"), group.get("currentCount"))
|
gkey = (title, group.get("scope"), group.get("currentCount"))
|
||||||
if gkey in seen_groups:
|
if gkey not in seen_groups:
|
||||||
continue
|
|
||||||
seen_groups.add(gkey)
|
seen_groups.add(gkey)
|
||||||
items = group.get("books") or []
|
|
||||||
if title:
|
if title:
|
||||||
groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0})
|
groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0})
|
||||||
|
items = group.get("books") or []
|
||||||
|
if title == "作者" and not items and group.get("scopeCount"):
|
||||||
|
authors.append({"name": keyword, "count": group.get("scopeCount") or 0})
|
||||||
for item in items:
|
for item in items:
|
||||||
bi = item.get("bookInfo") or item.get("book") or {}
|
bi = item.get("bookInfo") or item.get("book") or {}
|
||||||
bid = str(bi.get("bookId") or "")
|
bid = str(bi.get("bookId") or "")
|
||||||
@@ -1468,13 +1583,26 @@ def api_v2_search(conn):
|
|||||||
"short": short_title(bi.get("title")),
|
"short": short_title(bi.get("title")),
|
||||||
"desc": bi.get("intro") or "",
|
"desc": bi.get("intro") or "",
|
||||||
"category": bi.get("category") or "",
|
"category": bi.get("category") or "",
|
||||||
"score": round(float(item.get("newRating") or 0) / 100, 1) if item.get("newRating") else None,
|
"score": round(float(bi.get("newRating") or 0) / 100, 1) if bi.get("newRating") else None,
|
||||||
"reading_count": item.get("readingCount") or 0,
|
"reading_count": item.get("readingCount") or 0,
|
||||||
"deep_link": bi.get("deepLink") or "",
|
"deep_link": bi.get("deepLink") or "",
|
||||||
"group": title,
|
"group": title,
|
||||||
"status": "wish",
|
"status": "wish",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
}
|
}
|
||||||
|
if session_id:
|
||||||
|
upsert_search_result(
|
||||||
|
conn,
|
||||||
|
session_id,
|
||||||
|
(
|
||||||
|
keyword, bid, entry["title"], entry["author"], bi.get("cover", ""),
|
||||||
|
bi.get("intro", ""), bi.get("publisher", ""), entry["category"],
|
||||||
|
item.get("readingCount", 0), bi.get("newRating", 0), bi.get("newRatingCount", 0),
|
||||||
|
(bi.get("newRatingDetail") or {}).get("title", ""), entry["deep_link"],
|
||||||
|
bi.get("payType"), bi.get("price"), bi.get("soldout", 0), item.get("searchIdx", 0),
|
||||||
|
group.get("scope"), title, group.get("scopeCount") or 0, group.get("currentCount") or 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
if title in ("书单", "书单合集"):
|
if title in ("书单", "书单合集"):
|
||||||
lists.append({**entry, "name": entry["title"], "source": title, "ids": []})
|
lists.append({**entry, "name": entry["title"], "source": title, "ids": []})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ try:
|
|||||||
_ssl_context = ssl.create_default_context(cafile=certifi.where())
|
_ssl_context = ssl.create_default_context(cafile=certifi.where())
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_ssl_context = ssl.create_default_context()
|
_ssl_context = ssl.create_default_context()
|
||||||
from flask import Flask, jsonify, render_template, request
|
from flask import Flask, g, jsonify, render_template, request
|
||||||
|
|
||||||
from api_v2 import v2_bp
|
from api_v2 import v2_bp
|
||||||
|
|
||||||
@@ -61,6 +61,60 @@ MYSQL_CONFIG = {
|
|||||||
"cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值
|
"cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# 网站操作日志
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def start_operation_log_timer():
|
||||||
|
"""记录请求开始时间,供 after_request 计算接口耗时。"""
|
||||||
|
g.operation_started_at = time.perf_counter()
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def write_operation_log(response):
|
||||||
|
"""将 API 请求写入 operation_logs;日志失败不影响业务响应。"""
|
||||||
|
if not request.path.startswith("/api/"):
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
started_at = getattr(g, "operation_started_at", time.perf_counter())
|
||||||
|
duration_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
||||||
|
path_parts = [part for part in request.path.split("/") if part]
|
||||||
|
action = "/".join(path_parts[-2:]) if len(path_parts) >= 2 else request.path
|
||||||
|
target_type = path_parts[-2] if len(path_parts) >= 2 else None
|
||||||
|
target_id = path_parts[-1] if len(path_parts) >= 2 and path_parts[-1] != target_type else None
|
||||||
|
detail = {"query": request.args.to_dict(flat=True)}
|
||||||
|
conn = pymysql.connect(**MYSQL_CONFIG)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO operation_logs "
|
||||||
|
"(operation_type, action, target_type, target_id, keyword, request_path, "
|
||||||
|
"request_method, status_code, duration_ms, detail_json) "
|
||||||
|
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||||||
|
(
|
||||||
|
"api_request",
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
request.args.get("keyword"),
|
||||||
|
request.path,
|
||||||
|
request.method,
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
json.dumps(detail, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
# 日志属于旁路能力,数据库或表异常时不能影响正常页面/API 返回。
|
||||||
|
pass
|
||||||
|
return response
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# 微信读书 API 配置
|
# 微信读书 API 配置
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|||||||
+70
-16
@@ -19,6 +19,8 @@ const SHELF_PAGE = 48;
|
|||||||
/* ------------------------------- 运行时状态 ------------------------------ */
|
/* ------------------------------- 运行时状态 ------------------------------ */
|
||||||
|
|
||||||
let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week';
|
let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week';
|
||||||
|
let searchComposing = false, discoverSearchTimer = null;
|
||||||
|
let recommendOffset = 0;
|
||||||
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
|
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
|
||||||
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
|
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
|
||||||
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
|
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
|
||||||
@@ -81,7 +83,8 @@ $('#theme').onclick = () => {
|
|||||||
/* 封面:优先显示微信读书返回的真实封面,加载失败时回退到示意封面 */
|
/* 封面:优先显示微信读书返回的真实封面,加载失败时回退到示意封面 */
|
||||||
function cover(book, mini = false) {
|
function cover(book, mini = false) {
|
||||||
const short = book.short || book.title || '';
|
const short = book.short || book.title || '';
|
||||||
const coverUrl = book.cover_url || (typeof book.cover === 'string' && /^https?:\/\//i.test(book.cover) ? book.cover : '');
|
const rawCoverUrl = book.cover_url || (typeof book.cover === 'string' && /^https?:\/\//i.test(book.cover) ? book.cover : '');
|
||||||
|
const coverUrl = rawCoverUrl.replace(/\/(?:t6_|s_)/, '/t8_');
|
||||||
const coverClass = typeof book.cover === 'string' && !/^https?:\/\//i.test(book.cover) ? book.cover : '';
|
const coverClass = typeof book.cover === 'string' && !/^https?:\/\//i.test(book.cover) ? book.cover : '';
|
||||||
const fallback = mini
|
const fallback = mini
|
||||||
? `<div class="mini-cover ${coverClass}" aria-hidden="true"><b>${esc(book.title)}</b><small>${esc(book.author)}</small></div>`
|
? `<div class="mini-cover ${coverClass}" aria-hidden="true"><b>${esc(book.title)}</b><small>${esc(book.author)}</small></div>`
|
||||||
@@ -91,7 +94,7 @@ function cover(book, mini = false) {
|
|||||||
return `<img class="${className}" src="${esc(coverUrl)}" alt="${esc(book.title)}" loading="lazy" onerror='this.outerHTML=${JSON.stringify(fallback)}'>`;
|
return `<img class="${className}" src="${esc(coverUrl)}" alt="${esc(book.title)}" loading="lazy" onerror='this.outerHTML=${JSON.stringify(fallback)}'>`;
|
||||||
}
|
}
|
||||||
function scoreText(book) {
|
function scoreText(book) {
|
||||||
return book.score ? `评分 ${Math.round(Number(book.score) * 10)}/100` : '暂无评分';
|
return book.score ? `评分 ${(Number(book.score) * 10).toFixed(2)}%` : '暂无评分';
|
||||||
}
|
}
|
||||||
function stateText(book) {
|
function stateText(book) {
|
||||||
if (book.status === 'done') return '已读完';
|
if (book.status === 'done') return '已读完';
|
||||||
@@ -143,13 +146,35 @@ async function ensureShelf(force = false) {
|
|||||||
if (loadState.shelf === 'loading') return;
|
if (loadState.shelf === 'loading') return;
|
||||||
if (shelfData && !force) return;
|
if (shelfData && !force) return;
|
||||||
loadState.shelf = 'loading'; loadError.shelf = '';
|
loadState.shelf = 'loading'; loadError.shelf = '';
|
||||||
if (['shelf', 'overview', 'goals'].includes(page)) renderPage();
|
if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage();
|
||||||
try {
|
try {
|
||||||
const data = await WeApi.shelf('all', { progress: true });
|
// 首屏只读取书架基础数据,避免等待逐本补拉进度和评分。
|
||||||
|
const data = await WeApi.shelf('all', { progress: false });
|
||||||
shelfData = data;
|
shelfData = data;
|
||||||
books = data.books || [];
|
books = data.books || [];
|
||||||
booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` }));
|
booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` }));
|
||||||
loadState.shelf = 'ready';
|
loadState.shelf = 'ready';
|
||||||
|
const localDiscoverKey = `${searchScope}:books:${searchQuery}`;
|
||||||
|
if (!searchQuery) {
|
||||||
|
discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists };
|
||||||
|
loadState.discover[localDiscoverKey] = 'ready';
|
||||||
|
}
|
||||||
|
if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage();
|
||||||
|
|
||||||
|
// 后台补充评分和阅读进度,完成后静默刷新,不阻塞首屏。
|
||||||
|
WeApi.shelf('all', { progress: true }).then(enriched => {
|
||||||
|
shelfData = enriched;
|
||||||
|
books = enriched.books || books;
|
||||||
|
if (enriched.lists) {
|
||||||
|
booklists = enriched.lists.map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` }));
|
||||||
|
}
|
||||||
|
if (!searchQuery) {
|
||||||
|
discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists };
|
||||||
|
}
|
||||||
|
if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage();
|
||||||
|
}).catch(() => {
|
||||||
|
// 评分/进度补充失败时保留已展示的基础书架数据。
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.shelf = error.message; loadState.shelf = 'error';
|
loadError.shelf = error.message; loadState.shelf = 'error';
|
||||||
}
|
}
|
||||||
@@ -207,17 +232,17 @@ function discoverKey() {
|
|||||||
if (discoverTab === 'lists') return `${searchScope}:lists`;
|
if (discoverTab === 'lists') return `${searchScope}:lists`;
|
||||||
return `${searchScope}:${discoverTab}:${searchQuery}`;
|
return `${searchScope}:${discoverTab}:${searchQuery}`;
|
||||||
}
|
}
|
||||||
async function ensureDiscover(force = false) {
|
async function ensureDiscover(force = false, refresh = false) {
|
||||||
const key = discoverKey();
|
const key = discoverKey();
|
||||||
if (loadState.discover[key] === 'loading') return;
|
if (loadState.discover[key] === 'loading') return;
|
||||||
if (discoverData[key] && !force) return;
|
if (discoverData[key] && !force) return;
|
||||||
loadState.discover[key] = 'loading'; loadError.discover[key] = '';
|
loadState.discover[key] = 'loading'; loadError.discover[key] = '';
|
||||||
if (page === 'discover') renderPage();
|
if (page === 'discover') renderPage();
|
||||||
try {
|
try {
|
||||||
if (searchScope === 'shelf' || (!searchQuery && (discoverTab === 'books' || discoverTab === 'all'))) {
|
if (searchScope === 'shelf') {
|
||||||
discoverData[key] = { local: true, books: books, authors: [], lists: [] };
|
discoverData[key] = { local: true, books: books, authors: [], lists: [] };
|
||||||
} else if (discoverTab === 'recommend') {
|
} else if (discoverTab === 'recommend') {
|
||||||
discoverData[key] = await WeApi.recommend(0);
|
discoverData[key] = await WeApi.recommend(recommendOffset, refresh);
|
||||||
} else {
|
} else {
|
||||||
discoverData[key] = await WeApi.search(searchQuery || '文学', discoverTab);
|
discoverData[key] = await WeApi.search(searchQuery || '文学', discoverTab);
|
||||||
}
|
}
|
||||||
@@ -458,6 +483,11 @@ function renderDiscover() {
|
|||||||
const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']];
|
const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']];
|
||||||
if (!shelfData) ensureShelf();
|
if (!shelfData) ensureShelf();
|
||||||
const key = discoverKey();
|
const key = discoverKey();
|
||||||
|
// 默认书籍页直接使用已加载的本地书架,不等待远程书城搜索。
|
||||||
|
if (!discoverData[key] && !searchQuery && searchScope === 'shelf') {
|
||||||
|
discoverData[key] = { local: true, books, authors: [], lists: booklists };
|
||||||
|
loadState.discover[key] = 'ready';
|
||||||
|
}
|
||||||
const payload = discoverData[key];
|
const payload = discoverData[key];
|
||||||
let content = '';
|
let content = '';
|
||||||
if (loadError.discover[key]) content = statusBox('error', '暂时无法获取数据', loadError.discover[key], 'retry-discover');
|
if (loadError.discover[key]) content = statusBox('error', '暂时无法获取数据', loadError.discover[key], 'retry-discover');
|
||||||
@@ -465,10 +495,10 @@ function renderDiscover() {
|
|||||||
else if (discoverTab === 'recommend') {
|
else if (discoverTab === 'recommend') {
|
||||||
const list = payload.books || [];
|
const list = payload.books || [];
|
||||||
const prefer = (dashboardData[period] && dashboardData[period].prefer_words.category) || '你的阅读偏好';
|
const prefer = (dashboardData[period] && dashboardData[period].prefer_words.category) || '你的阅读偏好';
|
||||||
content = `<div class="discovery-hero"><div class="eyebrow">PICKED FOR YOUR NEXT CHAPTER</div><h2>读过的书,会带你遇见下一本。</h2><p>${esc(prefer)} · 微信读书实时推荐</p></div><div class="card panel"><div class="books full-books">${list.map(recommendCard).join('') || '<div class="empty">接口暂时没有返回推荐书籍。</div>'}</div></div>`;
|
content = `<div class="discovery-hero"><div class="section-head"><div><div class="eyebrow">PICKED FOR YOUR NEXT CHAPTER</div><h2>读过的书,会带你遇见下一本。</h2><p>${esc(prefer)} · 微信读书实时推荐</p></div><button class="secondary" data-action="refresh-recommend">换一批</button></div></div><div class="card panel"><div class="books full-books">${list.map(recommendCard).join('') || '<div class="empty">接口暂时没有返回推荐书籍。</div>'}</div></div>`;
|
||||||
} else if (discoverTab === 'lists') {
|
} else if (discoverTab === 'lists') {
|
||||||
const lists = (payload.lists && payload.lists.length) ? payload.lists : booklists;
|
const lists = searchScope === 'shelf' ? booklists : (payload.lists || []);
|
||||||
content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || '我的书单' }))) + (payload.lists && payload.lists.length ? '' : '<p class="hint section-space">书城书单搜索接口暂未返回结果,以上为你的书架书单。</p>');
|
content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || (searchScope === 'shelf' ? '我的书单' : '腾讯书城') }))) + (!lists.length && searchScope !== 'shelf' ? '<p class="hint section-space">腾讯书城返回了书单数量,但当前接口没有返回具体书单条目。</p>' : '');
|
||||||
} else if (discoverTab === 'author') {
|
} else if (discoverTab === 'author') {
|
||||||
const authors = payload.authors || [];
|
const authors = payload.authors || [];
|
||||||
content = `<div class="card panel">${authors.map(author => `<div class="book-row"><div class="avatar">${esc(author.name.slice(0, 1))}</div><div class="book-row-text"><b>${esc(author.name)}</b><p>作者 · 书城检索</p></div><button class="text-button" data-action="author-books" data-name="${esc(author.name)}">查看书籍 →</button></div>`).join('') || '<div class="empty">没有匹配的作者,换个关键词试试。</div>'}</div>`;
|
content = `<div class="card panel">${authors.map(author => `<div class="book-row"><div class="avatar">${esc(author.name.slice(0, 1))}</div><div class="book-row-text"><b>${esc(author.name)}</b><p>作者 · 书城检索</p></div><button class="text-button" data-action="author-books" data-name="${esc(author.name)}">查看书籍 →</button></div>`).join('') || '<div class="empty">没有匹配的作者,换个关键词试试。</div>'}</div>`;
|
||||||
@@ -480,7 +510,8 @@ function renderDiscover() {
|
|||||||
: `<div class="card empty">「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。</div>`;
|
: `<div class="card empty">「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。</div>`;
|
||||||
} else if (discoverTab === 'all') {
|
} else if (discoverTab === 'all') {
|
||||||
const list = payload.books || [];
|
const list = payload.books || [];
|
||||||
content = `<div class="section-head"><h2>书籍</h2></div><div class="card panel"><div class="books">${list.slice(0, 8).map(searchCard).join('') || '<div class="empty">没有匹配的书籍。</div>'}</div></div><div class="section-head section-space"><h2>书单</h2></div>${renderLists(booklists.slice(0, 3))}`;
|
const remoteLists = payload.lists || [];
|
||||||
|
content = `<div class="section-head"><h2>书籍</h2></div><div class="card panel"><div class="books">${list.slice(0, 8).map(searchCard).join('') || '<div class="empty">没有匹配的书籍。</div>'}</div></div><div class="section-head section-space"><h2>书单</h2></div>${renderLists(remoteLists)}${!remoteLists.length ? '<p class="hint section-space">腾讯书城未返回具体书单条目。</p>' : ''}`;
|
||||||
} else {
|
} else {
|
||||||
const list = payload.books || [];
|
const list = payload.books || [];
|
||||||
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
|
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
@@ -514,7 +545,7 @@ function renderBookDetail() {
|
|||||||
const progress = data.progress.progress;
|
const progress = data.progress.progress;
|
||||||
const state = progress >= 100 ? '已读完' : progress > 0 ? `已读 ${progress}%` : '尚未开始阅读';
|
const state = progress >= 100 ? '已读完' : progress > 0 ? `已读 ${progress}%` : '尚未开始阅读';
|
||||||
const readTimeText = data.progress.record_reading_time ? ` · 累计 ${esc(data.progress.record_reading_time_str)}` : '';
|
const readTimeText = data.progress.record_reading_time ? ` · 累计 ${esc(data.progress.record_reading_time_str)}` : '';
|
||||||
showDetail('书籍详情', `<div class="detail-body">${cover(info)}<div class="detail-info"><div class="eyebrow">READING SPACE / BOOK</div><h3>${esc(info.title)}</h3><p>${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}</p><p style="color:var(--green)">${info.rating ? `评分 ${Math.round(info.rating)}/100 · ${info.rating_count} 人评价` : '暂无评分'}</p><p>${state}${readTimeText}</p><button class="primary" data-action="add-book" data-id="${esc(info.id)}">+ 加入阅读目标</button>${info.deep_link ? ` <a class="secondary" style="text-decoration:none;display:inline-block;margin-top:8px" href="${esc(info.deep_link)}" target="_blank" rel="noreferrer">在微信读书中打开 ↗</a>` : ''}</div></div><div class="detail-tabs">${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => `<button data-action="book-tab" data-id="${tab}" class="${bookTab === tab ? 'active' : ''}">${label}</button>`).join('')}</div><div id="book-tab-content">${renderBookTab(data)}</div>`);
|
showDetail('书籍详情', `<div class="detail-body">${cover(info)}<div class="detail-info"><div class="eyebrow">READING SPACE / BOOK</div><h3>${esc(info.title)}</h3><p>${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}</p><p style="color:var(--green)">${info.rating ? `评分 ${(Number(info.rating) / 10).toFixed(2)}% · ${info.rating_count} 人评价` : '暂无评分'}</p><p>${state}${readTimeText}</p><button class="primary" data-action="add-book" data-id="${esc(info.id)}">+ 加入阅读目标</button>${info.deep_link ? ` <a class="secondary" style="text-decoration:none;display:inline-block;margin-top:8px" href="${esc(info.deep_link)}" target="_blank" rel="noreferrer">在微信读书中打开 ↗</a>` : ''}</div></div><div class="detail-tabs">${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => `<button data-action="book-tab" data-id="${tab}" class="${bookTab === tab ? 'active' : ''}">${label}</button>`).join('')}</div><div id="book-tab-content">${renderBookTab(data)}</div>`);
|
||||||
}
|
}
|
||||||
function renderBookTab(data) {
|
function renderBookTab(data) {
|
||||||
const info = data.book;
|
const info = data.book;
|
||||||
@@ -592,6 +623,7 @@ function handleAction(button) {
|
|||||||
case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break;
|
case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break;
|
||||||
case 'period': period = id; renderPage(); break;
|
case 'period': period = id; renderPage(); break;
|
||||||
case 'discover-tab': discoverTab = id; renderPage(); break;
|
case 'discover-tab': discoverTab = id; renderPage(); break;
|
||||||
|
case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break;
|
||||||
case 'retry-overview': ensureDashboard(period, true); break;
|
case 'retry-overview': ensureDashboard(period, true); break;
|
||||||
case 'retry-shelf': ensureShelf(true); break;
|
case 'retry-shelf': ensureShelf(true); break;
|
||||||
case 'retry-notes': if (!notebooksData) ensureNotebooks(true); else ensureNotes(noteBook, true); break;
|
case 'retry-notes': if (!notebooksData) ensureNotebooks(true); else ensureNotes(noteBook, true); break;
|
||||||
@@ -642,15 +674,37 @@ $('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value;
|
|||||||
$('#goal-name').oninput = () => $('#goal-name').setCustomValidity('');
|
$('#goal-name').oninput = () => $('#goal-name').setCustomValidity('');
|
||||||
$('#goal-form').onsubmit = saveGoal;
|
$('#goal-form').onsubmit = saveGoal;
|
||||||
$('#picker-search').oninput = renderPicker;
|
$('#picker-search').oninput = renderPicker;
|
||||||
$('#global-search').oninput = event => {
|
function applyGlobalSearch(value) {
|
||||||
searchQuery = event.target.value.trim();
|
searchQuery = value;
|
||||||
if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) discoverTab = 'books';
|
if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) discoverTab = 'books';
|
||||||
navigate('discover');
|
navigate('discover');
|
||||||
|
}
|
||||||
|
$('#global-search').addEventListener('compositionstart', () => { searchComposing = true; });
|
||||||
|
$('#global-search').addEventListener('compositionend', event => {
|
||||||
|
searchComposing = false;
|
||||||
|
applyGlobalSearch(event.target.value.trim());
|
||||||
|
});
|
||||||
|
$('#global-search').oninput = event => {
|
||||||
|
if (searchComposing || event.isComposing) return;
|
||||||
|
clearTimeout(discoverSearchTimer);
|
||||||
|
discoverSearchTimer = setTimeout(() => applyGlobalSearch(event.target.value.trim()), 300);
|
||||||
};
|
};
|
||||||
document.addEventListener('input', event => {
|
document.addEventListener('input', event => {
|
||||||
if (event.target.id === 'discover-search') {
|
if (event.target.id === 'discover-search') {
|
||||||
const position = event.target.selectionStart; searchQuery = event.target.value; $('#global-search').value = searchQuery; renderPage();
|
if (event.isComposing || searchComposing) return;
|
||||||
$('#discover-search').focus(); $('#discover-search').setSelectionRange(position, position);
|
const value = event.target.value;
|
||||||
|
$('#global-search').value = value;
|
||||||
|
clearTimeout(discoverSearchTimer);
|
||||||
|
discoverSearchTimer = setTimeout(() => applyGlobalSearch(value), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('compositionstart', event => {
|
||||||
|
if (event.target.id === 'discover-search') searchComposing = true;
|
||||||
|
});
|
||||||
|
document.addEventListener('compositionend', event => {
|
||||||
|
if (event.target.id === 'discover-search') {
|
||||||
|
searchComposing = false;
|
||||||
|
applyGlobalSearch(event.target.value.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); $('#global-search').focus(); } });
|
document.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); $('#global-search').focus(); } });
|
||||||
|
|||||||
+2
-2
@@ -112,8 +112,8 @@
|
|||||||
return request('/archives');
|
return request('/archives');
|
||||||
},
|
},
|
||||||
|
|
||||||
recommend(maxIdx) {
|
recommend(maxIdx, refresh = false) {
|
||||||
return request('/recommend', { maxIdx: maxIdx || 0 });
|
return request('/recommend', { maxIdx: maxIdx || 0, refresh: refresh ? 1 : 0 });
|
||||||
},
|
},
|
||||||
|
|
||||||
search(keyword, scope) {
|
search(keyword, scope) {
|
||||||
|
|||||||
Reference in new issue
Block a user