mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
1. Fix hdCover() to also replace s_ prefix (search results use s_ not t6_) 2. Add persistent search bar below navbar, switches to discover tab on search 3. Shelf search now supports book title and author search (new keyword param) 4. Add category tag pills above book grid, click to filter by category 5. Update /api/shelf backend: keyword param for title/author, category for exact match 6. Mobile responsive styles for search bar and category tags
1022 lines
41 KiB
JavaScript
1022 lines
41 KiB
JavaScript
/* ===== 微信读书数据展示 - 前端逻辑 ===== */
|
||
|
||
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;
|
||
|
||
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) {
|
||
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" });
|
||
}
|
||
|
||
// ===== 全局搜索(顶部常驻搜索框) =====
|
||
|
||
function globalSearch() {
|
||
const keyword = document.getElementById("globalSearchInput").value.trim();
|
||
if (!keyword) return;
|
||
|
||
// 切换到推荐标签页
|
||
switchTab("discover");
|
||
// 切换到搜索子标签
|
||
switchDiscoverTab("search");
|
||
// 填入搜索词并执行搜索
|
||
document.getElementById("searchInput").value = keyword;
|
||
doSearch();
|
||
}
|
||
|
||
// ===== 书架分类标签 =====
|
||
|
||
let shelfCategories = [];
|
||
let shelfActiveCategory = ""; // 空字符串表示"全部"
|
||
|
||
async function loadShelfCategories() {
|
||
try {
|
||
const data = await fetchAPI("/api/shelf?page=1&per_page=1");
|
||
shelfCategories = data.categories || [];
|
||
renderShelfCategories();
|
||
} catch (e) {
|
||
console.error("加载分类失败:", e);
|
||
}
|
||
}
|
||
|
||
function renderShelfCategories() {
|
||
const container = document.getElementById("shelfCategories");
|
||
if (!container) return;
|
||
|
||
let html = `<button class="shelf-category-tag ${shelfActiveCategory === "" ? "active" : ""}" onclick="filterByCategory('')">全部</button>`;
|
||
html += shelfCategories
|
||
.map((cat) => `<button class="shelf-category-tag ${shelfActiveCategory === cat ? "active" : ""}" onclick="filterByCategory('${cat.replace(/'/g, "\\'")}')">${cat}</button>`)
|
||
.join("");
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function filterByCategory(category) {
|
||
shelfActiveCategory = category;
|
||
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);
|
||
|
||
try {
|
||
const data = await fetchAPI(`/api/shelf?${params}`);
|
||
const grid = document.getElementById("bookGrid");
|
||
|
||
document.getElementById("shelfInfo").textContent =
|
||
`共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`;
|
||
|
||
grid.innerHTML = data.books
|
||
.map((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>`;
|
||
})
|
||
.join("");
|
||
|
||
renderPagination(page, data.total_pages);
|
||
} catch (e) {
|
||
console.error("加载书架失败:", e);
|
||
}
|
||
}
|
||
|
||
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="window.open('weread://reading?bId=${bookId}','_blank')">在微信读书中打开</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-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 id="thoughtsList"></div>
|
||
`;
|
||
|
||
content.innerHTML = html;
|
||
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");
|
||
renderThoughtsList(category);
|
||
}
|
||
|
||
function renderThoughtsList(category) {
|
||
const data = currentThoughtsData;
|
||
if (!data) return;
|
||
|
||
const highlights = data.highlights || [];
|
||
const bookReviews = (data.reviews || []).filter((r) => r.is_finish);
|
||
const thoughts = (data.reviews || []).filter((r) => !r.is_finish);
|
||
|
||
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>`;
|
||
const grouped = data.grouped_highlights || {};
|
||
for (const [chTitle, items] of Object.entries(grouped)) {
|
||
if (category === "highlight") {
|
||
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>
|
||
`;
|
||
});
|
||
if (category === "highlight") html += `</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) {
|
||
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("");
|
||
}
|