diff --git a/app.py b/app.py index d994501..57d7d56 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import json import os import sqlite3 import time +import urllib.request from datetime import datetime 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") +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(): conn = sqlite3.connect(DB_PATH) @@ -371,5 +446,405 @@ def api_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__": app.run(host="0.0.0.0", port=8080, debug=False) diff --git a/fetch_data.py b/fetch_data.py index 41601ef..626b14b 100644 --- a/fetch_data.py +++ b/fetch_data.py @@ -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() return conn diff --git a/schema_mysql.sql b/schema_mysql.sql new file mode 100644 index 0000000..1fe7a89 --- /dev/null +++ b/schema_mysql.sql @@ -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='搜索结果'; diff --git a/static/css/style.css b/static/css/style.css index f7c1ead..5ed160a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -678,6 +678,454 @@ body { 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) { .nav-links { diff --git a/static/js/app.js b/static/js/app.js index 1862f6c..39b7592 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -24,6 +24,7 @@ document.addEventListener("DOMContentLoaded", () => { loadTopBooks(); loadShelf(1); loadNotebooks(); + loadRecommend(0); initNavScroll(); }); @@ -525,7 +526,7 @@ async function loadNotebooks() { .map((nb) => { const progress = nb.reading_progress || 0; return ` -
+
${nb.title}
@@ -548,9 +549,252 @@ async function loadNotebooks() { } } -// ===== 打开书籍 ===== +// ===== 打开书籍详情弹窗 ===== function openBook(bookId) { - if (bookId) { - window.open(`weread://reading?bId=${bookId}`, "_blank"); + if (!bookId) return; + showModal("bookDetailModal"); + const body = document.getElementById("bookDetailBody"); + body.innerHTML = `
`; + + 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 = ` +
+ ${data.title} +
+
${data.title}
+
${data.author || ''}${data.translator ? ' / 译: ' + data.translator : ''}
+
${tags.map(t => `${t}`).join('')}
+ ${progress.is_start_reading ? ` +
+
+ 阅读进度: ${progress.progress}% + ${progress.record_reading_time_str ? ' · 累计 ' + progress.record_reading_time_str : ''} + ${progress.finish_time ? ' · 读完于 ' + progress.finish_time : ''} +
+
+
+
+
` : '
尚未开始阅读
'} +
+
+ ${data.intro ? `
${data.intro}
` : ''} +
+ ${data.publisher ? `
出版社: ${data.publisher}
` : ''} + ${data.publish_time ? `
出版时间: ${data.publish_time}
` : ''} + ${data.isbn ? `
ISBN: ${data.isbn}
` : ''} + ${data.word_count ? `
字数: ${(data.word_count / 10000).toFixed(1)}万字
` : ''} +
+
+ +
+ `; + }) + .catch((e) => { + body.innerHTML = `
加载失败: ${e.message}
`; + }); +} + +// ===== 打开笔记详情弹窗 ===== +function openNotes(bookId, title, author, cover) { + if (!bookId) return; + showModal("notesDetailModal"); + const body = document.getElementById("notesDetailBody"); + body.innerHTML = ` +
+ ${title} +
+
${title}
+
${author || ''}
+
+
+
+ `; + + fetchAPI(`/api/book/notes?book_id=${bookId}`) + .then((data) => { + let html = ` +
+ ${title} +
+
${title}
+
${author || ''}
+
+ 划线 ${data.highlight_count} + 想法/点评 ${data.review_count} +
+
+
+ `; + + // 划线区 + if (data.highlight_count > 0) { + html += `
📌 划线 (${data.highlight_count})
`; + const grouped = data.grouped_highlights; + for (const [chTitle, items] of Object.entries(grouped)) { + html += `
`; + html += `
${chTitle}
`; + items.forEach((h) => { + html += ` +
+ ${escapeHtml(h.mark_text)} +
${h.create_time || ''}
+
+ `; + }); + html += `
`; + } + } + + // 想法/点评区 + if (data.review_count > 0) { + html += `
💭 想法/点评 (${data.review_count})
`; + data.reviews.forEach((r) => { + const stars = r.star > 0 ? `${"★".repeat(r.star)}${"☆".repeat(5 - r.star)}` : ""; + html += ` +
+ ${escapeHtml(r.content)} +
+ ${stars} + ${r.chapter_name ? ' · ' + r.chapter_name : ''} + ${r.is_finish ? ' · 书评' : ''} + · ${r.create_time || ''} +
+
+ `; + }); + } + + if (data.highlight_count === 0 && data.review_count === 0) { + html += `
暂无笔记数据
`; + } + + body.innerHTML = html; + }) + .catch((e) => { + body.innerHTML = `
加载失败: ${e.message}
`; + }); +} + +// ===== 弹窗控制 ===== +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 = `
`; + + try { + const data = await fetchAPI(`/api/recommend?maxIdx=${maxIdx}`); + recommendMaxIdx = data.last_search_idx || 0; + renderDiscoverGrid(grid, data.books, true); + } catch (e) { + grid.innerHTML = `
加载推荐失败: ${e.message}
`; + } +} + +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 = `
`; + + try { + const data = await fetchAPI(`/api/search?keyword=${encodeURIComponent(keyword)}`); + if (data.books.length === 0) { + grid.innerHTML = `
未找到相关书籍
`; + return; + } + renderDiscoverGrid(grid, data.books, false); + } catch (e) { + grid.innerHTML = `
搜索失败: ${e.message}
`; + } +} + +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 ? `${book.rating_title}` : ""; + const ratingHtml = rating ? `${rating}` : ""; + const readingHtml = book.reading_count ? `${book.reading_count}人在读` : ""; + const reasonHtml = (showReason && book.reason) ? `
${book.reason}
` : ""; + const categoryHtml = book.category ? `
${book.category}
` : ""; + + return ` +
+ ${book.title} +
+
${book.title}
+
${book.author || ''}
+
${ratingHtml}${ratingLabel}${readingHtml}
+ ${categoryHtml} + ${reasonHtml} +
+
+ `; + }) + .join(""); +} diff --git a/templates/index.html b/templates/index.html index 4faf5eb..2817d4d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@ 读书排行 书架 笔记 + 推荐
@@ -117,6 +118,49 @@
+ +
+
+

🔍 推荐发现

+
+ + +
+ +
+
+ + +
+
+
+ + +
+
+ + + + + + +