Files
WeReadBoard/static/js/app.js
T
admin 4d8cc2a2d7 fix: clamp review star value to 0-5 range to prevent repeat() RangeError
WeRead API returns star=100 for book reviews and star=-1 for thoughts,
causing '☆'.repeat(5-100) to throw 'Invalid count value: -95'.
2026-07-17 11:17:55 +08:00

955 lines
38 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 = "";
// ===== 主题管理 =====
/**
* 获取当前主题。
* @returns {"light"|"dark"}
*/
function getCurrentTheme() {
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
/**
* 切换明亮/黑暗主题。
* 持久化到 localStorage,并触发图表重绘。
*/
function toggleTheme() {
const newTheme = getCurrentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
refreshChartsForTheme();
}
/**
* 从 CSS 变量读取当前主题的颜色值,供 ECharts 使用。
*/
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 屏模糊)
* t8_ = 高清版(~500px,适合 2x/3x 屏幕)
* 如果 URL 不含 t6_ 前缀或已是高清则原样返回。
*/
function hdCover(url) {
if (!url) return "";
return url.replace("/t6_", "/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);
}
// ===== 数据库健康检查 =====
// 存储所有 ECharts 实例,用于主题切换时重绘
const chartInstances = {};
/**
* 主题切换后重绘所有 ECharts 图表。
*/
function refreshChartsForTheme() {
Object.values(chartInstances).forEach((chart) => {
if (chart && !chart.isDisposed()) {
chart.dispose();
}
});
// 重新加载所有图表
loadYearlyTrend();
loadTimeDistribution();
loadCategories();
loadAuthors();
}
/**
* 页面加载时检查 MySQL 数据库连接状态。
* 如果连接失败,显示告警横幅并阻止后续数据加载。
*/
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;
}
}
/**
* 显示数据库连接告警横幅。
* @param {string} message - 告警详情信息
*/
function showDBAlert(message) {
const banner = document.getElementById("dbAlertBanner");
const detail = document.getElementById("dbAlertDetail");
if (banner) banner.style.display = "flex";
if (detail) detail.textContent = message;
}
// ===== 初始化 =====
document.addEventListener("DOMContentLoaded", async () => {
// 首先检查数据库连接状态
const isHealthy = await checkDBHealth();
if (!isHealthy) {
// 数据库连接失败,不加载后续数据
return;
}
// 数据库连接正常,加载所有数据模块
loadOverview();
loadYearlyTrend();
loadTimeDistribution();
loadCategories();
loadAuthors();
loadTopBooks();
loadShelf(1);
loadNotebooks();
loadRecommend(0);
initNavScroll();
});
// ===== 导航栏滚动高亮 =====
function initNavScroll() {
const links = document.querySelectorAll(".nav-link");
const sections = document.querySelectorAll("section[id]");
window.addEventListener("scroll", () => {
let current = "";
sections.forEach((sec) => {
const top = sec.offsetTop - 100;
if (window.scrollY >= top) current = sec.id;
});
links.forEach((link) => {
link.classList.toggle("active", link.getAttribute("href") === `#${current}`);
});
});
links.forEach((link) => {
link.addEventListener("click", (e) => {
e.preventDefault();
const target = document.querySelector(link.getAttribute("href"));
if (target) {
window.scrollTo({ top: target.offsetTop - 60, behavior: "smooth" });
}
});
});
}
// ===== 总览数据 =====
async function loadOverview() {
try {
const data = await fetchAPI("/api/overview");
// Hero 统计
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 grid = document.getElementById("topBooksGrid");
grid.innerHTML = data
.map((book, i) => {
const tagsHtml = book.tags
.map((t) => `<span class="top-book-tag">${t}</span>`)
.join("");
return `
<div class="top-book-card" onclick="openBook('${book.book_id}')">
<div class="top-book-cover-wrapper">
<span class="top-book-rank">${i + 1}</span>
<img class="top-book-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=%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=%2214%22%3E无封面%3C/text%3E%3C/svg%3E'}">
</div>
<div class="top-book-info">
<div class="top-book-title">${book.title}</div>
<div class="top-book-author">${book.author}</div>
<div class="top-book-time">⏱ ${book.read_time_str}</div>
${tagsHtml ? `<div class="top-book-tags">${tagsHtml}</div>` : ""}
</div>
</div>`;
})
.join("");
} catch (e) {
console.error("加载排行失败:", e);
}
}
// ===== 书架 =====
let shelfCurrentPage = 1;
async function loadShelf(page) {
shelfCurrentPage = page;
const search = document.getElementById("shelfSearch").value;
const finished = document.getElementById("finishedOnly").checked ? "1" : "0";
const params = new URLSearchParams({
page: page,
per_page: 60,
category: search,
finished: finished,
});
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;
}
// 搜索和筛选事件
document.getElementById("shelfSearch").addEventListener("input", debounce(() => loadShelf(1), 300));
document.getElementById("finishedOnly").addEventListener("change", () => loadShelf(1));
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="openNotes('${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>
${progress.is_start_reading ? `
<div class="detail-progress-info">
<div style="font-size:13px;color:var(--text-secondary)">
阅读进度: <strong>${progress.progress}%</strong>
${progress.record_reading_time_str ? ' · 累计 ' + progress.record_reading_time_str : ''}
${progress.finish_time ? ' · 读完于 ' + progress.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>
</div>
`;
})
.catch((e) => {
body.innerHTML = `<div class="empty-state">加载失败: ${e.message}</div>`;
});
}
// ===== 打开笔记详情弹窗 =====
function openNotes(bookId, title, author, cover) {
if (!bookId) return;
showModal("notesDetailModal");
const body = document.getElementById("notesDetailBody");
body.innerHTML = `
<div class="notes-header">
<img class="notes-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=%2260%22 height=%2280%22%3E%3Crect fill=%22%23e8e0d4%22 width=%2260%22 height=%2280%22/%3E%3C/svg%3E'}">
<div>
<div class="notes-title">${title}</div>
<div class="notes-author">${author || ''}</div>
</div>
</div>
<div class="loading"><div class="loading-spinner"></div></div>
`;
fetchAPI(`/api/book/notes?book_id=${bookId}`)
.then((data) => {
let html = `
<div class="notes-header">
<img class="notes-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=%2260%22 height=%2280%22%3E%3Crect fill=%22%23e8e0d4%22 width=%2260%22 height=%2280%22/%3E%3C/svg%3E'}">
<div>
<div class="notes-title">${title}</div>
<div class="notes-author">${author || ''}</div>
<div class="notes-counts">
<span>划线 <strong>${data.highlight_count}</strong></span>
<span>想法/点评 <strong>${data.review_count}</strong></span>
</div>
</div>
</div>
`;
// 划线区
if (data.highlight_count > 0) {
html += `<div class="notes-section-title">📌 划线 (${data.highlight_count})</div>`;
const grouped = data.grouped_highlights;
for (const [chTitle, items] of Object.entries(grouped)) {
html += `<div class="chapter-group">`;
html += `<div class="chapter-title">${chTitle}</div>`;
items.forEach((h) => {
html += `
<div class="highlight-item">
${escapeHtml(h.mark_text)}
<div class="highlight-date">${h.create_time || ''}</div>
</div>
`;
});
html += `</div>`;
}
}
// 想法/点评区
if (data.review_count > 0) {
html += `<div class="notes-section-title">💭 想法/点评 (${data.review_count})</div>`;
data.reviews.forEach((r) => {
const starVal = Math.max(0, Math.min(5, parseInt(r.star) || 0));
const stars = starVal > 0 ? `<span class="review-star">${"★".repeat(starVal)}${"☆".repeat(5 - starVal)}</span>` : "";
html += `
<div class="review-item">
${escapeHtml(r.content)}
<div class="review-meta">
${stars}
${r.chapter_name ? ' · ' + r.chapter_name : ''}
${r.is_finish ? ' · 书评' : ''}
· ${r.create_time || ''}
</div>
</div>
`;
});
}
if (data.highlight_count === 0 && data.review_count === 0) {
html += `<div class="empty-state">暂无笔记数据</div>`;
}
body.innerHTML = html;
})
.catch((e) => {
body.innerHTML = `<div class="empty-state">加载失败: ${e.message}</div>`;
});
}
// ===== 弹窗控制 =====
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 = "";
}
// ESC 关闭弹窗
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeModal("bookDetailModal");
closeModal("notesDetailModal");
}
});
// ===== HTML 转义 =====
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// ===== 推荐发现 =====
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("");
}