Files
WeReadBoard/static/js/app.js
T
admin 8b24052ab6 移除分类排序按钮,默认按分类排序展示
- 删除 index.html 中的 shelfSortBtn 按钮
- shelfSortMode 默认改为 category,始终按分类排序
- 删除 toggleShelfSort 函数和 .shelf-sort-btn CSS 样式
- 书架列表默认以分类分组渲染
2026-07-17 12:29:01 +08:00

1261 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===== 微信读书数据展示 - 前端逻辑 ===== */
const API_BASE = "";
// ===== 主题管理 =====
function getCurrentTheme() {
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
function toggleTheme() {
const newTheme = getCurrentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
refreshChartsForTheme();
}
function getChartColors() {
const cs = getComputedStyle(document.documentElement);
return {
axis: cs.getPropertyValue("--chart-axis").trim(),
axisLine: cs.getPropertyValue("--chart-axis-line").trim(),
splitLine: cs.getPropertyValue("--chart-split-line").trim(),
label: cs.getPropertyValue("--chart-label").trim(),
borderColor: cs.getPropertyValue("--chart-border-color").trim(),
tooltipBg: cs.getPropertyValue("--chart-tooltip-bg").trim(),
tooltipBorder: cs.getPropertyValue("--chart-tooltip-border").trim(),
tooltipText: cs.getPropertyValue("--chart-tooltip-text").trim(),
accent: cs.getPropertyValue("--accent").trim(),
accentLight: cs.getPropertyValue("--accent-light").trim(),
accentDark: cs.getPropertyValue("--accent-dark").trim(),
};
}
// ===== 工具函数 =====
/**
* 升级微信读书封面图 URL 到高清晰度版本。
* 微信读书 CDN 的封面图通过文件名前缀区分分辨率:
* t6_ = 低清缩略图(~150px,Retina 屏模糊)
* s_ = 超低清缩略图(~90px,搜索结果常用,更模糊)
* t8_ = 高清版(~500px,适合 2x/3x 屏幕)
* 将 t6_ 和 s_ 前缀统一升级为 t8_ 高清版。
*/
function hdCover(url) {
if (!url) return "";
return url.replace(/\/(t6_|s_)/, "/t8_");
}
async function fetchAPI(path) {
const resp = await fetch(API_BASE + path);
if (!resp.ok) throw new Error(`API error: ${resp.status}`);
return resp.json();
}
function formatHours(seconds) {
if (!seconds) return "0";
return (seconds / 3600).toFixed(1);
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// ===== 数据库健康检查 =====
const chartInstances = {};
let chartsLoaded = false;
let currentThoughtsData = null;
let currentThoughtsCategory = "all";
let currentThoughtsSort = "time_desc";
let currentThoughtsSearch = "";
function refreshChartsForTheme() {
if (!chartsLoaded) return;
Object.values(chartInstances).forEach((chart) => {
if (chart && !chart.isDisposed()) chart.dispose();
});
loadYearlyTrend();
loadTimeDistribution();
loadCategories();
loadAuthors();
}
async function checkDBHealth() {
try {
const resp = await fetch(API_BASE + "/api/health");
const data = await resp.json();
if (resp.ok && data.database === "connected") {
document.getElementById("dbAlertBanner").style.display = "none";
return true;
} else {
showDBAlert(data.message || "数据库连接失败");
return false;
}
} catch (error) {
showDBAlert("无法连接到服务器,请检查后端服务是否运行。");
return false;
}
}
function showDBAlert(message) {
const banner = document.getElementById("dbAlertBanner");
const detail = document.getElementById("dbAlertDetail");
if (banner) banner.style.display = "flex";
if (detail) detail.textContent = message;
}
// ===== 标签页切换 =====
function switchTab(tabName) {
// 如果想法详情页正在显示,先关闭它回到主视图
const thoughtsPage = document.getElementById("thoughtsPage");
if (thoughtsPage && thoughtsPage.style.display !== "none") {
thoughtsPage.style.display = "none";
document.getElementById("mainView").style.display = "";
currentThoughtsData = null;
}
// 关闭可能打开的弹窗
closeModal("bookDetailModal");
document.querySelectorAll(".tab-panel").forEach((panel) => {
panel.classList.remove("active");
});
document.querySelectorAll(".nav-link").forEach((link) => {
link.classList.toggle("active", link.dataset.tab === tabName);
});
const target = document.getElementById(`tab-${tabName}`);
if (target) {
target.classList.add("active");
}
window.scrollTo({ top: 0, behavior: "smooth" });
// 懒加载图表
if (tabName === "charts" && !chartsLoaded) {
chartsLoaded = true;
loadYearlyTrend();
loadTimeDistribution();
loadCategories();
loadAuthors();
}
// 切换到图表时重绘尺寸
if (tabName === "charts" && chartsLoaded) {
setTimeout(() => {
Object.values(chartInstances).forEach((chart) => {
if (chart && !chart.isDisposed()) chart.resize();
});
}, 100);
}
}
// ===== 回到顶部按钮 =====
function initBackToTop() {
const btn = document.getElementById("backToTop");
window.addEventListener("scroll", () => {
btn.classList.toggle("visible", window.scrollY > 400);
});
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
/**
* 在微信读书中打开书籍。
* 优先使用 API 返回的 deep_link(https://weread.qq.com/... 网页链接),
* 没有则用 book_id 构造网页链接。先尝试 weread:// 深链接唤起 App,
* 1.5 秒后无响应则回退到网页版。
*/
function openInWeRead(bookId, deepLink) {
const webUrl = deepLink || `https://weread.qq.com/#book/${bookId}`;
const schemeUrl = `weread://reading?bId=${bookId}`;
// 尝试唤起 App(移动端),同时设置定时器回退到网页版
let triggered = false;
const fallbackTimer = setTimeout(() => {
if (!triggered) window.open(webUrl, "_blank");
}, 1500);
// 监听页面可见性变化(App 唤起成功会切到后台)
const onVisChange = () => {
if (document.hidden) {
triggered = true;
clearTimeout(fallbackTimer);
}
};
document.addEventListener("visibilitychange", onVisChange);
// 尝试打开深链接
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = schemeUrl;
document.body.appendChild(iframe);
setTimeout(() => document.body.removeChild(iframe), 2000);
// 桌面端不会有 visibilitychange,确保 fallback 一定执行
setTimeout(() => {
document.removeEventListener("visibilitychange", onVisChange);
}, 3000);
}
// ===== 全局搜索(顶部常驻搜索框) =====
function globalSearch() {
const keyword = document.getElementById("globalSearchInput").value.trim();
if (!keyword) return;
// 切换到推荐标签页
switchTab("discover");
// 切换到搜索子标签
switchDiscoverTab("search");
// 填入搜索词并执行搜索
document.getElementById("searchInput").value = keyword;
doSearch();
}
// ===== 书架分类标签(可折叠层级结构) =====
let shelfCategoryTree = {}; // { "历史": {count:114, subs:{"世界史":5,...}}, ... }
let shelfActiveCategory = ""; // 完整分类名,如 "历史-中国古代"
let shelfActiveParent = ""; // 父分类名,如 "历史";空表示"全部"
let shelfExpandedParents = new Set(); // 当前展开的父分类
let shelfSortMode = "category"; // 始终按分类排序,前端不显示切换按钮
async function loadShelfCategories() {
try {
const data = await fetchAPI("/api/shelf?page=1&per_page=1");
shelfCategoryTree = data.category_tree || {};
renderShelfCategories();
} catch (e) {
console.error("加载分类失败:", e);
}
}
function renderShelfCategories() {
const container = document.getElementById("shelfCategories");
if (!container) return;
let html = "";
// "全部" 按钮
const allActive = !shelfActiveParent && !shelfActiveCategory;
html += `<button class="shelf-cat-parent ${allActive ? "active" : ""}" onclick="filterByParent('')">全部</button>`;
// 父分类按钮(按书籍数量降序)
const parents = Object.entries(shelfCategoryTree)
.sort((a, b) => b[1].count - a[1].count);
for (const [parent, info] of parents) {
const isActive = shelfActiveParent === parent && !shelfActiveCategory;
const isExpanded = shelfExpandedParents.has(parent);
const arrow = Object.keys(info.subs).length > 0
? `<span class="shelf-cat-arrow ${isExpanded ? "expanded" : ""}">${isExpanded ? "▼" : "▶"}</span>`
: "";
html += `<button class="shelf-cat-parent ${isActive ? "active" : ""}"
onclick="filterByParent('${parent.replace(/'/g, "\\'")}')">${parent}
<span class="shelf-cat-count">${info.count}</span>${arrow}
</button>`;
// 展开的子分类
if (isExpanded && Object.keys(info.subs).length > 0) {
html += `<div class="shelf-cat-subs">`;
for (const [sub, count] of Object.entries(info.subs).sort((a, b) => b[1] - a[1])) {
const fullCat = `${parent}-${sub}`;
const subActive = shelfActiveCategory === fullCat;
html += `<button class="shelf-cat-sub ${subActive ? "active" : ""}"
onclick="event.stopPropagation();filterByCategory('${fullCat.replace(/'/g, "\\'")}')">${sub}
<span class="shelf-cat-count-sm">${count}</span>
</button>`;
}
html += `</div>`;
}
}
container.innerHTML = html;
}
function filterByParent(parent) {
if (parent) {
// 点击父分类:展开/折叠子分类,同时筛选该父分类下的所有书
if (shelfExpandedParents.has(parent) && shelfActiveParent === parent && !shelfActiveCategory) {
// 已展开且已选中 → 折叠
shelfExpandedParents.delete(parent);
shelfActiveParent = "";
shelfActiveCategory = "";
} else {
// 展开 + 筛选
shelfExpandedParents.add(parent);
shelfActiveParent = parent;
shelfActiveCategory = "";
}
} else {
// 点击"全部"
shelfExpandedParents.clear();
shelfActiveParent = "";
shelfActiveCategory = "";
}
renderShelfCategories();
loadShelf(1);
}
function filterByCategory(fullCategory) {
shelfActiveCategory = fullCategory;
// 确保父分类已展开
const parent = fullCategory.split("-")[0];
shelfExpandedParents.add(parent);
shelfActiveParent = parent;
renderShelfCategories();
loadShelf(1);
}
// ===== 初始化 =====
document.addEventListener("DOMContentLoaded", async () => {
const isHealthy = await checkDBHealth();
if (!isHealthy) return;
loadOverview();
loadTopBooks();
loadShelfCategories();
loadShelf(1);
loadNotebooks();
loadRecommend(0);
initBackToTop();
// 搜索和筛选事件
document.getElementById("shelfSearch").addEventListener("input", debounce(() => loadShelf(1), 300));
document.getElementById("finishedOnly").addEventListener("change", () => loadShelf(1));
});
// ===== 总览数据 =====
async function loadOverview() {
try {
const data = await fetchAPI("/api/overview");
const heroStats = document.getElementById("heroStats");
heroStats.innerHTML = `
<div class="hero-stat">
<div class="hero-stat-value">${formatHours(data.total_read_time)}</div>
<div class="hero-stat-label">累计阅读 (小时)</div>
</div>
<div class="hero-stat">
<div class="hero-stat-value">${data.read_days}</div>
<div class="hero-stat-label">阅读天数</div>
</div>
<div class="hero-stat">
<div class="hero-stat-value">${data.shelf_count}</div>
<div class="hero-stat-label">书架藏书</div>
</div>
<div class="hero-stat">
<div class="hero-stat-value">${data.total_notes}</div>
<div class="hero-stat-label">读书笔记</div>
</div>
`;
document.getElementById("heroSubtitle").textContent =
`自 ${data.regist_time} 加入微信读书,已陪伴你 ${data.read_days} 个阅读的日子`;
const statsGrid = document.getElementById("statsGrid");
statsGrid.innerHTML = `
<div class="stat-card">
<div class="stat-card-icon">⏱️</div>
<div class="stat-card-value">${data.total_read_time_str}</div>
<div class="stat-card-label">累计阅读时长</div>
<div class="stat-card-sub">今年: ${data.year_read_time_str}</div>
</div>
<div class="stat-card">
<div class="stat-card-icon">📅</div>
<div class="stat-card-value">${data.read_days}</div>
<div class="stat-card-label">累计阅读天数</div>
<div class="stat-card-sub">今年: ${data.year_read_days} 天</div>
</div>
<div class="stat-card">
<div class="stat-card-icon">📚</div>
<div class="stat-card-value">${data.shelf_count}</div>
<div class="stat-card-label">书架书籍</div>
<div class="stat-card-sub">已读完: ${data.finished_count} 本</div>
</div>
<div class="stat-card">
<div class="stat-card-icon">✍️</div>
<div class="stat-card-value">${data.total_notes}</div>
<div class="stat-card-label">读书笔记</div>
<div class="stat-card-sub">来自 ${data.notebook_count} 本书</div>
</div>
`;
const timeStr = `数据更新: ${data.fetch_time}`;
document.getElementById("fetchTime").textContent = timeStr;
document.getElementById("footerTime").textContent = data.fetch_time;
} catch (e) {
console.error("加载总览失败:", e);
}
}
// ===== 年度趋势图 =====
async function loadYearlyTrend() {
try {
const data = await fetchAPI("/api/yearly-trend");
const el = document.getElementById("chart-yearly");
if (chartInstances.yearly) chartInstances.yearly.dispose();
const chart = echarts.init(el);
chartInstances.yearly = chart;
const c = getChartColors();
const years = data.map((d) => d.year);
const hours = data.map((d) => d.hours);
chart.setOption({
tooltip: {
trigger: "axis",
backgroundColor: c.tooltipBg,
borderColor: c.tooltipBorder,
textStyle: { color: c.tooltipText },
formatter: (params) => {
const p = params[0];
return `${p.name}年<br/>阅读: <b>${p.value}</b> 小时`;
},
},
grid: { left: 50, right: 30, top: 30, bottom: 40 },
xAxis: {
type: "category",
data: years,
axisLabel: { color: c.axis },
axisLine: { lineStyle: { color: c.axisLine } },
},
yAxis: {
type: "value",
name: "小时",
nameTextStyle: { color: c.axis },
axisLabel: { color: c.axis },
splitLine: { lineStyle: { color: c.splitLine } },
},
series: [{
data: hours,
type: "bar",
barWidth: "50%",
itemStyle: {
borderRadius: [8, 8, 0, 0],
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent },
] },
},
label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" },
}],
});
window.addEventListener("resize", () => chart.resize());
} catch (e) {
console.error("加载年度趋势失败:", e);
}
}
// ===== 24小时时段分布 =====
async function loadTimeDistribution() {
try {
const data = await fetchAPI("/api/time-distribution");
const el = document.getElementById("chart-hours");
if (chartInstances.hours) chartInstances.hours.dispose();
const chart = echarts.init(el);
chartInstances.hours = chart;
const c = getChartColors();
const hours = data.map((d) => `${d.hour}时`);
const minutes = data.map((d) => d.duration_min);
chart.setOption({
tooltip: {
trigger: "axis",
backgroundColor: c.tooltipBg,
borderColor: c.tooltipBorder,
textStyle: { color: c.tooltipText },
formatter: (params) => {
const p = params[0];
return `${p.name}<br/>阅读: <b>${p.value}</b> 分钟`;
},
},
grid: { left: 50, right: 20, top: 20, bottom: 40 },
xAxis: {
type: "category",
data: hours,
axisLabel: { color: c.axis, fontSize: 10, interval: 2 },
axisLine: { lineStyle: { color: c.axisLine } },
},
yAxis: {
type: "value",
name: "分钟",
nameTextStyle: { color: c.axis },
axisLabel: { color: c.axis },
splitLine: { lineStyle: { color: c.splitLine } },
},
series: [{
data: minutes,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 4,
lineStyle: { width: 2, color: c.accent },
itemStyle: { color: c.accent },
areaStyle: {
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: c.accent + "4D" },
{ offset: 1, color: c.accent + "05" },
] },
},
}],
});
window.addEventListener("resize", () => chart.resize());
} catch (e) {
console.error("加载时段分布失败:", e);
}
}
// ===== 偏好分类 =====
async function loadCategories() {
try {
const data = await fetchAPI("/api/categories");
const el = document.getElementById("chart-categories");
if (chartInstances.categories) chartInstances.categories.dispose();
const chart = echarts.init(el);
chartInstances.categories = chart;
const c = getChartColors();
const items = data.slice(0, 8).map((d) => ({
name: d.category_title,
value: d.reading_time,
}));
chart.setOption({
tooltip: {
trigger: "item",
backgroundColor: c.tooltipBg,
borderColor: c.tooltipBorder,
textStyle: { color: c.tooltipText },
formatter: (p) => {
const hours = (p.value / 3600).toFixed(1);
return `${p.name}<br/>阅读: <b>${hours}</b> 小时 (${p.percent}%)`;
},
},
legend: {
type: "scroll",
orient: "vertical",
right: 10,
top: 20,
bottom: 20,
textStyle: { color: c.label, fontSize: 11 },
},
series: [{
type: "pie",
radius: ["40%", "70%"],
center: ["35%", "50%"],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: c.borderColor, borderWidth: 2 },
label: { show: false },
emphasis: {
label: { show: true, fontSize: 14, fontWeight: "bold", color: c.tooltipText },
},
data: items,
color: ["#c17b4a", "#e8a87c", "#d4a574", "#b8860b", "#cd853f", "#daa520", "#bc8f8f", "#bdb76b"],
}],
});
window.addEventListener("resize", () => chart.resize());
} catch (e) {
console.error("加载分类失败:", e);
}
}
// ===== 偏好作者 =====
async function loadAuthors() {
try {
const data = await fetchAPI("/api/authors");
const el = document.getElementById("chart-authors");
if (chartInstances.authors) chartInstances.authors.dispose();
const chart = echarts.init(el);
chartInstances.authors = chart;
const c = getChartColors();
const top10 = data.slice(0, 10);
const names = top10.map((d) => d.name);
const counts = top10.map((d) => d.count);
chart.setOption({
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
backgroundColor: c.tooltipBg,
borderColor: c.tooltipBorder,
textStyle: { color: c.tooltipText },
formatter: (params) => {
const p = params[0];
return `${p.name}<br/>阅读: <b>${p.value}</b> 本`;
},
},
grid: { left: 100, right: 40, top: 20, bottom: 20 },
xAxis: {
type: "value",
axisLabel: { color: c.axis },
splitLine: { lineStyle: { color: c.splitLine } },
},
yAxis: {
type: "category",
data: names.reverse(),
axisLabel: { color: c.axis, fontSize: 12 },
axisLine: { lineStyle: { color: c.axisLine } },
},
series: [{
type: "bar",
data: counts.reverse(),
barWidth: "50%",
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: { type: "linear", x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent },
] },
},
label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" },
}],
});
window.addEventListener("resize", () => chart.resize());
} catch (e) {
console.error("加载作者失败:", e);
}
}
// ===== 读书排行(竖向列表) =====
async function loadTopBooks() {
try {
const data = await fetchAPI("/api/top-books");
const list = document.getElementById("topBooksGrid");
list.innerHTML = data
.map((book, i) => {
const tagsHtml = book.tags
.map((t) => `<span class="top-book-tag">${t}</span>`)
.join("");
return `
<div class="top-book-row" onclick="openBook('${book.book_id}')">
<span class="top-book-rank-num">${i + 1}</span>
<img class="top-book-mini-cover" src="${hdCover(book.cover)}" alt="${book.title}"
loading="lazy"
onerror="this.onerror=null;this.src='${book.cover}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2264%22%3E%3Crect fill=%22%23e8e0d4%22 width=%2248%22 height=%2264%22/%3E%3C/svg%3E'}">
<div class="top-book-row-info">
<div class="top-book-row-title">${book.title}</div>
<div class="top-book-row-meta">
<span>${book.author}</span>
${tagsHtml ? `<div class="top-book-row-tags">${tagsHtml}</div>` : ""}
</div>
</div>
<div class="top-book-row-time">⏱ ${book.read_time_str}</div>
</div>`;
})
.join("");
} catch (e) {
console.error("加载排行失败:", e);
}
}
// ===== 书架 =====
let shelfCurrentPage = 1;
async function loadShelf(page) {
shelfCurrentPage = page;
const keyword = document.getElementById("shelfSearch").value.trim();
const finished = document.getElementById("finishedOnly").checked ? "1" : "0";
const params = new URLSearchParams({ page: page, per_page: 60, finished: finished });
if (keyword) params.set("keyword", keyword);
if (shelfActiveCategory) {
params.set("category", shelfActiveCategory);
} else if (shelfActiveParent) {
params.set("parent_category", shelfActiveParent);
}
if (shelfSortMode === "category") params.set("sort", "category");
try {
const data = await fetchAPI(`/api/shelf?${params}`);
const grid = document.getElementById("bookGrid");
document.getElementById("shelfInfo").textContent =
`共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`;
if (shelfSortMode === "category" && data.books.length > 0) {
// 按分类分组渲染
grid.innerHTML = renderBooksGroupedByCategory(data.books);
} else {
grid.innerHTML = data.books
.map((book) => renderBookCard(book))
.join("");
}
renderPagination(page, data.total_pages);
} catch (e) {
console.error("加载书架失败:", e);
}
}
function renderBookCard(book) {
const badges = [];
if (book.is_top) badges.push(`<span class="book-top-badge">置顶</span>`);
if (book.finish_reading) badges.push(`<span class="book-finished-badge">已读完</span>`);
return `
<div class="book-card" onclick="openBook('${book.book_id}')">
<div class="book-cover-wrapper">
<img class="book-cover-img" src="${hdCover(book.cover)}" alt="${book.title}"
loading="lazy"
onerror="this.onerror=null;this.src='${book.cover}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22160%22%3E%3Crect fill=%22%23e8e0d4%22 width=%22120%22 height=%22160%22/%3E%3Ctext x=%2260%22 y=%2280%22 text-anchor=%22middle%22 fill=%22%239b8e80%22 font-size=%2212%22%3E无封面%3C/text%3E%3C/svg%3E'}">
${badges.join("")}
</div>
<div class="book-title">${book.title}</div>
<div class="book-author">${book.author}</div>
</div>`;
}
function renderBooksGroupedByCategory(books) {
// 按 category 分组
const groups = {};
books.forEach((book) => {
const cat = book.category || "未分类";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(book);
});
let html = "";
for (const [cat, items] of Object.entries(groups)) {
html += `<div class="shelf-group-header">${cat} <span class="shelf-group-count">${items.length} 本</span></div>`;
html += `<div class="book-grid">${items.map(renderBookCard).join("")}</div>`;
}
return html;
}
function renderPagination(current, total) {
const pag = document.getElementById("pagination");
if (total <= 1) { pag.innerHTML = ""; return; }
let html = `<button class="page-btn" ${current <= 1 ? "disabled" : ""} onclick="loadShelf(${current - 1})">上一页</button>`;
const maxButtons = 7;
let start = Math.max(1, current - 3);
let end = Math.min(total, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
if (start > 1) {
html += `<button class="page-btn" onclick="loadShelf(1)">1</button>`;
if (start > 2) html += `<span class="page-info">...</span>`;
}
for (let i = start; i <= end; i++) {
html += `<button class="page-btn ${i === current ? "active" : ""}" onclick="loadShelf(${i})">${i}</button>`;
}
if (end < total) {
if (end < total - 1) html += `<span class="page-info">...</span>`;
html += `<button class="page-btn" onclick="loadShelf(${total})">${total}</button>`;
}
html += `<button class="page-btn" ${current >= total ? "disabled" : ""} onclick="loadShelf(${current + 1})">下一页</button>`;
pag.innerHTML = html;
}
function debounce(fn, delay) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
}
// ===== 笔记概览 =====
async function loadNotebooks() {
try {
const data = await fetchAPI("/api/notebooks");
const summary = document.getElementById("notesSummary");
const totalHighlights = data.reduce((s, d) => s + d.note_count, 0);
const totalReviews = data.reduce((s, d) => s + d.review_count, 0);
const totalBookmarks = data.reduce((s, d) => s + d.bookmark_count, 0);
summary.innerHTML = `
<div class="note-stat">
<div class="note-stat-value">${data.length}</div>
<div class="note-stat-label">有笔记的书</div>
</div>
<div class="note-stat">
<div class="note-stat-value">${totalHighlights}</div>
<div class="note-stat-label">划线</div>
</div>
<div class="note-stat">
<div class="note-stat-value">${totalReviews}</div>
<div class="note-stat-label">想法/点评</div>
</div>
<div class="note-stat">
<div class="note-stat-value">${totalBookmarks}</div>
<div class="note-stat-label">书签</div>
</div>
`;
const grid = document.getElementById("notebooksGrid");
grid.innerHTML = data
.map((nb) => {
const progress = nb.reading_progress || 0;
return `
<div class="notebook-card" onclick="openThoughtsPage('${nb.book_id}', '${nb.title.replace(/'/g, "\\'")}', '${(nb.author || '').replace(/'/g, "\\'")}', '${nb.cover}')" style="cursor:pointer">
<img class="notebook-cover" src="${hdCover(nb.cover)}" alt="${nb.title}"
loading="lazy"
onerror="this.onerror=null;this.src='${nb.cover}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2260%22 height=%2280%22%3E%3Crect fill=%22%23e8e0d4%22 width=%2260%22 height=%2280%22/%3E%3C/svg%3E'}">
<div class="notebook-info">
<div class="notebook-title">${nb.title}</div>
<div class="notebook-author">${nb.author}</div>
<div class="notebook-stats">
<span class="notebook-stat-item">划线 <strong>${nb.note_count}</strong></span>
<span class="notebook-stat-item">想法 <strong>${nb.review_count}</strong></span>
<span class="notebook-stat-item">书签 <strong>${nb.bookmark_count}</strong></span>
</div>
<div class="notebook-progress">
<div class="notebook-progress-bar" style="width: ${progress}%"></div>
</div>
</div>
</div>`;
})
.join("");
} catch (e) {
console.error("加载笔记失败:", e);
}
}
// ===== 书籍详情弹窗 =====
function openBook(bookId) {
if (!bookId) return;
showModal("bookDetailModal");
const body = document.getElementById("bookDetailBody");
body.innerHTML = `<div class="loading"><div class="loading-spinner"></div></div>`;
fetchAPI(`/api/book/detail?book_id=${bookId}`)
.then((data) => {
const p = data.progress || {};
const progress = p.progress || 0;
const rating = data.new_rating > 0 ? (data.new_rating / 10).toFixed(1) : null;
const tags = [];
if (data.category) tags.push(data.category);
if (rating) tags.push(`${rating}分`);
if (data.new_rating_count) tags.push(`${data.new_rating_count}人评分`);
body.innerHTML = `
<div class="detail-header">
<img class="detail-cover" src="${hdCover(data.cover || '')}" alt="${data.title}"
onerror="this.onerror=null;this.src='${data.cover || ''}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22160%22%3E%3Crect fill=%22%23e8e0d4%22 width=%22120%22 height=%22160%22/%3E%3C/svg%3E'}">
<div class="detail-meta">
<div class="detail-title">${data.title}</div>
<div class="detail-author">${data.author || ''}${data.translator ? ' / 译: ' + data.translator : ''}</div>
<div class="detail-tags">${tags.map(t => `<span class="detail-tag">${t}</span>`).join('')}</div>
${p.is_start_reading ? `
<div class="detail-progress-info">
<div style="font-size:13px;color:var(--text-secondary)">
阅读进度: <strong>${p.progress}%</strong>
${p.record_reading_time_str ? ' · 累计 ' + p.record_reading_time_str : ''}
${p.finish_time ? ' · 读完于 ' + p.finish_time : ''}
</div>
<div class="detail-progress-bar">
<div class="detail-progress-fill" style="width:${progress}%"></div>
</div>
</div>` : '<div style="font-size:13px;color:var(--text-light);margin-top:8px">尚未开始阅读</div>'}
</div>
</div>
${data.intro ? `<div class="detail-intro">${data.intro}</div>` : ''}
<div class="detail-info-grid">
${data.publisher ? `<div class="detail-info-item"><strong>出版社:</strong> ${data.publisher}</div>` : ''}
${data.publish_time ? `<div class="detail-info-item"><strong>出版时间:</strong> ${data.publish_time}</div>` : ''}
${data.isbn ? `<div class="detail-info-item"><strong>ISBN:</strong> ${data.isbn}</div>` : ''}
${data.word_count ? `<div class="detail-info-item"><strong>字数:</strong> ${(data.word_count / 10000).toFixed(1)}万字</div>` : ''}
</div>
<div class="detail-actions">
<button class="btn-primary" onclick="openInWeRead('${bookId}', ${data.deep_link ? `'${data.deep_link}'` : 'null'})">在微信读书中打开</button>
<button class="btn-primary detail-action-thoughts" onclick="closeModal('bookDetailModal');openThoughtsPage('${bookId}', '${(data.title || '').replace(/'/g, "\\'")}', '${(data.author || '').replace(/'/g, "\\'")}', '${data.cover || ''}')">📖 查看想法</button>
</div>
`;
})
.catch((e) => {
body.innerHTML = `<div class="empty-state">加载失败: ${e.message}</div>`;
});
}
// ===== 想法详情独立页面 =====
function openThoughtsPage(bookId, title, author, cover) {
if (!bookId) return;
// 隐藏主视图,显示想法页面
document.getElementById("mainView").style.display = "none";
document.getElementById("thoughtsPage").style.display = "block";
window.scrollTo({ top: 0, behavior: "auto" });
// 渲染头部
const header = document.getElementById("thoughtsBookHeader");
header.innerHTML = `
<img class="thoughts-book-cover" src="${hdCover(cover || '')}" alt="${title}"
onerror="this.onerror=null;this.src='${cover || ''}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2264%22 height=%2285%22%3E%3Crect fill=%22%23e8e0d4%22 width=%2264%22 height=%2285%22/%3E%3C/svg%3E'}">
<div>
<div class="thoughts-book-title">${title}</div>
<div class="thoughts-book-author">${author || ''}</div>
<div class="thoughts-book-stats" id="thoughtsBookStats">加载中...</div>
</div>
`;
const content = document.getElementById("thoughtsContent");
content.innerHTML = `<div class="loading"><div class="loading-spinner"></div></div>`;
fetchAPI(`/api/book/notes?book_id=${bookId}`)
.then((data) => {
currentThoughtsData = data;
renderThoughtsPage(data);
})
.catch((e) => {
content.innerHTML = `<div class="thoughts-empty">加载失败: ${e.message}</div>`;
});
}
function closeThoughtsPage() {
document.getElementById("thoughtsPage").style.display = "none";
document.getElementById("mainView").style.display = "";
currentThoughtsData = null;
}
function renderThoughtsPage(data) {
// 更新统计
const stats = document.getElementById("thoughtsBookStats");
// 分类:划线 / 书评 / 想法
const highlights = data.highlights || [];
const bookReviews = (data.reviews || []).filter((r) => r.is_finish);
const thoughts = (data.reviews || []).filter((r) => !r.is_finish);
stats.innerHTML = `
<span>划线 <strong>${highlights.length}</strong></span>
<span>书评 <strong>${bookReviews.length}</strong></span>
<span>想法 <strong>${thoughts.length}</strong></span>
`;
const content = document.getElementById("thoughtsContent");
if (highlights.length === 0 && bookReviews.length === 0 && thoughts.length === 0) {
content.innerHTML = `<div class="thoughts-empty">暂无笔记数据</div>`;
return;
}
let html = `
<div class="thoughts-toolbar">
<div class="thoughts-filters">
<button class="thought-filter-btn active" onclick="filterThoughts('all')">全部</button>
<button class="thought-filter-btn" onclick="filterThoughts('highlight')">
📌 划线 <span class="filter-count">${highlights.length}</span>
</button>
<button class="thought-filter-btn" onclick="filterThoughts('review')">
⭐ 书评 <span class="filter-count">${bookReviews.length}</span>
</button>
<button class="thought-filter-btn" onclick="filterThoughts('thought')">
💭 想法 <span class="filter-count">${thoughts.length}</span>
</button>
</div>
<div class="thoughts-controls">
<input type="text" id="thoughtsSearchInput" class="thoughts-search-input"
placeholder="搜索划线、书评、想法内容..."
oninput="onThoughtsSearch(this.value)">
<select id="thoughtsSortSelect" class="thoughts-sort-select" onchange="onThoughtsSort(this.value)">
<option value="time_desc">最新优先</option>
<option value="time_asc">最早优先</option>
<option value="chapter">按章节顺序</option>
</select>
</div>
</div>
<div id="thoughtsList"></div>
`;
content.innerHTML = html;
currentThoughtsCategory = "all";
currentThoughtsSort = "time_desc";
currentThoughtsSearch = "";
renderThoughtsList("all");
}
function filterThoughts(category) {
document.querySelectorAll(".thought-filter-btn").forEach((btn) => {
btn.classList.remove("active");
});
event.target.closest(".thought-filter-btn").classList.add("active");
currentThoughtsCategory = category;
renderThoughtsList(category);
}
function onThoughtsSearch(val) {
currentThoughtsSearch = val.trim().toLowerCase();
renderThoughtsList(currentThoughtsCategory);
}
function onThoughtsSort(val) {
currentThoughtsSort = val;
renderThoughtsList(currentThoughtsCategory);
}
/** 解析日期字符串为时间戳,无效则返回 0 */
function parseDate(str) {
if (!str) return 0;
const d = new Date(str.replace(/-/g, "/"));
return isNaN(d.getTime()) ? 0 : d.getTime();
}
/** 对数组按排序模式排序,返回新数组 */
function sortByMode(arr) {
const sorted = [...arr];
if (currentThoughtsSort === "time_desc") {
sorted.sort((a, b) => parseDate(b.create_time) - parseDate(a.create_time));
} else if (currentThoughtsSort === "time_asc") {
sorted.sort((a, b) => parseDate(a.create_time) - parseDate(b.create_time));
} else if (currentThoughtsSort === "chapter") {
sorted.sort((a, b) => {
const ua = parseInt(a.chapter_uid) || 0;
const ub = parseInt(b.chapter_uid) || 0;
if (ua !== ub) return ua - ub;
// 同章节内按 range 起始位置排序
const ra = a.range ? parseInt(a.range.split("-")[0]) : 0;
const rb = b.range ? parseInt(b.range.split("-")[0]) : 0;
return ra - rb;
});
}
return sorted;
}
/** 过滤搜索关键词 */
function matchSearch(text) {
if (!currentThoughtsSearch) return true;
return (text || "").toLowerCase().includes(currentThoughtsSearch);
}
function renderThoughtsList(category) {
const data = currentThoughtsData;
if (!data) return;
// 原始分类
let highlights = data.highlights || [];
let bookReviews = (data.reviews || []).filter((r) => r.is_finish);
let thoughts = (data.reviews || []).filter((r) => !r.is_finish);
// 搜索过滤
highlights = highlights.filter((h) => matchSearch(h.mark_text));
bookReviews = bookReviews.filter((r) => matchSearch(r.content) || matchSearch(r.abstract));
thoughts = thoughts.filter((r) => matchSearch(r.content) || matchSearch(r.abstract));
// 排序
highlights = sortByMode(highlights);
bookReviews = sortByMode(bookReviews);
thoughts = sortByMode(thoughts);
const list = document.getElementById("thoughtsList");
let html = "";
// 划线区
if (category === "all" || category === "highlight") {
if (highlights.length > 0) {
html += `<div class="thoughts-section-title">📌 划线 <span class="section-badge badge-highlight">${highlights.length}</span></div>`;
if (currentThoughtsSort === "chapter") {
// 按章节分组展示
const grouped = {};
highlights.forEach((h) => {
const ch = h.chapter_title || "未知章节";
if (!grouped[ch]) grouped[ch] = [];
grouped[ch].push(h);
});
for (const [chTitle, items] of Object.entries(grouped)) {
html += `<div class="thoughts-chapter-group">`;
html += `<div class="thoughts-chapter-title">${chTitle}</div>`;
items.forEach((h) => {
html += `
<div class="thought-highlight-item">
${escapeHtml(h.mark_text)}
<div class="thought-highlight-meta">
${h.chapter_title ? `<span>${h.chapter_title}</span>` : ""}
${h.create_time ? `<span>${h.create_time}</span>` : ""}
</div>
</div>
`;
});
html += `</div>`;
}
} else {
// 按时间排序,平铺展示(标注章节)
highlights.forEach((h) => {
html += `
<div class="thought-highlight-item">
${escapeHtml(h.mark_text)}
<div class="thought-highlight-meta">
${h.chapter_title ? `<span>${h.chapter_title}</span>` : ""}
${h.create_time ? `<span>${h.create_time}</span>` : ""}
</div>
</div>
`;
});
}
}
}
// 书评区
if (category === "all" || category === "review") {
if (bookReviews.length > 0) {
html += `<div class="thoughts-section-title">⭐ 书评 <span class="section-badge badge-review">${bookReviews.length}</span></div>`;
bookReviews.forEach((r) => {
const starVal = Math.max(0, Math.min(5, parseInt(r.star) || 0));
const stars = starVal > 0 ? `<div class="thought-review-stars">${"★".repeat(starVal)}${"☆".repeat(5 - starVal)}</div>` : "";
html += `
<div class="thought-review-item">
${stars}
${escapeHtml(r.content)}
<div class="thought-review-meta">
${r.chapter_name ? `<span>${r.chapter_name}</span>` : ""}
<span>${r.create_time || ""}</span>
</div>
</div>
`;
});
}
}
// 想法区
if (category === "all" || category === "thought") {
if (thoughts.length > 0) {
html += `<div class="thoughts-section-title">💭 想法 <span class="section-badge badge-thought">${thoughts.length}</span></div>`;
thoughts.forEach((r) => {
const abstract = r.abstract ? `<div class="thought-thought-abstract">${escapeHtml(r.abstract)}</div>` : "";
html += `
<div class="thought-thought-item">
${abstract}
<div class="thought-thought-content">${escapeHtml(r.content)}</div>
<div class="thought-thought-meta">
${r.chapter_name ? `<span>${r.chapter_name}</span>` : ""}
<span>${r.create_time || ""}</span>
</div>
</div>
`;
});
}
}
if (!html) {
if (currentThoughtsSearch) {
html = `<div class="thoughts-empty">未找到包含"${escapeHtml(currentThoughtsSearch)}"的内容</div>`;
} else {
html = `<div class="thoughts-empty">该分类暂无内容</div>`;
}
}
list.innerHTML = html;
}
// ===== 弹窗控制 =====
function showModal(id) {
document.getElementById(id).classList.add("active");
document.body.style.overflow = "hidden";
}
function closeModal(id) {
document.getElementById(id).classList.remove("active");
document.body.style.overflow = "";
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeModal("bookDetailModal");
if (document.getElementById("thoughtsPage").style.display !== "none") {
closeThoughtsPage();
}
}
});
// ===== 推荐发现 =====
let recommendMaxIdx = 0;
function switchDiscoverTab(tab) {
const recPanel = document.getElementById("recommendPanel");
const searchPanel = document.getElementById("searchPanel");
const tabRec = document.getElementById("tabRecommend");
const tabSearch = document.getElementById("tabSearch");
if (tab === "recommend") {
recPanel.style.display = "";
searchPanel.style.display = "none";
tabRec.classList.add("active");
tabSearch.classList.remove("active");
} else {
recPanel.style.display = "none";
searchPanel.style.display = "";
tabRec.classList.remove("active");
tabSearch.classList.add("active");
}
}
async function loadRecommend(maxIdx) {
const grid = document.getElementById("recommendGrid");
grid.innerHTML = `<div class="loading"><div class="loading-spinner"></div></div>`;
try {
const data = await fetchAPI(`/api/recommend?maxIdx=${maxIdx}`);
recommendMaxIdx = data.last_search_idx || 0;
renderDiscoverGrid(grid, data.books, true);
} catch (e) {
grid.innerHTML = `<div class="empty-state">加载推荐失败: ${e.message}</div>`;
}
}
async function loadMoreRecommend() {
loadRecommend(recommendMaxIdx);
}
async function doSearch() {
const keyword = document.getElementById("searchInput").value.trim();
if (!keyword) return;
const grid = document.getElementById("searchGrid");
grid.innerHTML = `<div class="loading"><div class="loading-spinner"></div></div>`;
try {
const data = await fetchAPI(`/api/search?keyword=${encodeURIComponent(keyword)}`);
if (data.books.length === 0) {
grid.innerHTML = `<div class="empty-state">未找到相关书籍</div>`;
return;
}
renderDiscoverGrid(grid, data.books, false);
} catch (e) {
grid.innerHTML = `<div class="empty-state">搜索失败: ${e.message}</div>`;
}
}
function renderDiscoverGrid(grid, books, showReason) {
grid.innerHTML = books
.map((book) => {
const rating = book.new_rating > 0 ? (book.new_rating / 10).toFixed(1) : null;
const ratingLabel = book.rating_title ? `<span class="discover-rating-label">${book.rating_title}</span>` : "";
const ratingHtml = rating ? `<span class="discover-rating">${rating}</span>` : "";
const readingHtml = book.reading_count ? `<span class="discover-reading-count">${book.reading_count}人在读</span>` : "";
const reasonHtml = (showReason && book.reason) ? `<div class="discover-reason">${book.reason}</div>` : "";
const categoryHtml = book.category ? `<div class="discover-category">${book.category}</div>` : "";
return `
<div class="discover-card" onclick="openBook('${book.book_id}')">
<img class="discover-cover" src="${hdCover(book.cover || '')}" alt="${book.title}"
loading="lazy"
onerror="this.onerror=null;this.src='${book.cover || ''}';this.onerror=function(){this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22180%22%3E%3Crect fill=%22%23e8e0d4%22 width=%22200%22 height=%22180%22/%3E%3Ctext x=%22100%22 y=%2290%22 text-anchor=%22middle%22 fill=%22%239b8e80%22 font-size=%2214%22%3E无封面%3C/text%3E%3C/svg%3E'}">
<div class="discover-info">
<div class="discover-title">${book.title}</div>
<div class="discover-author">${book.author || ''}</div>
<div class="discover-meta">${ratingHtml}${ratingLabel}${readingHtml}</div>
${categoryHtml}
${reasonHtml}
</div>
</div>
`;
})
.join("");
}