merge: merge dev into master - book detail, notes, recommendation, MySQL schema

合并 dev 分支新功能到 master:
1. 书籍详情弹窗(点击书架书籍查看详情)
2. 笔记详情弹窗(点击笔记书籍查看划线和想法)
3. 推荐页面(个性化推荐 + 关键词搜索)
4. MySQL 版本建表语句
This commit is contained in:
admin committed 2026-07-17 10:07:11 +08:00
commit 463b736d13
6 files changed
+1660 -4

No files matched your search

+475
View File
@@ -8,6 +8,7 @@ import json
import os import os
import sqlite3 import sqlite3
import time import time
import urllib.request
from datetime import datetime from datetime import datetime
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request
@@ -16,6 +17,80 @@ app = Flask(__name__, static_folder="static", template_folder="templates")
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db")
WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway"
WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "")
WEREAD_SKILL_VERSION = "1.0.3"
def weread_api_call(api_name, **kwargs):
"""调用微信读书 API(按需获取数据时使用)"""
body = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION}
body.update(kwargs)
req = urllib.request.Request(
WEREAD_API_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {WEREAD_API_KEY}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def ensure_tables(conn):
"""确保按需数据表存在"""
c = conn.cursor()
c.executescript("""
CREATE TABLE IF NOT EXISTS book_details (
book_id TEXT PRIMARY KEY,
title TEXT, author TEXT, translator TEXT, cover TEXT,
intro TEXT, category TEXT, publisher TEXT, publish_time TEXT,
isbn TEXT, word_count INTEGER, new_rating REAL,
new_rating_count INTEGER, fetched_at INTEGER
);
CREATE TABLE IF NOT EXISTS book_progress (
book_id TEXT PRIMARY KEY,
progress INTEGER, chapter_uid INTEGER, chapter_offset INTEGER,
update_time INTEGER, record_reading_time INTEGER,
finish_time INTEGER, is_start_reading INTEGER, fetched_at INTEGER
);
CREATE TABLE IF NOT EXISTS book_highlights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, bookmark_id TEXT, chapter_uid INTEGER,
mark_text TEXT, create_time INTEGER, type INTEGER,
range_text TEXT, color_style INTEGER, fetched_at INTEGER
);
CREATE TABLE IF NOT EXISTS book_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, chapter_uid INTEGER, chapter_idx INTEGER,
title TEXT, UNIQUE(book_id, chapter_uid)
);
CREATE TABLE IF NOT EXISTS book_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, review_id TEXT, content TEXT,
create_time INTEGER, star INTEGER, chapter_name TEXT,
is_finish INTEGER, fetched_at INTEGER
);
CREATE TABLE IF NOT EXISTS recommended_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT, title TEXT, author TEXT, cover TEXT,
intro TEXT, category TEXT, reason TEXT,
reading_count INTEGER, new_rating REAL,
new_rating_count INTEGER, rating_title TEXT, fetched_at INTEGER
);
CREATE TABLE IF NOT EXISTS search_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT, book_id TEXT, title TEXT, author TEXT,
cover TEXT, intro TEXT, publisher TEXT, category TEXT,
reading_count INTEGER, new_rating REAL,
new_rating_count INTEGER, rating_title TEXT,
search_idx INTEGER, fetched_at INTEGER
);
""")
conn.commit()
def get_db(): def get_db():
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
@@ -371,5 +446,405 @@ def api_archives():
return jsonify(archives) return jsonify(archives)
# ===== 按需获取:书籍详情 =====
@app.route("/api/book/detail")
def api_book_detail():
"""书籍详情:实时调微信读书 API 获取,入库后返回"""
book_id = request.args.get("book_id", "")
if not book_id:
return jsonify({"error": "book_id is required"}), 400
conn = get_db()
ensure_tables(conn)
c = conn.cursor()
now = int(time.time())
# 1. 获取书籍信息
try:
info = weread_api_call("/book/info", bookId=book_id)
c.execute(
"""INSERT OR REPLACE INTO book_details
(book_id, title, author, translator, cover, intro, category,
publisher, publish_time, isbn, word_count, new_rating,
new_rating_count, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
book_id,
info.get("title", ""),
info.get("author", ""),
info.get("translator", ""),
info.get("cover", ""),
info.get("intro", ""),
info.get("category", ""),
info.get("publisher", ""),
info.get("publishTime", ""),
info.get("isbn", ""),
info.get("wordCount", 0),
info.get("newRating", 0),
info.get("newRatingCount", 0),
now,
),
)
conn.commit()
except Exception as e:
# API 调用失败时尝试从数据库读取缓存
c.execute("SELECT * FROM book_details WHERE book_id=?", (book_id,))
cached = c.fetchone()
if not cached:
return jsonify({"error": f"获取书籍信息失败: {str(e)}"}), 500
# 2. 获取阅读进度
progress_data = {}
try:
prog = weread_api_call("/book/getprogress", bookId=book_id)
book_prog = prog.get("book", {})
c.execute(
"""INSERT OR REPLACE INTO book_progress
(book_id, progress, chapter_uid, chapter_offset, update_time,
record_reading_time, finish_time, is_start_reading, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
book_id,
book_prog.get("progress", 0),
book_prog.get("chapterUid", 0),
book_prog.get("chapterOffset", 0),
book_prog.get("updateTime", 0),
book_prog.get("recordReadingTime", 0),
book_prog.get("finishTime", 0),
book_prog.get("isStartReading", 0),
now,
),
)
conn.commit()
progress_data = {
"progress": book_prog.get("progress", 0),
"record_reading_time": book_prog.get("recordReadingTime", 0),
"record_reading_time_str": format_duration(book_prog.get("recordReadingTime", 0)),
"update_time": ts_to_date(book_prog.get("updateTime", 0)),
"finish_time": ts_to_date(book_prog.get("finishTime", 0)),
"is_start_reading": book_prog.get("isStartReading", 0),
}
except Exception:
c.execute("SELECT * FROM book_progress WHERE book_id=?", (book_id,))
cached = c.fetchone()
if cached:
progress_data = {
"progress": cached["progress"],
"record_reading_time": cached["record_reading_time"],
"record_reading_time_str": format_duration(cached["record_reading_time"]),
"update_time": ts_to_date(cached["update_time"]),
"finish_time": ts_to_date(cached["finish_time"]),
"is_start_reading": cached["is_start_reading"],
}
# 3. 组装返回
c.execute("SELECT * FROM book_details WHERE book_id=?", (book_id,))
detail = c.fetchone()
conn.close()
if not detail:
return jsonify({"error": "数据获取失败"}), 500
return jsonify({
"book_id": book_id,
"title": detail["title"],
"author": detail["author"],
"translator": detail["translator"],
"cover": detail["cover"],
"intro": detail["intro"],
"category": detail["category"],
"publisher": detail["publisher"],
"publish_time": detail["publish_time"],
"isbn": detail["isbn"],
"word_count": detail["word_count"],
"new_rating": detail["new_rating"],
"new_rating_count": detail["new_rating_count"],
"progress": progress_data,
"deep_link": f"weread://reading?bId={book_id}",
})
# ===== 按需获取:笔记详情 =====
@app.route("/api/book/notes")
def api_book_notes():
"""笔记详情:实时调微信读书 API 获取划线+想法,入库后返回"""
book_id = request.args.get("book_id", "")
if not book_id:
return jsonify({"error": "book_id is required"}), 400
conn = get_db()
ensure_tables(conn)
c = conn.cursor()
now = int(time.time())
# 1. 获取划线
highlights = []
chapters = {}
try:
data = weread_api_call("/book/bookmarklist", bookId=book_id)
# 保存章节信息
for ch in data.get("chapters", []):
c.execute(
"""INSERT OR REPLACE INTO book_chapters
(book_id, chapter_uid, chapter_idx, title)
VALUES (?,?,?,?)""",
(book_id, ch.get("chapterUid", 0), ch.get("chapterIdx", 0), ch.get("title", "")),
)
chapters[ch.get("chapterUid", 0)] = ch.get("title", "")
# 保存划线
c.execute("DELETE FROM book_highlights WHERE book_id=?", (book_id,))
for bm in data.get("updated", []):
c.execute(
"""INSERT INTO book_highlights
(book_id, bookmark_id, chapter_uid, mark_text, create_time,
type, range_text, color_style, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
book_id,
bm.get("bookmarkId", ""),
bm.get("chapterUid", 0),
bm.get("markText", ""),
bm.get("createTime", 0),
bm.get("type", 0),
bm.get("range", ""),
bm.get("colorStyle", 0),
now,
),
)
highlights.append({
"bookmark_id": bm.get("bookmarkId", ""),
"chapter_uid": bm.get("chapterUid", 0),
"chapter_title": chapters.get(bm.get("chapterUid", 0), ""),
"mark_text": bm.get("markText", ""),
"create_time": ts_to_date(bm.get("createTime", 0)),
"range": bm.get("range", ""),
})
conn.commit()
except Exception as e:
# 从数据库读取缓存
c.execute("SELECT * FROM book_highlights WHERE book_id=? ORDER BY create_time", (book_id,))
for row in c.fetchall():
c2 = conn.cursor()
c2.execute("SELECT title FROM book_chapters WHERE book_id=? AND chapter_uid=?", (book_id, row["chapter_uid"]))
ch_row = c2.fetchone()
highlights.append({
"bookmark_id": row["bookmark_id"],
"chapter_uid": row["chapter_uid"],
"chapter_title": ch_row["title"] if ch_row else "",
"mark_text": row["mark_text"],
"create_time": ts_to_date(row["create_time"]),
"range": row["range_text"],
})
# 2. 获取想法/点评
reviews = []
try:
rev_data = weread_api_call("/review/list/mine", bookid=book_id, count=100)
c.execute("DELETE FROM book_reviews WHERE book_id=?", (book_id,))
for item in rev_data.get("reviews", []):
rev = item.get("review", {})
c.execute(
"""INSERT INTO book_reviews
(book_id, review_id, content, create_time, star,
chapter_name, is_finish, fetched_at)
VALUES (?,?,?,?,?,?,?,?)""",
(
book_id,
rev.get("reviewId", ""),
rev.get("content", ""),
rev.get("createTime", 0),
rev.get("star", 0),
rev.get("chapterName", ""),
rev.get("isFinish", 0),
now,
),
)
reviews.append({
"review_id": rev.get("reviewId", ""),
"content": rev.get("content", ""),
"create_time": ts_to_date(rev.get("createTime", 0)),
"star": rev.get("star", 0),
"chapter_name": rev.get("chapterName", ""),
"is_finish": rev.get("isFinish", 0),
})
conn.commit()
except Exception:
c.execute("SELECT * FROM book_reviews WHERE book_id=? ORDER BY create_time DESC", (book_id,))
for row in c.fetchall():
reviews.append({
"review_id": row["review_id"],
"content": row["content"],
"create_time": ts_to_date(row["create_time"]),
"star": row["star"],
"chapter_name": row["chapter_name"],
"is_finish": row["is_finish"],
})
conn.close()
# 按章节分组划线
grouped_highlights = {}
for h in highlights:
ch_title = h["chapter_title"] or f"章节{h['chapter_uid']}"
if ch_title not in grouped_highlights:
grouped_highlights[ch_title] = []
grouped_highlights[ch_title].append(h)
return jsonify({
"book_id": book_id,
"highlights": highlights,
"highlight_count": len(highlights),
"grouped_highlights": grouped_highlights,
"reviews": reviews,
"review_count": len(reviews),
})
# ===== 推荐书籍 =====
@app.route("/api/recommend")
def api_recommend():
"""个性化推荐:实时调微信读书 API,入库后返回"""
max_idx = int(request.args.get("maxIdx", 0))
conn = get_db()
ensure_tables(conn)
c = conn.cursor()
now = int(time.time())
try:
data = weread_api_call("/book/recommend", count=12, maxIdx=max_idx)
books = data.get("books", [])
for book in books:
rating_detail = book.get("newRatingDetail", {})
c.execute(
"""INSERT OR REPLACE INTO recommended_books
(book_id, title, author, cover, intro, category, reason,
reading_count, new_rating, new_rating_count, rating_title, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
book.get("bookId", ""),
book.get("title", ""),
book.get("author", ""),
book.get("cover", ""),
book.get("intro", ""),
book.get("category", ""),
book.get("reason", ""),
book.get("readingCount", 0),
book.get("newRating", 0),
book.get("newRatingCount", 0),
rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
now,
),
)
conn.commit()
except Exception as e:
return jsonify({"error": f"获取推荐失败: {str(e)}"}), 500
conn.close()
result = []
for book in books:
rating_detail = book.get("newRatingDetail", {})
result.append({
"book_id": book.get("bookId", ""),
"title": book.get("title", ""),
"author": book.get("author", ""),
"cover": book.get("cover", ""),
"intro": book.get("intro", ""),
"category": book.get("category", ""),
"reason": book.get("reason", ""),
"reading_count": book.get("readingCount", 0),
"new_rating": book.get("newRating", 0),
"new_rating_count": book.get("newRatingCount", 0),
"rating_title": rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
})
return jsonify({
"books": result,
"has_more": data.get("hasMore", 0),
"last_search_idx": books[-1].get("searchIdx", 0) if books else 0,
})
# ===== 搜索书籍 =====
@app.route("/api/search")
def api_search():
"""搜索书籍:实时调微信读书 API,入库后返回"""
keyword = request.args.get("keyword", "").strip()
# 修复 Flask URL 参数中文编码问题
try:
keyword = keyword.encode("latin-1").decode("utf-8")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
if not keyword:
return jsonify({"error": "keyword is required"}), 400
max_idx = int(request.args.get("maxIdx", 0))
conn = get_db()
ensure_tables(conn)
c = conn.cursor()
now = int(time.time())
try:
data = weread_api_call("/store/search", keyword=keyword, scope=10, maxIdx=max_idx)
results = data.get("results", [])
books = []
for group in results:
for item in group.get("books", []):
bi = item.get("bookInfo", {})
rating_detail = item.get("newRatingDetail", {})
c.execute(
"""INSERT OR REPLACE INTO search_results
(keyword, book_id, title, author, cover, intro, publisher,
category, reading_count, new_rating, new_rating_count,
rating_title, search_idx, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
keyword,
bi.get("bookId", ""),
bi.get("title", ""),
bi.get("author", ""),
bi.get("cover", ""),
bi.get("intro", ""),
bi.get("publisher", ""),
bi.get("category", ""),
item.get("readingCount", 0),
item.get("newRating", 0),
item.get("newRatingCount", 0),
rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
item.get("searchIdx", 0),
now,
),
)
books.append({
"book_id": bi.get("bookId", ""),
"title": bi.get("title", ""),
"author": bi.get("author", ""),
"cover": bi.get("cover", ""),
"intro": bi.get("intro", ""),
"publisher": bi.get("publisher", ""),
"category": bi.get("category", ""),
"reading_count": item.get("readingCount", 0),
"new_rating": item.get("newRating", 0),
"new_rating_count": item.get("newRatingCount", 0),
"rating_title": rating_detail.get("title", "") if isinstance(rating_detail, dict) else "",
})
conn.commit()
except Exception as e:
return jsonify({"error": f"搜索失败: {str(e)}"}), 500
conn.close()
return jsonify({
"keyword": keyword,
"books": books,
"has_more": data.get("hasMore", 0),
})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=False) app.run(host="0.0.0.0", port=8080, debug=False)
+120
View File
@@ -180,6 +180,126 @@ def init_db(db_path):
) )
""") """)
# ===== 按需获取的详细数据表 =====
# 书籍详细信息(来自 /book/info)
c.execute("""
CREATE TABLE IF NOT EXISTS book_details (
book_id TEXT PRIMARY KEY,
title TEXT,
author TEXT,
translator TEXT,
cover TEXT,
intro TEXT,
category TEXT,
publisher TEXT,
publish_time TEXT,
isbn TEXT,
word_count INTEGER,
new_rating REAL,
new_rating_count INTEGER,
fetched_at INTEGER
)
""")
# 阅读进度(来自 /book/getprogress)
c.execute("""
CREATE TABLE IF NOT EXISTS book_progress (
book_id TEXT PRIMARY KEY,
progress INTEGER,
chapter_uid INTEGER,
chapter_offset INTEGER,
update_time INTEGER,
record_reading_time INTEGER,
finish_time INTEGER,
is_start_reading INTEGER,
fetched_at INTEGER
)
""")
# 划线内容(来自 /book/bookmarklist)
c.execute("""
CREATE TABLE IF NOT EXISTS book_highlights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT,
bookmark_id TEXT,
chapter_uid INTEGER,
mark_text TEXT,
create_time INTEGER,
type INTEGER,
range_text TEXT,
color_style INTEGER,
fetched_at INTEGER
)
""")
# 章节信息(来自 /book/bookmarklist 的 chapters)
c.execute("""
CREATE TABLE IF NOT EXISTS book_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT,
chapter_uid INTEGER,
chapter_idx INTEGER,
title TEXT,
UNIQUE(book_id, chapter_uid)
)
""")
# 个人想法/点评(来自 /review/list/mine)
c.execute("""
CREATE TABLE IF NOT EXISTS book_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT,
review_id TEXT,
content TEXT,
create_time INTEGER,
star INTEGER,
chapter_name TEXT,
is_finish INTEGER,
fetched_at INTEGER
)
""")
# 推荐书籍(来自 /book/recommend)
c.execute("""
CREATE TABLE IF NOT EXISTS recommended_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT,
title TEXT,
author TEXT,
cover TEXT,
intro TEXT,
category TEXT,
reason TEXT,
reading_count INTEGER,
new_rating REAL,
new_rating_count INTEGER,
rating_title TEXT,
fetched_at INTEGER
)
""")
# 搜索结果(来自 /store/search)
c.execute("""
CREATE TABLE IF NOT EXISTS search_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT,
book_id TEXT,
title TEXT,
author TEXT,
cover TEXT,
intro TEXT,
publisher TEXT,
category TEXT,
reading_count INTEGER,
new_rating REAL,
new_rating_count INTEGER,
rating_title TEXT,
search_idx INTEGER,
fetched_at INTEGER
)
""")
conn.commit() conn.commit()
return conn return conn
+325
View File
@@ -0,0 +1,325 @@
-- ============================================================
-- 微信读书数据展示网站 - MySQL 版本建表语句
-- 适用于后续从 SQLite 迁移至 MySQL
-- 字符集: utf8mb4 (支持完整 Unicode,包括 emoji)
-- ============================================================
CREATE DATABASE IF NOT EXISTS `weread_dashboard`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `weread_dashboard`;
-- ============================================================
-- 1. 书架书籍表
-- ============================================================
CREATE TABLE IF NOT EXISTS `books` (
`book_id` VARCHAR(64) NOT NULL COMMENT '书籍唯一标识',
`title` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '书名',
`author` VARCHAR(255) DEFAULT NULL COMMENT '作者',
`cover` VARCHAR(512) DEFAULT NULL COMMENT '封面图 URL',
`category` VARCHAR(128) DEFAULT NULL COMMENT '分类',
`finish_reading` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否读完 (1=读完)',
`read_update_time` INT UNSIGNED DEFAULT NULL COMMENT '最近阅读时间 (Unix 时间戳)',
`secret` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否私密 (1=私密)',
`is_top` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否置顶 (1=置顶)',
`update_time` INT UNSIGNED DEFAULT NULL COMMENT '书籍更新时间 (Unix 时间戳)',
`deep_link` VARCHAR(512) DEFAULT NULL COMMENT '深度链接',
PRIMARY KEY (`book_id`),
INDEX `idx_books_category` (`category`),
INDEX `idx_books_finish_reading` (`finish_reading`),
INDEX `idx_books_read_update_time` (`read_update_time` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='书架书籍信息';
-- ============================================================
-- 2. 阅读统计概要表
-- ============================================================
CREATE TABLE IF NOT EXISTS `reading_stats` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(20) NOT NULL COMMENT '统计维度: overall/annually/monthly/weekly',
`period` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '周期标识',
`total_read_time` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '总阅读时长 (秒)',
`read_days` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '有效阅读天数',
`day_average_read_time` DOUBLE DEFAULT 0 COMMENT '日均阅读时长 (秒)',
`regist_time` INT UNSIGNED DEFAULT NULL COMMENT '用户注册时间 (Unix 时间戳)',
`raw_json` LONGTEXT DEFAULT NULL COMMENT '原始 API 返回 JSON',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
PRIMARY KEY (`id`),
INDEX `idx_reading_stats_mode` (`mode`, `period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='阅读统计概要';
-- ============================================================
-- 3. 阅读时长时间序列(按年/月/日分桶)
-- ============================================================
CREATE TABLE IF NOT EXISTS `read_times` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(30) NOT NULL COMMENT '统计维度 (含_daily后缀)',
`period` VARCHAR(20) NOT NULL DEFAULT '',
`bucket_timestamp` INT UNSIGNED NOT NULL COMMENT '分桶起始时间戳',
`duration` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读时长 (秒)',
PRIMARY KEY (`id`),
INDEX `idx_read_times_mode_period` (`mode`, `period`),
INDEX `idx_read_times_bucket` (`bucket_timestamp`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='阅读时长时间序列';
-- ============================================================
-- 4. 阅读时长最久的书籍排行
-- ============================================================
CREATE TABLE IF NOT EXISTS `read_longest` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(20) NOT NULL,
`period` VARCHAR(20) NOT NULL DEFAULT '',
`book_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) DEFAULT NULL,
`author` VARCHAR(255) DEFAULT NULL,
`cover` VARCHAR(512) DEFAULT NULL,
`read_time` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读时长 (秒)',
`tags` VARCHAR(512) DEFAULT NULL COMMENT '标签 JSON 数组',
PRIMARY KEY (`id`),
INDEX `idx_read_longest_mode` (`mode`, `period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='阅读时长排行';
-- ============================================================
-- 5. 偏好分类
-- ============================================================
CREATE TABLE IF NOT EXISTS `prefer_categories` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(20) NOT NULL,
`period` VARCHAR(20) NOT NULL DEFAULT '',
`category_id` INT UNSIGNED DEFAULT NULL,
`category_title` VARCHAR(128) DEFAULT NULL,
`parent_category_title` VARCHAR(128) DEFAULT NULL,
`val` DOUBLE DEFAULT 0 COMMENT '偏好权重',
`reading_time` INT UNSIGNED DEFAULT 0 COMMENT '该分类阅读时长 (秒)',
`reading_count` INT UNSIGNED DEFAULT 0 COMMENT '该分类阅读本数',
PRIMARY KEY (`id`),
INDEX `idx_prefer_cat_mode` (`mode`, `period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='偏好阅读分类';
-- ============================================================
-- 6. 偏好作者
-- ============================================================
CREATE TABLE IF NOT EXISTS `prefer_authors` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(20) NOT NULL,
`period` VARCHAR(20) NOT NULL DEFAULT '',
`author_id` INT UNSIGNED DEFAULT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`count` INT UNSIGNED DEFAULT 0 COMMENT '阅读该作者的书本数',
`read_time` VARCHAR(64) DEFAULT NULL COMMENT '阅读时长 (格式化字符串)',
PRIMARY KEY (`id`),
INDEX `idx_prefer_author_mode` (`mode`, `period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='偏好作者';
-- ============================================================
-- 7. 24小时阅读时段分布
-- ============================================================
CREATE TABLE IF NOT EXISTS `prefer_time` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(20) NOT NULL,
`period` VARCHAR(20) NOT NULL DEFAULT '',
`hour_index` TINYINT UNSIGNED NOT NULL COMMENT '小时 (0-23)',
`duration` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读时长 (秒)',
PRIMARY KEY (`id`),
INDEX `idx_prefer_time_mode` (`mode`, `period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='24小时阅读时段分布';
-- ============================================================
-- 8. 笔记本概览
-- ============================================================
CREATE TABLE IF NOT EXISTS `notebooks` (
`book_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) DEFAULT NULL,
`author` VARCHAR(255) DEFAULT NULL,
`cover` VARCHAR(512) DEFAULT NULL,
`review_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '想法/点评数',
`note_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '划线数',
`bookmark_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '书签数',
`reading_progress` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读进度 (0-100)',
`marked_status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '标记状态 (1=读完)',
`sort` BIGINT DEFAULT NULL COMMENT '排序值',
PRIMARY KEY (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='笔记本概览';
-- ============================================================
-- 9. 阅读统计摘要项
-- ============================================================
CREATE TABLE IF NOT EXISTS `read_stat_items` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`mode` VARCHAR(20) NOT NULL,
`period` VARCHAR(20) NOT NULL DEFAULT '',
`stat_name` VARCHAR(64) DEFAULT NULL COMMENT '统计项名称 (读过/读完/阅读/笔记)',
`counts` VARCHAR(64) DEFAULT NULL COMMENT '统计值文案',
`scheme` VARCHAR(512) DEFAULT NULL COMMENT 'App 跳转链接',
PRIMARY KEY (`id`),
INDEX `idx_read_stat_items_mode` (`mode`, `period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='阅读统计摘要项';
-- ============================================================
-- 10. 书单/归档
-- ============================================================
CREATE TABLE IF NOT EXISTS `archives` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) DEFAULT NULL COMMENT '书单名称',
`book_ids` LONGTEXT DEFAULT NULL COMMENT 'bookId 列表 JSON 数组',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='书单/归档';
-- ============================================================
-- 11. 数据采集元信息
-- ============================================================
CREATE TABLE IF NOT EXISTS `fetch_meta` (
`key` VARCHAR(64) NOT NULL,
`value` TEXT DEFAULT NULL,
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='数据采集元信息';
-- ============================================================
-- 12. 书籍详细信息(按需获取,来自 /book/info)
-- ============================================================
CREATE TABLE IF NOT EXISTS `book_details` (
`book_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) DEFAULT NULL,
`author` VARCHAR(255) DEFAULT NULL,
`translator` VARCHAR(255) DEFAULT NULL COMMENT '译者',
`cover` VARCHAR(512) DEFAULT NULL,
`intro` LONGTEXT DEFAULT NULL COMMENT '简介',
`category` VARCHAR(128) DEFAULT NULL,
`publisher` VARCHAR(255) DEFAULT NULL,
`publish_time` VARCHAR(32) DEFAULT NULL COMMENT '出版时间',
`isbn` VARCHAR(32) DEFAULT NULL,
`word_count` INT UNSIGNED DEFAULT 0 COMMENT '总字数',
`new_rating` DECIMAL(5,1) DEFAULT 0 COMMENT '评分 (百分制)',
`new_rating_count` INT UNSIGNED DEFAULT 0 COMMENT '评分人数',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获取时间',
PRIMARY KEY (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='书籍详细信息';
-- ============================================================
-- 13. 阅读进度(按需获取,来自 /book/getprogress)
-- ============================================================
CREATE TABLE IF NOT EXISTS `book_progress` (
`book_id` VARCHAR(64) NOT NULL,
`progress` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读进度百分比 (0-100)',
`chapter_uid` INT UNSIGNED DEFAULT NULL COMMENT '当前阅读章节 UID',
`chapter_offset` INT UNSIGNED DEFAULT NULL COMMENT '当前章节内偏移',
`update_time` INT UNSIGNED DEFAULT NULL COMMENT '最后阅读时间 (Unix 时间戳)',
`record_reading_time` INT UNSIGNED DEFAULT 0 COMMENT '累计阅读时长 (秒)',
`finish_time` INT UNSIGNED DEFAULT NULL COMMENT '读完时间 (Unix 时间戳)',
`is_start_reading` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已开始阅读',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='阅读进度';
-- ============================================================
-- 14. 划线内容(按需获取,来自 /book/bookmarklist)
-- ============================================================
CREATE TABLE IF NOT EXISTS `book_highlights` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL,
`bookmark_id` VARCHAR(64) DEFAULT NULL COMMENT '划线唯一 ID',
`chapter_uid` INT UNSIGNED DEFAULT NULL COMMENT '所在章节 UID',
`mark_text` LONGTEXT DEFAULT NULL COMMENT '划线原文',
`create_time` INT UNSIGNED DEFAULT NULL COMMENT '创建时间 (Unix 时间戳)',
`type` TINYINT DEFAULT NULL COMMENT '类型',
`range_text` VARCHAR(64) DEFAULT NULL COMMENT '位置范围',
`color_style` TINYINT DEFAULT NULL COMMENT '划线颜色样式',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_highlights_book_id` (`book_id`),
INDEX `idx_highlights_chapter` (`book_id`, `chapter_uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='划线内容';
-- ============================================================
-- 15. 章节信息(来自 /book/bookmarklist 的 chapters)
-- ============================================================
CREATE TABLE IF NOT EXISTS `book_chapters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL,
`chapter_uid` INT UNSIGNED NOT NULL,
`chapter_idx` INT UNSIGNED DEFAULT NULL COMMENT '章节序号',
`title` VARCHAR(255) DEFAULT NULL COMMENT '章节标题',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_book_chapter` (`book_id`, `chapter_uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='章节信息';
-- ============================================================
-- 16. 个人想法/点评(按需获取,来自 /review/list/mine)
-- ============================================================
CREATE TABLE IF NOT EXISTS `book_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL,
`review_id` VARCHAR(64) DEFAULT NULL COMMENT '想法唯一 ID',
`content` LONGTEXT DEFAULT NULL COMMENT '内容文本',
`create_time` INT UNSIGNED DEFAULT NULL COMMENT '创建时间 (Unix 时间戳)',
`star` TINYINT DEFAULT -1 COMMENT '评分 (0-5, -1=无评分)',
`chapter_name` VARCHAR(255) DEFAULT NULL COMMENT '所在章节名',
`is_finish` TINYINT(1) DEFAULT 0 COMMENT '是否书评',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_reviews_book_id` (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='个人想法/点评';
-- ============================================================
-- 17. 推荐书籍(按需获取,来自 /book/recommend)
-- ============================================================
CREATE TABLE IF NOT EXISTS `recommended_books` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`book_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) DEFAULT NULL,
`author` VARCHAR(255) DEFAULT NULL,
`cover` VARCHAR(512) DEFAULT NULL,
`intro` TEXT DEFAULT NULL,
`category` VARCHAR(128) DEFAULT NULL,
`reason` VARCHAR(512) DEFAULT NULL COMMENT '推荐理由',
`reading_count` INT UNSIGNED DEFAULT 0 COMMENT '在读人数',
`new_rating` DECIMAL(5,1) DEFAULT 0 COMMENT '评分 (百分制)',
`new_rating_count` INT UNSIGNED DEFAULT 0 COMMENT '评分人数',
`rating_title` VARCHAR(32) DEFAULT NULL COMMENT '评分标签 (神作/力荐等)',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_recommended_book_id` (`book_id`),
INDEX `idx_recommended_fetched` (`fetched_at` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='推荐书籍';
-- ============================================================
-- 18. 搜索结果(按需获取,来自 /store/search)
-- ============================================================
CREATE TABLE IF NOT EXISTS `search_results` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`keyword` VARCHAR(255) NOT NULL COMMENT '搜索关键词',
`book_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) DEFAULT NULL,
`author` VARCHAR(255) DEFAULT NULL,
`cover` VARCHAR(512) DEFAULT NULL,
`intro` TEXT DEFAULT NULL,
`publisher` VARCHAR(255) DEFAULT NULL,
`category` VARCHAR(128) DEFAULT NULL,
`reading_count` INT UNSIGNED DEFAULT 0,
`new_rating` DECIMAL(5,1) DEFAULT 0,
`new_rating_count` INT UNSIGNED DEFAULT 0,
`rating_title` VARCHAR(32) DEFAULT NULL,
`search_idx` INT UNSIGNED DEFAULT 0 COMMENT '搜索序号',
`fetched_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_search_keyword` (`keyword`),
INDEX `idx_search_book_id` (`book_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='搜索结果';
+448
View File
@@ -678,6 +678,454 @@ body {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ===== 推荐发现区 ===== */
.discover-section {
padding: 60px 0;
}
.discover-tabs {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 24px;
}
.discover-tab {
padding: 8px 24px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.discover-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.discover-controls {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 24px;
}
.btn-primary {
padding: 8px 20px;
border: none;
border-radius: var(--radius-sm);
background: var(--accent);
color: #fff;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--accent-dark);
}
.btn-secondary {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-secondary:hover {
border-color: var(--accent);
color: var(--accent);
}
.search-box {
display: flex;
gap: 12px;
max-width: 500px;
margin: 0 auto 24px;
}
.search-input {
flex: 1;
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--bg-card);
color: var(--text-primary);
}
.search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(193, 123, 74, 0.15);
}
.discover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.discover-card {
background: var(--bg-card);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.discover-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-hover);
}
.discover-cover {
width: 100%;
height: 180px;
object-fit: cover;
background: var(--bg-primary);
}
.discover-info {
padding: 14px 16px;
}
.discover-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discover-author {
font-size: 12px;
color: var(--text-light);
margin-bottom: 6px;
}
.discover-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.discover-rating {
font-size: 13px;
color: #e65100;
font-weight: 600;
}
.discover-rating-label {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: #fff3e0;
color: #e65100;
}
.discover-reading-count {
font-size: 11px;
color: var(--text-light);
}
.discover-reason {
margin-top: 6px;
font-size: 12px;
color: var(--accent);
background: rgba(193, 123, 74, 0.08);
padding: 4px 8px;
border-radius: 4px;
}
.discover-category {
font-size: 11px;
color: var(--text-light);
margin-top: 4px;
}
/* ===== 弹窗通用 ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
overflow-y: auto;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: var(--bg-card);
border-radius: var(--radius);
max-width: 700px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
margin-bottom: 40px;
}
.modal-close {
position: absolute;
top: 12px;
right: 16px;
font-size: 28px;
color: var(--text-light);
cursor: pointer;
z-index: 10;
line-height: 1;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 32px;
}
/* ===== 书籍详情弹窗 ===== */
.book-detail-modal .detail-header {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.book-detail-modal .detail-cover {
width: 120px;
height: 160px;
border-radius: 8px;
object-fit: cover;
box-shadow: var(--shadow);
flex-shrink: 0;
}
.book-detail-modal .detail-meta {
flex: 1;
}
.book-detail-modal .detail-title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
line-height: 1.4;
}
.book-detail-modal .detail-author {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.book-detail-modal .detail-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.book-detail-modal .detail-tag {
font-size: 12px;
padding: 2px 10px;
border-radius: 10px;
background: rgba(193, 123, 74, 0.1);
color: var(--accent-dark);
}
.book-detail-modal .detail-progress-info {
margin-top: 12px;
padding: 12px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
}
.book-detail-modal .detail-progress-bar {
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.book-detail-modal .detail-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.3s;
}
.book-detail-modal .detail-intro {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.8;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.book-detail-modal .detail-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.book-detail-modal .detail-info-item {
font-size: 13px;
color: var(--text-secondary);
}
.book-detail-modal .detail-info-item strong {
color: var(--text-primary);
}
.book-detail-modal .detail-actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
/* ===== 笔记详情弹窗 ===== */
.notes-detail-modal {
max-width: 800px;
}
.notes-detail-modal .notes-header {
display: flex;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.notes-detail-modal .notes-cover {
width: 60px;
height: 80px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
box-shadow: var(--shadow);
}
.notes-detail-modal .notes-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.notes-detail-modal .notes-author {
font-size: 13px;
color: var(--text-light);
margin-top: 4px;
}
.notes-detail-modal .notes-counts {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.notes-detail-modal .notes-counts strong {
color: var(--accent-dark);
}
.notes-detail-modal .notes-section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.notes-detail-modal .chapter-group {
margin-bottom: 20px;
}
.notes-detail-modal .chapter-title {
font-size: 13px;
font-weight: 600;
color: var(--accent-dark);
margin-bottom: 8px;
padding: 4px 0;
border-left: 3px solid var(--accent);
padding-left: 10px;
}
.notes-detail-modal .highlight-item {
padding: 10px 14px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.7;
border-left: 3px solid var(--accent-light);
}
.notes-detail-modal .highlight-date {
font-size: 11px;
color: var(--text-light);
margin-top: 4px;
}
.notes-detail-modal .review-item {
padding: 10px 14px;
background: #fff8e1;
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.7;
border-left: 3px solid #ffc107;
}
.notes-detail-modal .review-meta {
font-size: 11px;
color: var(--text-light);
margin-top: 4px;
}
.notes-detail-modal .review-star {
color: #ff9800;
}
.notes-detail-modal .empty-state {
text-align: center;
padding: 40px;
color: var(--text-light);
font-size: 14px;
}
/* ===== 响应式 ===== */ /* ===== 响应式 ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.nav-links { .nav-links {
+248 -4
View File
@@ -24,6 +24,7 @@ document.addEventListener("DOMContentLoaded", () => {
loadTopBooks(); loadTopBooks();
loadShelf(1); loadShelf(1);
loadNotebooks(); loadNotebooks();
loadRecommend(0);
initNavScroll(); initNavScroll();
}); });
@@ -525,7 +526,7 @@ async function loadNotebooks() {
.map((nb) => { .map((nb) => {
const progress = nb.reading_progress || 0; const progress = nb.reading_progress || 0;
return ` return `
<div class="notebook-card"> <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="${nb.cover}" alt="${nb.title}" <img class="notebook-cover" src="${nb.cover}" alt="${nb.title}"
onerror="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'"> onerror="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-info">
@@ -548,9 +549,252 @@ async function loadNotebooks() {
} }
} }
// ===== 打开书籍 ===== // ===== 打开书籍详情弹窗 =====
function openBook(bookId) { function openBook(bookId) {
if (bookId) { if (!bookId) return;
window.open(`weread://reading?bId=${bookId}`, "_blank"); 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="${data.cover || ''}" alt="${data.title}"
onerror="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="${cover || ''}" alt="${title}"
onerror="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="${cover || ''}" alt="${title}"
onerror="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 stars = r.star > 0 ? `<span class="review-star">${"★".repeat(r.star)}${"☆".repeat(5 - r.star)}</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="${book.cover || ''}" alt="${book.title}"
onerror="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("");
}
+44
View File
@@ -21,6 +21,7 @@
<a href="#top-books" class="nav-link">读书排行</a> <a href="#top-books" class="nav-link">读书排行</a>
<a href="#shelf" class="nav-link">书架</a> <a href="#shelf" class="nav-link">书架</a>
<a href="#notes" class="nav-link">笔记</a> <a href="#notes" class="nav-link">笔记</a>
<a href="#discover" class="nav-link">推荐</a>
</div> </div>
<div class="nav-update" id="fetchTime"></div> <div class="nav-update" id="fetchTime"></div>
</div> </div>
@@ -117,6 +118,49 @@
</div> </div>
</section> </section>
<!-- 推荐发现 -->
<section id="discover" class="discover-section">
<div class="container">
<h2 class="section-title">🔍 推荐发现</h2>
<div class="discover-tabs">
<button class="discover-tab active" id="tabRecommend" onclick="switchDiscoverTab('recommend')">为你推荐</button>
<button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button>
</div>
<!-- 推荐区 -->
<div id="recommendPanel" class="discover-panel">
<div class="discover-controls">
<button class="btn-primary" onclick="loadRecommend(0)">刷新推荐</button>
<button class="btn-secondary" onclick="loadMoreRecommend()">换一批</button>
</div>
<div class="discover-grid" id="recommendGrid"></div>
</div>
<!-- 搜索区 -->
<div id="searchPanel" class="discover-panel" style="display:none">
<div class="search-box">
<input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..." onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn-primary" onclick="doSearch()">搜索</button>
</div>
<div class="discover-grid" id="searchGrid"></div>
</div>
</div>
</section>
<!-- 书籍详情弹窗 -->
<div class="modal-overlay" id="bookDetailModal" onclick="closeModal('bookDetailModal')">
<div class="modal-content book-detail-modal" onclick="event.stopPropagation()">
<span class="modal-close" onclick="closeModal('bookDetailModal')">&times;</span>
<div id="bookDetailBody" class="modal-body"></div>
</div>
</div>
<!-- 笔记详情弹窗 -->
<div class="modal-overlay" id="notesDetailModal" onclick="closeModal('notesDetailModal')">
<div class="modal-content notes-detail-modal" onclick="event.stopPropagation()">
<span class="modal-close" onclick="closeModal('notesDetailModal')">&times;</span>
<div id="notesDetailBody" class="modal-body"></div>
</div>
</div>
<!-- 页脚 --> <!-- 页脚 -->
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">