diff --git a/.gitignore b/.gitignore index 7363bf8..0e2e76c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ Thumbs.db # Env .env .server.pid + +# WorkBuddy workspace memory +.workbuddy/ diff --git a/README.md b/README.md index ce09b81..189040c 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,29 @@ # 微信读书数据展示网站 -基于微信读书 API 的个人读书数据展示网站,数据存储在本地 SQLite 数据库中。 +基于微信读书 API 的个人读书数据展示网站。 + +- **v2「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/v2/*` 实时聚合接口,见 `docs/V2_API.md`。 +- **v1 仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/v1/`。 ## 项目结构 ``` . ├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite) -├── app.py # Flask 后端服务 +├── app.py # Flask 后端服务(v1 接口 + 注册 v2 Blueprint) +├── api_v2.py # v2 数据接口层(/api/v2/*,供「阅间」前端使用) +├── start_dashboard.sh # 启动脚本(端口可用 PORT 覆盖) +├── legacy/v1/ # v1 前端备份(index.html / style.css / app.js) ├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成) ├── requirements.txt # Python 依赖 ├── templates/ -│ └── index.html # 前端页面 +│ └── index.html # 「阅间」前端页面(v2 设计稿) └── static/ - ├── css/style.css # 样式文件 - └── js/app.js # 前端逻辑 + ├── css/styles.css # v2 样式(设计稿原样) + ├── css/style.css # v1 样式(保留) + └── js/ + ├── weapi.js # v2 数据层(封装 /api/v2/*) + └── app.js # v2 交互逻辑 ``` ## 快速开始 @@ -42,10 +51,15 @@ python fetch_data.py ### 4. 启动网站 ```bash -python app.py +./start_dashboard.sh # 默认 8770 端口 +PORT=8080 ./start_dashboard.sh # 或指定端口 ``` -访问 http://127.0.0.1:8080 即可查看读书数据展示。 +也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖), +然后访问 http://127.0.0.1:8770 查看「阅间」看板。 + +> v2 的书架、统计、推荐、搜索等数据实时来自微信读书接口; +> 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。 ## 功能模块 diff --git a/api_v2.py b/api_v2.py new file mode 100644 index 0000000..3d938e3 --- /dev/null +++ b/api_v2.py @@ -0,0 +1,1677 @@ +#!/usr/bin/env python3 +""" +阅间 · 微信读书看板 v2 数据接口层 +================================== +为 v2 前端(阅间设计版)提供数据聚合接口。 + +设计原则: + 1. 不修改 v1 的任何接口,v2 全部挂在 /api/v2/* 下 + 2. 以微信读书 Agent Gateway 实时数据为主、本地 MySQL 为缓存与聚合补充 + 3. MySQL 不可用时降级运行(实时接口仍可用),只在响应里标注 degraded + 4. 热点接口带进程内 TTL 缓存,避免频繁打微信读书接口 + +作者:WorkBuddy +创建时间:2026-09-11 +""" + +import hashlib +import json +import os +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from functools import wraps + +import pymysql +from flask import Blueprint, jsonify, request + +v2_bp = Blueprint("v2", __name__) + +# ====================================================================== +# 配置 +# ====================================================================== + +WEREAD_API_URL = os.environ.get("WEREAD_API_URL", "https://i.weread.qq.com/api/agent/gateway") +WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "") +WEREAD_SKILL_VERSION = os.environ.get("WEREAD_SKILL_VERSION", "1.0.4") + +MYSQL_CONFIG = { + "host": os.environ.get("WEREAD_DB_HOST", "127.0.0.1"), + "port": int(os.environ.get("WEREAD_DB_PORT", 3306)), + "user": os.environ.get("WEREAD_DB_USER", "root"), + "password": os.environ.get("WEREAD_DB_PASSWORD", "258963"), + "database": os.environ.get("WEREAD_DB_NAME", "weread_dashboard"), + "charset": "utf8mb4", + "cursorclass": pymysql.cursors.DictCursor, + "connect_timeout": 5, +} + +CST = timezone(timedelta(hours=8)) + +# 进程内缓存 {key: (expire_ts, value)} +_CACHE = {} +_CACHE_LOCK = None + + +def cached(key, ttl, producer): + """带 TTL 的进程内缓存;producer 抛异常时不写缓存。""" + now = time.time() + hit = _CACHE.get(key) + if hit and hit[0] > now: + return hit[1] + value = producer() + _CACHE[key] = (now + ttl, value) + return value + + +# ====================================================================== +# 微信读书接口调用 +# ====================================================================== + + +class WeReadError(RuntimeError): + """微信读书接口调用异常。""" + + +def weapi(api_name, ttl=0, **kwargs): + """ + 调用微信读书 Agent Gateway。 + + Args: + api_name: 接口名,如 "/readdata/detail" + ttl: 进程内缓存秒数,0 表示不缓存 + **kwargs: 业务参数(平铺在 body 顶层) + """ + if not WEREAD_API_KEY: + raise WeReadError("WEREAD_API_KEY 未设置") + + cache_key = f"api:{api_name}:{json.dumps(kwargs, sort_keys=True, ensure_ascii=False)}" + + def _produce(): + 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", + ) + try: + with urllib.request.urlopen(req, timeout=25) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + raise WeReadError(f"{api_name} 调用失败: HTTP {e.code}") + except Exception as e: # noqa: BLE001 + raise WeReadError(f"{api_name} 调用失败: {e}") + + errcode = data.get("errcode") + if errcode: + raise WeReadError(f"{api_name} 返回错误 {errcode}: {data.get('errmsg', '')}") + return data + + if ttl: + return cached(cache_key, ttl, _produce) + return _produce() + + +def weapi_safe(api_name, ttl=0, default=None, **kwargs): + """调用失败时返回 default,不抛异常(用于可降级的数据项)。""" + try: + return weapi(api_name, ttl=ttl, **kwargs) + except Exception: # noqa: BLE001 + return default + + +# ====================================================================== +# 数据库(可降级) +# ====================================================================== + + +def db_or_none(): + """返回 MySQL 连接;不可用时返回 None(接口降级运行)。""" + try: + return pymysql.connect(**MYSQL_CONFIG) + except Exception: # noqa: BLE001 + return None + + +def with_db(f): + """ + 装饰器:注入关键字参数 conn(可能为 None)与 degraded 标记。 + 请求结束后关闭连接。 + """ + + @wraps(f) + def wrapper(*args, **kwargs): + conn = db_or_none() + try: + result = f(conn=conn, *args, **kwargs) + return result + finally: + if conn: + conn.close() + + return wrapper + + +def query_all(conn, sql, params=None): + if not conn: + return [] + with conn.cursor() as cur: + cur.execute(sql, params or ()) + return list(cur.fetchall()) + + +def query_one(conn, sql, params=None): + rows = query_all(conn, sql, params) + return rows[0] if rows else None + + +# ====================================================================== +# 格式化与展示辅助 +# ====================================================================== + + +def ts_to_date(ts): + """时间戳 → YYYY-MM-DD。""" + try: + ts = int(ts) + except (TypeError, ValueError): + return "" + if ts <= 0: + return "" + return datetime.fromtimestamp(ts, tz=CST).strftime("%Y-%m-%d") + + +def duration_str(seconds): + """秒 → X小时Y分钟 / Y分钟。""" + seconds = int(seconds or 0) + hours, minutes = seconds // 3600, (seconds % 3600) // 60 + if hours > 0: + return f"{hours}小时{minutes}分钟" + return f"{minutes}分钟" + + +def cover_class(book_id): + """按 book_id 稳定映射到设计稿的 4 种封面底色(c1~c4)。""" + digest = hashlib.md5(str(book_id).encode("utf-8")).hexdigest() + return f"c{int(digest[:8], 16) % 4 + 1}" + + +def short_title(title): + """把书名切成封面上的短行(最多 3 行)。""" + text = (title or "").strip() + for ch in "《》": + text = text.replace(ch, "") + # 去掉括号里的副标题,封面只留主书名 + for left, right in (("(", ")"), ("(", ")"), ("【", "】"), ("[", "]")): + while left in text and right in text: + start = text.index(left) + end = text.index(right, start) + text = text[:start] + text[end + 1 :] + text = text.split("·")[0].strip() + if not text: + text = (title or "未命名").strip() + if len(text) <= 5: + return text + # 按标点优先断行,其次按长度均分 + for sep in (":", ":", ",", ",", " "): + if sep in text: + head = text.split(sep)[0] + if 2 <= len(head) <= 8: + return head + line_count = 3 if len(text) > 10 else 2 + size = (len(text) + line_count - 1) // line_count + return "\n".join(text[i : i + size] for i in range(0, len(text), size)) + + +def norm_progress(value): + """进度统一到 0~100 的整数。""" + try: + value = float(value or 0) + except (TypeError, ValueError): + return 0 + if value <= 0: + return 0 + if value > 100: # 有的字段是万分比 + value = value / 100 + return max(0, min(100, int(round(value)))) + + +RECENT_DAYS = 30 # 「在读」判定窗口:有进度,或最近 30 天内有阅读记录 + + +def book_status(finish, progress, read_update_time, recent_days=RECENT_DAYS): + """ + 书籍状态口径: + done —— 已读完(接口 finish_reading=1) + reading—— 有阅读进度(0= cutoff: + return "reading" + return "wish" + + +def make_book( + book_id, + title, + author=None, + category=None, + finish=0, + read_update_time=0, + cover_url=None, + deep_link=None, + progress=None, + rating=None, + read_time=None, + intro=None, +): + """ + 组装成 v2 前端使用的书籍结构(字段名与设计稿一致)。 + """ + finish = int(finish or 0) + read_update_time = int(read_update_time or 0) + if progress is None: + progress = 100 if finish else 0 + if rating: + score = round(float(rating) / 100, 1) # 771 → 7.7 → 前端展示 77/100 + else: + score = None + return { + "id": str(book_id), + "bookId": str(book_id), + "title": title or "未命名", + "short": short_title(title), + "author": author or "佚名", + "en": "", + "cover": cover_class(book_id), + "cover_url": cover_url or "", + "progress": norm_progress(progress), + "status": book_status(finish, progress, read_update_time), + "score": score, + "desc": intro or (f"书架中「{category}」分类的藏书。" if category else "书架中的藏书。"), + "category": category or "", + "read_time": read_time or 0, + "read_time_str": duration_str(read_time) if read_time else "", + "read_update_time": ts_to_date(read_update_time), + "finish_reading": finish, + "deep_link": deep_link or "", + } + + +# ====================================================================== +# 书架:实时数据 + 本地进度/评分缓存合并 +# ====================================================================== + + +def shelf_counts(payload): + """按接口规范统计书架条目:电子书 + 专辑 + 文章收藏入口。""" + books = payload.get("books") or [] + albums = payload.get("albums") or [] + has_mp = 1 if payload.get("mp") else 0 + return { + "ebooks": len(books), + "albums": len(albums), + "mp": has_mp, + "total": len(books) + len(albums) + has_mp, + "archives": len(payload.get("archive") or []), + } + + +def shelf_payload(): + """书架原始数据(缓存 10 分钟)。""" + return weapi("/shelf/sync", ttl=600) + + +def progress_map(conn): + """从本地缓存表收集阅读进度:notebooks.reading_progress 优先,其次 book_progress。""" + result = {} + for row in query_all( + conn, + "SELECT book_id, reading_progress AS p FROM notebooks WHERE reading_progress > 0", + ): + result[str(row["book_id"])] = row["p"] + for row in query_all( + conn, "SELECT book_id, progress AS p FROM book_progress WHERE progress > 0" + ): + result.setdefault(str(row["book_id"]), row["p"]) + return result + + +def rating_map(conn): + """从本地缓存表收集评分(0~1000)。""" + rows = query_all(conn, "SELECT book_id, new_rating AS r FROM book_details WHERE new_rating > 0") + return {str(row["book_id"]): row["r"] for row in rows} + + +def intro_map(conn): + rows = query_all(conn, "SELECT book_id, intro, word_count FROM book_details") + return {str(row["book_id"]): row for row in rows} + + +def persist_detail(conn, bid, info): + """把书籍基本信息写入缓存表(供后续快速读取评分/简介)。""" + if not conn or not info: + return + rating_detail = info.get("newRatingDetail") + try: + with conn.cursor() as cur: + cur.execute( + "REPLACE INTO book_details " + "(book_id, title, author, translator, cover, intro, category, publisher, " + "publish_time, isbn, word_count, new_rating, new_rating_count, new_rating_detail, deep_link) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + str(bid), + 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") or 0, + info.get("newRating") or 0, + info.get("newRatingCount") or 0, + json.dumps(rating_detail, ensure_ascii=False) if isinstance(rating_detail, dict) else None, + info.get("deepLink", ""), + ), + ) + conn.commit() + except Exception: # noqa: BLE001 + pass + + +def persist_progress(conn, bid, info): + """把阅读进度写入缓存表。""" + if not conn or not info: + return + try: + with conn.cursor() as cur: + cur.execute( + "REPLACE INTO book_progress " + "(book_id, progress, chapter_uid, chapter_offset, update_time, update_time_dt, " + "record_reading_time, finish_time, finish_time_dt, is_start_reading) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + str(bid), + int(info.get("progress") or 0), + info.get("chapter_uid") or 0, + info.get("chapter_offset") or 0, + info.get("update_time") or 0, + ts_to_datetime(info.get("update_time") or 0), + int(info.get("record_reading_time") or 0), + info.get("finish_time") or 0, + ts_to_datetime(info.get("finish_time") or 0), + info.get("is_start_reading") or 0, + ), + ) + conn.commit() + except Exception: # noqa: BLE001 + pass + + +def ts_to_datetime(ts): + try: + ts = int(ts) + except (TypeError, ValueError): + return None + if ts <= 0: + return None + return datetime.fromtimestamp(ts, tz=CST).strftime("%Y-%m-%d %H:%M:%S") + + +def enrich_books(conn, book_ids, with_rating=True, workers=6): + """ + 首屏富化:并发补齐阅读进度(并可同时补评分),结果写入本地缓存表, + 下次访问直接命中缓存。返回 {book_id: {progress, rating, ...}}。 + """ + book_ids = [str(b) for b in book_ids if b] + if not book_ids: + return {} + + def one(bid): + result = {"progress": None, "rating": None} + prog = weapi_safe("/book/getprogress", ttl=600, bookId=bid) + if prog: + book = prog.get("book") or {} + result["progress"] = { + "progress": norm_progress(book.get("progress")), + "record_reading_time": book.get("recordReadingTime") or 0, + "finish_time": book.get("finishTime") or 0, + "update_time": book.get("updateTime") or 0, + "is_start_reading": book.get("isStartReading") or 0, + "chapter_uid": book.get("chapterUid") or 0, + "chapter_offset": book.get("chapterOffset") or 0, + } + persist_progress(conn, bid, result["progress"]) + if with_rating: + info = weapi_safe("/book/info", ttl=1800, bookId=bid) + if info and info.get("newRating"): + result["rating"] = info.get("newRating") + persist_detail(conn, bid, info) + return bid, result + + with ThreadPoolExecutor(max_workers=workers) as pool: + return {bid: info for bid, info in pool.map(one, book_ids)} + + +def shelf_books(conn, with_progress=False, progress_limit=30): + """ + 书架书籍列表(设计稿字段结构)。 + + Args: + with_progress: 是否富化首屏书籍(实时补拉进度与评分,并写入本地缓存) + progress_limit: 单次富化的最大本数 + """ + payload = shelf_payload() + known_progress = progress_map(conn) + known_rating = rating_map(conn) + + rows = payload.get("books") or [] + books = [] + for row in rows: + bid = str(row.get("bookId") or "") + if not bid: + continue + books.append( + make_book( + bid, + row.get("title"), + row.get("author"), + row.get("category"), + finish=row.get("finishReading"), + read_update_time=row.get("readUpdateTime"), + cover_url=row.get("cover"), + deep_link=row.get("deepLink"), + progress=known_progress.get(bid), + rating=known_rating.get(bid), + ) + ) + + # 首屏富化:进度与评分缺失的书并发补齐(结果落库,下次直接命中缓存) + if with_progress: + missing = [b["id"] for b in books if b["id"] not in known_progress or b["id"] not in known_rating] + enriched = enrich_books(conn, missing[:progress_limit]) + for book in books: + info = enriched.get(book["id"]) + if not info: + continue + prog = info.get("progress") + if prog: + if prog["progress"]: + book["progress"] = prog["progress"] + if prog["record_reading_time"]: + book["read_time"] = prog["record_reading_time"] + book["read_time_str"] = duration_str(prog["record_reading_time"]) + if prog["finish_time"]: + book["status"] = "done" + elif not book["read_update_time"] and prog["update_time"]: + book["read_update_time"] = ts_to_date(prog["update_time"]) + if info.get("rating"): + book["score"] = round(float(info["rating"]) / 100, 1) + if book["status"] != "done": + book["status"] = book_status(book["finish_reading"], book["progress"], int(_ts(book["read_update_time"])), 30) + + return books, payload + + +def _ts(date_text): + """YYYY-MM-DD → 时间戳(无法解析返回 0)。""" + if not date_text: + return 0 + try: + return int(datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=CST).timestamp()) + except ValueError: + return 0 + + +# ====================================================================== +# 接口:连接状态 +# ====================================================================== + + +@v2_bp.route("/api/v2/status") +@with_db +def api_v2_status(conn): + """前端顶栏/横幅使用:数据源连接状态与书架规模。""" + db_ok = conn is not None + counts, err = None, None + try: + payload = shelf_payload() + counts = shelf_counts(payload) + except Exception as e: # noqa: BLE001 + err = str(e) + + fetch_time = "" + row = query_one(conn, "SELECT value FROM fetch_meta WHERE `key`='last_fetch_time'") + if row: + fetch_time = row["value"] + + return jsonify( + { + "ok": bool(counts), + "api_key_set": bool(WEREAD_API_KEY), + "database": "connected" if db_ok else "disconnected", + "fetch_time": fetch_time, + "counts": counts, + "error": err, + "degraded": not db_ok, + } + ) + + +# ====================================================================== +# 接口:阅读概览(趋势 + 统计 + 洞察,一次请求返回整页数据) +# ====================================================================== + +PERIOD_MODE = {"week": "weekly", "month": "monthly", "year": "annually", "overall": "overall"} +PERIOD_HINT = {"week": "本周", "month": "本月", "year": "本年", "overall": "总计"} + + +def natural_days(period, regist_time=0): + """周期自然天数(未满周期按已过天数计)。""" + now = datetime.now(tz=CST) + if period == "week": + return 7 + if period == "month": + return now.day + if period == "year": + return now.timetuple().tm_yday + if regist_time: + try: + start = datetime.fromtimestamp(int(regist_time), tz=CST) + return max(1, (now - start).days + 1) + except (ValueError, OSError): + return 365 + return 365 + + +def build_trend(period, read_times): + """把 readTimes 字典转成设计稿柱状图数据。""" + items = sorted(((int(k), int(v or 0)) for k, v in (read_times or {}).items()), key=lambda x: x[0]) + + is_hour_unit = period in ("year", "overall") + + def shown(duration): + return round(duration / 3600, 1) if is_hour_unit else int(round(duration / 60)) + + # 去掉首尾「折算后为 0」的空桶(未到的月份 / 注册初期的年份) + while len(items) > 1 and shown(items[-1][1]) == 0: + items.pop() + while len(items) > 1 and shown(items[0][1]) == 0: + items.pop(0) + + labels, values, seconds = [], [], [] + for ts, duration in items: + dt = datetime.fromtimestamp(ts, tz=CST) + if period == "week": + labels.append("一二三四五六日"[dt.weekday()]) + elif period == "month": + labels.append(f"{dt.day}日") + elif period == "year": + labels.append(f"{dt.month}月") + else: + labels.append(str(dt.year)) + values.append(shown(duration)) + seconds.append(duration) + + total_seconds = sum(seconds) + return { + "labels": labels, + "values": values, + "unit": "时" if is_hour_unit else "分", + "minutes": round(total_seconds / 60, 1), + "total_seconds": total_seconds, + } + + +def dedupe_by_book(items, key="bookId", limit=None): + """按书籍去重(接口返回里同一本书可能出现多条)。""" + seen, out = set(), [] + for item in items or []: + k = item.get(key) or item.get("book_id") or item.get("title") + if k in seen: + continue + seen.add(k) + out.append(item) + return out[:limit] if limit else out + + +@v2_bp.route("/api/v2/dashboard") +@with_db +def api_v2_dashboard(conn): + """ + 阅读概览整页数据:周期趋势、统计卡、读得最多、偏好方向、时段分布、阅读画像。 + Query: period=week|month|year|overall(默认 week) + """ + period = request.args.get("period", "week") + if period not in PERIOD_MODE: + period = "week" + mode = PERIOD_MODE[period] + + data = weapi_safe("/readdata/detail", ttl=300, mode=mode, **({"baseTime": 0} if mode != "overall" else {})) + if not data: + return jsonify({"error": "阅读统计获取失败,请稍后重试"}), 502 + + # 周/月维度接口不回偏好画像字段,缺失项用「总计」口径补齐并标注来源 + pref_source = "period" + pref_data = data + if mode != "overall" and not (data.get("preferCategory") and data.get("preferTime")): + fallback = weapi_safe("/readdata/detail", ttl=600, mode="overall") or {} + if fallback: + pref_source = "overall" + merged = dict(fallback) + for k in ("preferCategory", "preferTime", "preferAuthor", "preferCp", "preferPublisher", "medals", "preferCategoryWord", "preferTimeWord", "preferBooks"): + if data.get(k): + merged[k] = data[k] + pref_data = merged + + trend = build_trend(period, data.get("readTimes") or {}) + total_seconds = int(data.get("totalReadTime") or trend["total_seconds"] or 0) + total_minutes = total_seconds / 60 + read_days = int(data.get("readDays") or 0) + days = natural_days(period, data.get("registTime")) + + # 笔记口径:本地笔记本合计(全量);周期口径见 readStat + note_row = query_one( + conn, + "SELECT COALESCE(SUM(review_count),0) AS review, COALESCE(SUM(note_count),0) AS note, " + "COALESCE(SUM(bookmark_count),0) AS bookmark, COUNT(*) AS books FROM notebooks", + ) or {} + note_review = int(note_row.get("review") or 0) + note_note = int(note_row.get("note") or 0) + note_bookmark = int(note_row.get("bookmark") or 0) + note_total = note_review + note_note + note_bookmark + note_books = int(note_row.get("books") or 0) + + stats = [ + { + "label": "阅读 / 收听时长", + "value": duration_str(total_seconds), + "hint": f"{PERIOD_HINT[period]}合计", + "icon": "clock", + }, + { + "label": "有效阅读天数", + "value": f"{read_days}天", + "hint": "单日满 1 分钟计一天", + "icon": "flag", + }, + { + "label": "自然日均阅读", + "value": f"{round(total_minutes / days)}分钟" if days else "0分钟", + "hint": f"按 {days} 个自然日折算", + "icon": "chart", + }, + { + "label": "笔记总数", + "value": f"{note_total}条", + "hint": f"划线 {note_note} · 想法 {note_review} · 书签 {note_bookmark}", + "icon": "note", + }, + ] + + # 读得最多(周期内) + top_books = [] + for item in dedupe_by_book(data.get("readLongest") or [], limit=3): + book = item.get("book") or {} + album = item.get("albumInfo") or {} + top_books.append( + { + "id": str(book.get("bookId") or album.get("albumId") or ""), + "title": book.get("title") or album.get("name") or "", + "author": book.get("author") or album.get("authorName") or "", + "read_time": int(item.get("readTime") or 0), + "read_time_str": duration_str(item.get("readTime") or 0), + } + ) + + # 偏爱的阅读方向(按分类合并同年份重复条目,权重归一化到 100) + cat_agg = {} + for cat in pref_data.get("preferCategory") or []: + title = cat.get("categoryTitle") or "" + if not title: + continue + entry = cat_agg.setdefault( + title, + {"title": title, "parent": cat.get("parentCategoryTitle") or "", "seconds": 0, "count": 0}, + ) + entry["seconds"] += int(cat.get("readingTime") or 0) + entry["count"] += int(cat.get("readingCount") or 0) + ranked = sorted(cat_agg.values(), key=lambda c: c["seconds"], reverse=True)[:3] + max_seconds = max([c["seconds"] for c in ranked] or [0]) or 1 + cats = [ + { + "title": c["title"], + "parent": c["parent"], + "val": max(1, round(c["seconds"] / max_seconds * 100)), + "reading_time_str": duration_str(c["seconds"]), + "reading_count": c["count"], + } + for c in ranked + ] + max_val = max([c["val"] for c in cats] or [0]) or 1 + + # 留给自己的时段(接口按 06:00 起 24 个桶返回) + prefer_time = list(pref_data.get("preferTime") or []) + hours = [(6 + i) % 24 for i in range(len(prefer_time))] + max_hour = max(prefer_time) if prefer_time else 0 + peak_hour = hours[prefer_time.index(max_hour)] if max_hour else None + + # 阅读画像 + authors = [] + for a in dedupe_by_book(pref_data.get("preferAuthor") or [], key="name", limit=3): + authors.append({"name": a.get("name") or "", "count": a.get("count") or 0, "read_time": a.get("readTime") or ""}) + publishers = [ + {"name": p.get("name") or "", "count": p.get("count") or 0} + for p in dedupe_by_book(pref_data.get("preferPublisher") or [], key="name", limit=3) + ] + cps = [ + {"name": (cp.get("copyrightInfo") or {}).get("name") or "", "count": cp.get("count") or 0} + for cp in dedupe_by_book(pref_data.get("preferCp") or [], key="name", limit=3) + ] + medals = [] + for m in dedupe_by_book(pref_data.get("medals") or [], key="name", limit=8): + medals.append( + { + "name": m.get("name") or m.get("title") or "", + "detail": m.get("rankText") or m.get("hint") or "", + "date": ts_to_date(m.get("ctime") or 0), + } + ) + rank = data.get("rank") or pref_data.get("rank") or {} + + read_stat = {s.get("stat"): s.get("counts") for s in (pref_data.get("readStat") or [])} + regists = ts_to_date(pref_data.get("registTime") or data.get("registTime") or 0) + + # 年度小结(真实数据,用于“年度回顾”弹窗) + year_data = data if mode == "annually" else weapi_safe("/readdata/detail", ttl=300, mode="annually", baseTime=0) + year_summary = {} + if year_data: + year_stat = {s.get("stat"): s.get("counts") for s in (year_data.get("readStat") or [])} + year_top = dedupe_by_book(year_data.get("readLongest") or [], limit=3) + year_summary = { + "year": datetime.now(tz=CST).year, + "total_seconds": int(year_data.get("totalReadTime") or 0), + "duration_str": duration_str(year_data.get("totalReadTime") or 0), + "read_days": int(year_data.get("readDays") or 0), + "prefer_category": year_data.get("preferCategoryWord") or "", + "prefer_time": year_data.get("preferTimeWord") or "", + "read_count": year_stat.get("读过") or "", + "finish_count": year_stat.get("读完") or "", + "note_count": year_stat.get("笔记") or "", + "top_books": [ + { + "title": (t.get("book") or {}).get("title") or (t.get("albumInfo") or {}).get("name") or "", + "read_time_str": duration_str(t.get("readTime") or 0), + } + for t in year_top + ], + } + + return jsonify( + { + "period": period, + "mode": mode, + "trend": trend, + "stats": stats, + "top_books": top_books, + "categories": { + "items": cats, + "max_val": max_val, + "word": pref_data.get("preferCategoryWord") or "", + }, + "time_distribution": { + "values": prefer_time, + "hours": hours, + "max": max_hour, + "peak_hour": peak_hour, + "word": pref_data.get("preferTimeWord") or "", + }, + "authors": authors, + "publishers": publishers, + "cps": cps, + "medals": medals, + "rank": rank, + "read_stat": read_stat, + "prefer_words": { + "category": pref_data.get("preferCategoryWord") or "", + "time": pref_data.get("preferTimeWord") or "", + "records": pref_data.get("readRecordsWord") or "", + "distribution": pref_data.get("readDistributionWord") or "", + }, + "preference_source": pref_source, + "notes": { + "total": note_total, + "review": note_review, + "note": note_note, + "bookmark": note_bookmark, + "books": note_books, + }, + "regist_time": regists, + "year_summary": year_summary, + "degraded": conn is None, + } + ) + + +# ====================================================================== +# 接口:书架 +# ====================================================================== + +SHELF_TABS = ("all", "reading", "done", "wish", "lists", "audio", "articles") + + +@v2_bp.route("/api/v2/shelf") +@with_db +def api_v2_shelf(conn): + """ + 书架数据。 + Query: + tab: all|reading|done|wish|lists|audio|articles + keyword: 书名/作者筛选 + limit/offset: 分页(默认全部返回,前端做本地筛选时传 tab=all) + progress: 1 时为首屏书籍实时补拉进度 + """ + tab = request.args.get("tab", "all") + keyword = (request.args.get("keyword") or "").strip().lower() + limit = request.args.get("limit", type=int) + offset = request.args.get("offset", type=int, default=0) + with_progress = request.args.get("progress", "0") == "1" + + try: + books, payload = shelf_books(conn, with_progress=with_progress) + except WeReadError as e: + return jsonify({"error": str(e)}), 502 + + counts = shelf_counts(payload) + counts.update( + { + "reading": sum(1 for b in books if b["status"] == "reading"), + "done": sum(1 for b in books if b["status"] == "done"), + "wish": sum(1 for b in books if b["status"] == "wish"), + } + ) + + if keyword: + books = [b for b in books if keyword in (b["title"] + b["author"] + b["category"]).lower()] + if tab in ("reading", "done", "wish"): + books = [b for b in books if b["status"] == tab] + elif tab == "all": + pass + + total = len(books) + page_books = books[offset : offset + limit] if limit else books[offset:] + + archives = [] + for arc in payload.get("archive") or []: + ids = [str(i) for i in (arc.get("bookIds") or [])] + archives.append( + { + "id": arc.get("name") or "", + "name": arc.get("name") or "", + "source": "我的书单", + "description": f"书架书单 · 收录 {len(ids)} 本", + "ids": ids, + "book_count": len(ids), + "album_ids": arc.get("albumIds") or [], + } + ) + + albums = [ + { + "id": str(a.get("albumId") or ""), + "title": a.get("name") or "", + "author": a.get("authorName") or "", + "cover": a.get("cover") or "", + "count": a.get("trackCount") or 0, + "finish": a.get("finish") or 0, + "intro": a.get("intro") or "", + } + for a in (payload.get("albums") or []) + ] + + mp = payload.get("mp") or {} + + return jsonify( + { + "tab": tab, + "counts": counts, + "total": total, + "books": page_books, + "lists": archives, + "albums": albums, + "mp": {"title": (mp.get("book") or {}).get("title") or "", "show": mp.get("show") or 0}, + "degraded": conn is None, + } + ) + + +# ====================================================================== +# 接口:笔记本与笔记 +# ====================================================================== + + +@v2_bp.route("/api/v2/notebooks") +@with_db +def api_v2_notebooks(conn): + """笔记本概览(有笔记的书 + 各自数量),本地库数据。""" + rows = query_all( + conn, + "SELECT book_id, title, author, cover, review_count, note_count, bookmark_count, " + "reading_progress, marked_status FROM notebooks " + "ORDER BY (review_count + note_count + bookmark_count) DESC", + ) + items = [] + for row in rows: + total = int(row["review_count"] or 0) + int(row["note_count"] or 0) + int(row["bookmark_count"] or 0) + items.append( + { + "id": str(row["book_id"]), + "bookId": str(row["book_id"]), + "title": row["title"], + "author": row["author"], + "cover": cover_class(row["book_id"]), + "cover_url": row["cover"], + "review_count": int(row["review_count"] or 0), + "note_count": int(row["note_count"] or 0), + "bookmark_count": int(row["bookmark_count"] or 0), + "total_notes": total, + "progress": norm_progress(row["reading_progress"]), + } + ) + totals = { + "books": len(items), + "notes": sum(i["total_notes"] for i in items), + "highlights": sum(i["note_count"] for i in items), + "thoughts": sum(i["review_count"] for i in items), + "bookmarks": sum(i["bookmark_count"] for i in items), + } + return jsonify({"items": items, "totals": totals, "degraded": conn is None}) + + +@v2_bp.route("/api/v2/notes") +@with_db +def api_v2_notes(conn): + """ + 某本书的划线与想法(实时接口)。 + Query: book_id(必填);book_id=all 时聚合笔记最多的前 N 本(limit 默认 4) + """ + book_id = (request.args.get("book_id") or "").strip() + if not book_id: + return jsonify({"error": "book_id is required"}), 400 + + if book_id == "all": + limit = request.args.get("limit", type=int, default=4) + rows = query_all( + conn, + "SELECT book_id FROM notebooks ORDER BY (review_count + note_count + bookmark_count) DESC LIMIT %s", + (limit,), + ) + collected, counts = [], {"highlights": 0, "thoughts": 0, "bookmarks": 0} + books_meta = [] + for row in rows: + bid = str(row["book_id"]) + detail = collect_notes(bid) + if not detail: + continue + meta = notes_book_meta(conn, bid) + books_meta.append(meta) + for h in detail["highlights"]: + collected.append({**h, "bookId": bid, "book_title": meta["title"], "type": "highlight"}) + for t in detail["thoughts"]: + collected.append({**t, "bookId": bid, "book_title": meta["title"], "type": "thought"}) + counts["highlights"] += len(detail["highlights"]) + counts["thoughts"] += len(detail["thoughts"]) + counts["bookmarks"] += detail["bookmark_count"] + collected.sort(key=lambda n: n.get("date") or "", reverse=True) + return jsonify( + { + "scope": "all", + "books": books_meta, + "notes": collected, + "counts": counts, + "aggregated_books": len(books_meta), + "degraded": conn is None, + } + ) + + detail = collect_notes(book_id) + if detail is None: + return jsonify({"error": "获取笔记失败,请稍后重试"}), 502 + meta = notes_book_meta(conn, book_id) + notes = [ + {**h, "bookId": book_id, "book_title": meta["title"], "type": "highlight"} for h in detail["highlights"] + ] + [ + {**t, "bookId": book_id, "book_title": meta["title"], "type": "thought"} for t in detail["thoughts"] + ] + notes.sort(key=lambda n: n.get("date") or "", reverse=True) + return jsonify( + { + "scope": "book", + "book": meta, + "notes": notes, + "counts": { + "highlights": len(detail["highlights"]), + "thoughts": len(detail["thoughts"]), + "bookmarks": detail["bookmark_count"], + }, + "degraded": conn is None, + } + ) + + +def notes_book_meta(conn, book_id): + """笔记页需要的书籍信息(本地库优先,回落实时书架)。""" + row = query_one(conn, "SELECT title, author, cover, reading_progress FROM notebooks WHERE book_id=%s", (book_id,)) + if row: + return { + "id": str(book_id), + "bookId": str(book_id), + "title": row["title"], + "author": row["author"], + "cover": cover_class(book_id), + "cover_url": row["cover"], + "progress": norm_progress(row["reading_progress"]), + } + data = weapi_safe("/book/info", ttl=1800, bookId=book_id) or {} + return { + "id": str(book_id), + "bookId": str(book_id), + "title": data.get("title") or "未知书籍", + "author": data.get("author") or "", + "cover": cover_class(book_id), + "cover_url": data.get("cover") or "", + "progress": 0, + } + + +def collect_notes(book_id): + """ + 拉取一本书的划线 + 想法,归一成前端使用的结构。 + 返回 None 表示接口不可用;返回空结构表示确实没有笔记。 + """ + marks = weapi_safe("/book/bookmarklist", ttl=300, bookId=book_id) + thoughts = weapi_safe("/review/list/mine", ttl=300, bookid=book_id, count=100) + if marks is None and thoughts is None: + return None + + chapters = {} + for ch in (marks or {}).get("chapters") or []: + chapters[ch.get("chapterUid")] = ch.get("title") or "" + + highlights = [] + for bm in (marks or {}).get("updated") or []: + text = (bm.get("markText") or "").strip() + if not text: + continue + highlights.append( + { + "id": str(bm.get("bookmarkId") or ""), + "text": text, + "chapter": chapters.get(bm.get("chapterUid"), ""), + "range": bm.get("range") or "", + "date": ts_to_date(bm.get("createTime")), + "url": bookmark_url(book_id, bm), + } + ) + + items, bookmark_count = [], 0 + for item in (thoughts or {}).get("reviews") or []: + rev = item.get("review") or {} + content = rev.get("content") or "" + # 纯书签记录没有正文,只计入数量 + if rev.get("type") == 4 or (not content.strip()): + bookmark_count += 1 + continue + items.append( + { + "id": str(rev.get("reviewId") or ""), + "text": content.strip(), + "abstract": (rev.get("abstract") or "").strip(), + "chapter": rev.get("chapterName") or (chapters.get(rev.get("chapterUid")) or ""), + "range": rev.get("range") or "", + "star": rev.get("star"), + "date": ts_to_date(rev.get("createTime")), + "url": bookmark_url(book_id, rev), + } + ) + + return {"highlights": highlights, "thoughts": items, "bookmark_count": bookmark_count} + + +def bookmark_url(book_id, mark): + """拼微信读书深度链接(划线定位)。""" + rng = (mark.get("range") or "").split("-") + chapter_uid = mark.get("chapterUid") or 0 + if len(rng) == 2 and rng[0].isdigit() and rng[1].isdigit(): + return ( + f"weread://bestbookmark?bookId={book_id}&chapterUid={chapter_uid}" + f"&rangeStart={rng[0]}&rangeEnd={rng[1]}" + ) + return f"weread://reading?bId={book_id}" + + +# ====================================================================== +# 接口:书籍详情(简介 / 目录 / 我的笔记 / 热门划线 / 公开点评 / 相似推荐) +# ====================================================================== + + +@v2_bp.route("/api/v2/book/") +@with_db +def api_v2_book(conn, book_id): + """书籍详情聚合接口(全部走实时接口,逐项降级)。""" + info = weapi_safe("/book/info", ttl=1800, bookId=book_id) + if not info: + return jsonify({"error": "获取书籍信息失败,请稍后重试"}), 502 + + progress = weapi_safe("/book/getprogress", ttl=300, bookId=book_id) or {} + prog_book = progress.get("book") or {} + persist_detail(conn, book_id, info) + persist_progress( + conn, + book_id, + { + "progress": norm_progress(prog_book.get("progress")), + "record_reading_time": prog_book.get("recordReadingTime") or 0, + "finish_time": prog_book.get("finishTime") or 0, + "update_time": prog_book.get("updateTime") or 0, + "is_start_reading": prog_book.get("isStartReading") or 0, + "chapter_uid": prog_book.get("chapterUid") or 0, + "chapter_offset": prog_book.get("chapterOffset") or 0, + }, + ) + chapters_raw = weapi_safe("/book/chapterinfo", ttl=1800, bookId=book_id) or {} + best = weapi_safe("/book/bestbookmarks", ttl=600, bookId=book_id) or {} + reviews_raw = weapi_safe("/review/list", ttl=600, bookId=book_id, count=10) or {} + similar_raw = weapi_safe("/book/similar", ttl=1800, bookId=book_id, count=8, maxIdx=0) or {} + notes = collect_notes(book_id) or {"highlights": [], "thoughts": [], "bookmark_count": 0} + + rating = float(info.get("newRating") or 0) + + chapters = [] + for ch in (chapters_raw.get("chapters") or []): + if (ch.get("level") or 1) > 1: # 只保留一级章节,避免目录过长 + continue + chapters.append( + { + "uid": ch.get("chapterUid"), + "idx": ch.get("chapterIdx"), + "title": ch.get("title") or "", + "words": ch.get("wordCount") or 0, + "paid": ch.get("paid") or 0, + } + ) + + chapters_map = {c["uid"]: c["title"] for c in chapters} + for c in (chapters_raw.get("chapters") or []): + chapters_map.setdefault(c.get("chapterUid"), c.get("title") or "") + + best_marks = [] + for item in (best.get("items") or [])[:10]: + best_marks.append( + { + "text": (item.get("markText") or "").strip(), + "count": int(item.get("totalCount") or 0), + "chapter": chapters_map.get(item.get("chapterUid"), ""), + "range": item.get("range") or "", + "url": bookmark_url(book_id, item), + } + ) + + reviews = [] + for item in ((reviews_raw.get("reviews") or []))[:10]: + rev = item.get("review") or {} + author = rev.get("author") or {} + star = rev.get("star") or 0 + reviews.append( + { + "id": str(rev.get("reviewId") or ""), + "name": author.get("name") or "书友", + "avatar": author.get("avatar") or "", + "rating": max(1, min(5, int(round(star / 20)))) if star else 0, + "star": star, + "text": (rev.get("content") or "").strip(), + "date": ts_to_date(rev.get("createTime")), + "chapter": rev.get("chapterName") or "", + "is_finish": rev.get("isFinish") or 0, + } + ) + + similar = [] + for item in ((similar_raw.get("booksimilar") or {}).get("books") or [])[:8]: + bi = (item.get("book") or {}).get("bookInfo") or {} + if not bi.get("bookId"): + continue + similar.append( + make_book( + bi.get("bookId"), + bi.get("title"), + bi.get("author"), + bi.get("category"), + cover_url=bi.get("cover"), + deep_link=bi.get("deepLink"), + rating=bi.get("newRating"), + intro=bi.get("intro"), + ) + ) + + return jsonify( + { + "book": { + "id": str(book_id), + "bookId": str(book_id), + "title": info.get("title") or "", + "author": info.get("author") or "", + "translator": info.get("translator") or "", + "cover": cover_class(book_id), + "cover_url": info.get("cover") or "", + "short": short_title(info.get("title")), + "intro": info.get("intro") or "", + "category": info.get("category") or "", + "publisher": info.get("publisher") or "", + "publish_time": info.get("publishTime") or "", + "isbn": info.get("isbn") or "", + "word_count": info.get("wordCount") or 0, + "rating": round(rating / 10, 1) if rating else None, + "rating_count": info.get("newRatingCount") or 0, + "deep_link": info.get("deepLink") or f"weread://reading?bId={book_id}", + }, + "progress": { + "progress": norm_progress(prog_book.get("progress")), + "record_reading_time": prog_book.get("recordReadingTime") or 0, + "record_reading_time_str": duration_str(prog_book.get("recordReadingTime") or 0), + "update_time": ts_to_date(prog_book.get("updateTime")), + "finish_time": ts_to_date(prog_book.get("finishTime")), + "is_start_reading": prog_book.get("isStartReading") or 0, + }, + "chapters": chapters, + "chapter_total": len(chapters_raw.get("chapters") or []), + "best_bookmarks": {"items": best_marks, "total": int(best.get("totalCount") or 0)}, + "reviews": { + "items": reviews, + "count": int(reviews_raw.get("reviewsCnt") or len(reviews)), + "deep_v": reviews_raw.get("deepVRecommendInfo") or {}, + }, + "notes": { + "items": [ + {**h, "bookId": str(book_id), "type": "highlight"} for h in notes["highlights"] + ] + + [{**t, "bookId": str(book_id), "type": "thought"} for t in notes["thoughts"]], + "counts": { + "highlights": len(notes["highlights"]), + "thoughts": len(notes["thoughts"]), + "bookmarks": notes["bookmark_count"], + }, + }, + "similar": similar, + } + ) + + +# ====================================================================== +# 接口:书单 / 推荐 / 搜索 +# ====================================================================== + + +@v2_bp.route("/api/v2/archives") +@with_db +def api_v2_archives(conn): + """书单(书架 archive)+ 成员书籍。""" + try: + payload = shelf_payload() + except WeReadError as e: + return jsonify({"error": str(e)}), 502 + + books, _ = shelf_books(conn) + by_id = {b["id"]: b for b in books} + lists = [] + for arc in payload.get("archive") or []: + ids = [str(i) for i in (arc.get("bookIds") or [])] + members = [by_id[i] for i in ids if i in by_id] + lists.append( + { + "id": arc.get("name") or "", + "name": arc.get("name") or "", + "source": "我的书单", + "description": f"书架书单 · 收录 {len(ids)} 本", + "ids": ids, + "members": members, + "book_count": len(ids), + } + ) + return jsonify({"lists": lists, "count": len(lists), "degraded": conn is None}) + + +@v2_bp.route("/api/v2/recommend") +@with_db +def api_v2_recommend(conn): + """为你推荐(实时)。Query: maxIdx""" + max_idx = request.args.get("maxIdx", type=int, default=0) + try: + data = weapi("/book/recommend", ttl=600, count=12, maxIdx=max_idx) + except WeReadError as e: + return jsonify({"error": str(e)}), 502 + + books = [] + for book in data.get("books") or []: + rating_detail = book.get("newRatingDetail") or {} + books.append( + { + "id": str(book.get("bookId") or ""), + "bookId": str(book.get("bookId") or ""), + "title": book.get("title") or "", + "author": book.get("author") or "", + "cover": cover_class(book.get("bookId") or ""), + "cover_url": book.get("cover") or "", + "short": short_title(book.get("title")), + "desc": book.get("intro") or "", + "reason": book.get("reason") or "", + "category": book.get("category") or "", + "score": round(float(book.get("newRating") or 0) / 100, 1) if book.get("newRating") else None, + "rating_title": (rating_detail or {}).get("title") or "", + "reading_count": book.get("readingCount") or 0, + "deep_link": book.get("deepLink") or "", + "status": "wish", + "progress": 0, + } + ) + return jsonify({"books": books, "has_more": data.get("hasMore") or 0}) + + +SEARCH_SCOPES = { + "all": 0, + "books": 10, + "author": 6, + "lists": 13, + "audio": 14, + "fulltext": 12, + "novel": 11, + "account": 2, + "article": 4, +} + + +@v2_bp.route("/api/v2/search") +@with_db +def api_v2_search(conn): + """ + 搜索(实时)。Query: keyword(必填), scope=all|books|author|lists|audio|fulltext|novel|account|article + 返回按分组归一后的书籍 / 作者 / 书单结果。 + """ + keyword = (request.args.get("keyword") or "").strip() + scope_name = request.args.get("scope", "books") + # Flask 对中文查询参数的编码兜底 + try: + keyword = keyword.encode("latin-1").decode("utf-8") + except (UnicodeDecodeError, UnicodeEncodeError): + pass + if not keyword: + return jsonify({"error": "keyword is required"}), 400 + + scope = SEARCH_SCOPES.get(scope_name, 10) + try: + data = weapi("/store/search", ttl=300, keyword=keyword, scope=scope, maxIdx=0, count=15) + except WeReadError as e: + return jsonify({"error": str(e), "keyword": keyword, "scope": scope_name}), 502 + + books, authors, lists, groups = [], [], [], [] + seen_books, seen_authors, seen_groups = set(), set(), set() + for group in data.get("results") or []: + title = group.get("title") or "" + gkey = (title, group.get("scope"), group.get("currentCount")) + if gkey in seen_groups: + continue + seen_groups.add(gkey) + items = group.get("books") or [] + if title: + groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0}) + for item in items: + bi = item.get("bookInfo") or item.get("book") or {} + bid = str(bi.get("bookId") or "") + if bid and title == "作者": + name = bi.get("author") or bi.get("title") or "" + if name and name not in seen_authors: + seen_authors.add(name) + authors.append({"name": name, "count": bi.get("bookCount") or 0}) + continue + if not bid or bid in seen_books: + continue + seen_books.add(bid) + entry = { + "id": bid, + "bookId": bid, + "title": bi.get("title") or "", + "author": bi.get("author") or (bi.get("authorInfo") or {}).get("name") or "", + "cover": cover_class(bid), + "cover_url": bi.get("cover") or "", + "short": short_title(bi.get("title")), + "desc": bi.get("intro") or "", + "category": bi.get("category") or "", + "score": round(float(item.get("newRating") or 0) / 100, 1) if item.get("newRating") else None, + "reading_count": item.get("readingCount") or 0, + "deep_link": bi.get("deepLink") or "", + "group": title, + "status": "wish", + "progress": 0, + } + if title in ("书单", "书单合集"): + lists.append({**entry, "name": entry["title"], "source": title, "ids": []}) + else: + books.append(entry) + + return jsonify( + { + "keyword": keyword, + "scope": scope_name, + "scope_id": scope, + "groups": groups, + "books": books, + "authors": authors, + "lists": lists, + "has_more": data.get("hasMore") or 0, + } + ) + + +# ====================================================================== +# 接口:单条想法详情(想法 / 点赞 / 评论) +# ====================================================================== + + +@v2_bp.route("/api/v2/thought/") +@with_db +def api_v2_thought(conn, review_id): + """单条想法详情:正文 + 点赞数 + 评论列表。""" + data = weapi_safe("/review/single", ttl=300, reviewId=review_id, commentsCount=10, likesCount=10) + if not data: + return jsonify({"error": "获取想法详情失败"}), 502 + + review = data.get("review") or {} + author = review.get("author") or {} + comments = [] + for c in (data.get("comments") or []): + inner = c.get("comment") or c + c_author = inner.get("author") or {} + comments.append( + { + "name": c_author.get("name") or "书友", + "text": (inner.get("content") or "").strip(), + "date": ts_to_date(inner.get("createTime")), + "likes": inner.get("likesCount") or 0, + } + ) + likes = data.get("likes") or [] + return jsonify( + { + "id": review_id, + "text": (review.get("content") or "").strip(), + "abstract": (review.get("abstract") or "").strip(), + "author": author.get("name") or "我", + "date": ts_to_date(review.get("createTime")), + "chapter": review.get("chapterName") or "", + "star": review.get("star"), + "book_review_count": data.get("bookReviewCount") or 0, + "like_count": len(likes), + "likes": [ + {"name": ((l.get("like") or {}).get("author") or {}).get("name") or "书友"} for l in likes[:10] + ], + "comments": comments, + "comment_count": len(comments), + } + ) + + +# ====================================================================== +# 接口:公开点评(可按类型筛选) +# ====================================================================== + + +@v2_bp.route("/api/v2/book//reviews") +@with_db +def api_v2_book_reviews(conn, book_id): + """ + 书籍公开点评。Query: type=0|1|2|3|4(全部/推荐/不行/最新/一般), count + """ + review_type = request.args.get("type", type=int, default=0) + count = request.args.get("count", type=int, default=10) + data = weapi_safe("/review/list", ttl=600, bookId=book_id, reviewListType=review_type, count=count) + if data is None: + return jsonify({"error": "获取点评失败"}), 502 + + items = [] + for item in (data.get("reviews") or [])[:count]: + rev = item.get("review") or {} + author = rev.get("author") or {} + star = rev.get("star") or 0 + items.append( + { + "id": str(rev.get("reviewId") or ""), + "name": author.get("name") or "书友", + "avatar": author.get("avatar") or "", + "rating": max(1, min(5, int(round(star / 20)))) if star else 0, + "star": star, + "text": (rev.get("content") or "").strip(), + "date": ts_to_date(rev.get("createTime")), + "chapter": rev.get("chapterName") or "", + "is_finish": rev.get("isFinish") or 0, + } + ) + + return jsonify( + { + "book_id": str(book_id), + "type": review_type, + "items": items, + "count": int(data.get("reviewsCnt") or len(items)), + "has_more": data.get("reviewsHasMore") or 0, + "friend_count": data.get("friendCommentCount") or 0, + "deep_v": data.get("deepVRecommendInfo") or {}, + "value": data.get("deepVRecommendValue") or 0, + } + ) + + +# ====================================================================== +# 接口:章节划线下想法(供热门划线弹层使用) +# ====================================================================== + + +@v2_bp.route("/api/v2/book//highlights") +@with_db +def api_v2_book_highlights(conn, book_id): + """ + 热门划线 / 章节划线热度。 + Query: chapter_uid(可选,0=全书) + """ + chapter_uid = request.args.get("chapter_uid", type=int, default=0) + best = weapi_safe("/book/bestbookmarks", ttl=600, bookId=book_id, chapterUid=chapter_uid) + if best is None: + return jsonify({"error": "获取热门划线失败"}), 502 + + chapters_raw = weapi_safe("/book/chapterinfo", ttl=1800, bookId=book_id) or {} + chapters_map = {c.get("chapterUid"): c.get("title") or "" for c in (chapters_raw.get("chapters") or [])} + + items = [] + for item in (best.get("items") or [])[:20]: + rng = (item.get("range") or "").split("-") + items.append( + { + "text": (item.get("markText") or "").strip(), + "count": int(item.get("totalCount") or 0), + "chapter": chapters_map.get(item.get("chapterUid"), ""), + "chapter_uid": item.get("chapterUid"), + "range": item.get("range") or "", + "range_start": int(rng[0]) if len(rng) == 2 and rng[0].isdigit() else None, + "range_end": int(rng[1]) if len(rng) == 2 and rng[1].isdigit() else None, + "url": bookmark_url(book_id, item), + } + ) + return jsonify( + { + "book_id": str(book_id), + "chapter_uid": chapter_uid, + "total": int(best.get("totalCount") or 0), + "items": items, + } + ) + + +# ====================================================================== +# 接口:划线下想法列表 +# ====================================================================== + + +@v2_bp.route("/api/v2/book//underline-thoughts") +@with_db +def api_v2_underline_thoughts(conn, book_id): + """某条划线下他人的想法(每屏最多 20 条)。Query: chapter_uid, range""" + chapter_uid = request.args.get("chapter_uid", type=int, default=0) + rng = (request.args.get("range") or "").strip() + if not chapter_uid or not rng: + return jsonify({"error": "chapter_uid 与 range 必填"}), 400 + + payload = {"bookId": book_id, "chapterUid": chapter_uid, "reviews": [{"range": rng, "maxIdx": 0, "count": 10}]} + data = weapi_safe("/book/readreviews", ttl=300, **payload) + if data is None: + return jsonify({"error": "获取划线下想法失败"}), 502 + + items = [] + for group in data.get("reviews") or []: + for item in group.get("reviews") or []: + rev = item.get("review") or item + inner = rev.get("review") or rev + author = inner.get("author") or {} + text = (inner.get("content") or "").strip() + if not text: + continue + items.append( + { + "id": str(inner.get("reviewId") or ""), + "name": author.get("name") or "书友", + "text": text, + "date": ts_to_date(inner.get("createTime")), + "likes": inner.get("likesCount") or 0, + } + ) + return jsonify({"book_id": str(book_id), "chapter_uid": chapter_uid, "range": rng, "items": items[:20]}) diff --git a/app.py b/app.py index d780e04..474c0bf 100644 --- a/app.py +++ b/app.py @@ -27,11 +27,16 @@ from functools import wraps import pymysql from flask import Flask, jsonify, render_template, request +from api_v2 import v2_bp + # ====================================================================== # Flask 应用实例 # ====================================================================== app = Flask(__name__, static_folder="static", template_folder="templates") +# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据) +app.register_blueprint(v2_bp) + # ====================================================================== # 数据库连接配置 # ====================================================================== @@ -1270,5 +1275,7 @@ if __name__ == "__main__": else: print(f"[信息] {msg}") - # 启动 Flask 服务,监听 8080 端口 - app.run(host="0.0.0.0", port=8080, debug=False) + # 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口) + port = int(os.environ.get("PORT", 8080)) + print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板") + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/docs/V2_API.md b/docs/V2_API.md new file mode 100644 index 0000000..6be952d --- /dev/null +++ b/docs/V2_API.md @@ -0,0 +1,45 @@ +# v2 数据接口(/api/v2/*) + +「阅间」新版前端的数据来源。实现在 `api_v2.py`(Flask Blueprint,注册于 `app.py`), +v1 的 `/api/*` 接口保持不变,两套接口可同时使用。 + +## 数据来源与优先级 + +| 数据 | 来源 | 说明 | +|------|------|------| +| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 | +| 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 | +| 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 | +| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 | +| 书籍详情 | 实时 `/book/info`、`/book/getprogress`、`/book/chapterinfo`、`/book/bestbookmarks`、`/review/list`、`/book/similar` | 逐项降级,单项失败不影响整页 | +| 搜索 / 推荐 | 实时 `/store/search`(按 scope 切换类型)、`/book/recommend` | | +| 阅读目标 | 浏览器 localStorage(key `weread-goals-v2`) | 与设计稿一致,不落库 | + +MySQL 不可用时接口仍可用(返回 `degraded: true`),依赖本地缓存的字段会退化为实时接口结果。 + +## 接口清单 + +| 方法 | 路径 | 参数 | 说明 | +|------|------|------|------| +| GET | `/api/v2/status` | — | 连接状态、书架规模、数据快照时间 | +| GET | `/api/v2/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) | +| GET | `/api/v2/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 | +| GET | `/api/v2/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) | +| GET | `/api/v2/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 | +| GET | `/api/v2/book/` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 | +| GET | `/api/v2/book//reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) | +| GET | `/api/v2/book//highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) | +| GET | `/api/v2/book//underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 | +| GET | `/api/v2/thought/` | — | 单条想法详情:正文、划线原文、点赞、评论 | +| GET | `/api/v2/archives` | — | 书架书单及成员书籍 | +| GET | `/api/v2/recommend` | `maxIdx` | 个性化推荐 | +| GET | `/api/v2/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) | +| POST | `/api/sync` | — | v1 已有:重新采集数据并迁移到 MySQL | + +## 与微信读书接口的字段口径 + +- 评分:接口 `newRating` 为 0~1000,接口层换算成 `score = newRating / 100`(前端按设计稿公式展示为 `评分 XX/100`),详情页展示 `rating = newRating / 10`。 +- 封面:设计稿使用 4 种示意底色(`c1`~`c4`),由 `book_id` 哈希稳定映射;真实封面 URL 保留在 `cover_url`。 +- 进度:统一到 0~100 整数;`status` 口径为「读完=done、有最近阅读=reading、从未读过=wish」。 +- 周/月维度的统计接口不回偏好画像字段,接口层用「总计」口径补齐,并在 `preference_source` 中标注来源。 +- 「文章收藏」按接口规范计为 1 个书架条目(`books + albums + mp`)。 diff --git a/docs/screenshots/v2-book-detail.png b/docs/screenshots/v2-book-detail.png new file mode 100644 index 0000000..ad12d5e Binary files /dev/null and b/docs/screenshots/v2-book-detail.png differ diff --git a/docs/screenshots/v2-discover.png b/docs/screenshots/v2-discover.png new file mode 100644 index 0000000..9450fa4 Binary files /dev/null and b/docs/screenshots/v2-discover.png differ diff --git a/docs/screenshots/v2-goal-editor.png b/docs/screenshots/v2-goal-editor.png new file mode 100644 index 0000000..526c102 Binary files /dev/null and b/docs/screenshots/v2-goal-editor.png differ diff --git a/docs/screenshots/v2-notes.png b/docs/screenshots/v2-notes.png new file mode 100644 index 0000000..0b2ad7c Binary files /dev/null and b/docs/screenshots/v2-notes.png differ diff --git a/docs/screenshots/v2-overview-dark.png b/docs/screenshots/v2-overview-dark.png new file mode 100644 index 0000000..12856d8 Binary files /dev/null and b/docs/screenshots/v2-overview-dark.png differ diff --git a/docs/screenshots/v2-overview.png b/docs/screenshots/v2-overview.png new file mode 100644 index 0000000..690d4e6 Binary files /dev/null and b/docs/screenshots/v2-overview.png differ diff --git a/docs/screenshots/v2-shelf.png b/docs/screenshots/v2-shelf.png new file mode 100644 index 0000000..b64ddde Binary files /dev/null and b/docs/screenshots/v2-shelf.png differ diff --git a/legacy/v1/static/css/style.css b/legacy/v1/static/css/style.css new file mode 100644 index 0000000..92e4e86 --- /dev/null +++ b/legacy/v1/static/css/style.css @@ -0,0 +1,2056 @@ +/* ===== 主题变量 ===== */ +:root { + /* 明亮模式 - 暖纸色文学风 */ + --bg-primary: #f8f5f0; + --bg-card: #ffffff; + --bg-secondary: #f0ebe3; + --bg-hero: linear-gradient(135deg, #8B6914 0%, #c17b4a 50%, #a05c2c 100%); + --bg-hero-overlay: rgba(255, 255, 255, 0.04); + + --text-primary: #2c2416; + --text-secondary: #6b5d4f; + --text-light: #9b8e80; + + --accent: #c17b4a; + --accent-light: #e8a87c; + --accent-dark: #a05c2c; + --accent-bg: rgba(193, 123, 74, 0.08); + --accent-bg-hover: rgba(193, 123, 74, 0.12); + + --border: #e8e0d4; + --border-light: #f0ebe3; + + --shadow: 0 2px 12px rgba(44, 36, 22, 0.08); + --shadow-hover: 0 8px 24px rgba(44, 36, 22, 0.15); + --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.3); + + --radius: 12px; + --radius-sm: 8px; + --radius-xs: 4px; + + --alert-bg: #fff3cd; + --alert-border: #ffc107; + --alert-text: #856404; + + /* ECharts 专用 */ + --chart-axis: #6b5d4f; + --chart-axis-line: #e8e0d4; + --chart-split-line: #f0ebe3; + --chart-label: #6b5d4f; + --chart-border-color: #fff; + --chart-tooltip-bg: rgba(255, 255, 255, 0.95); + --chart-tooltip-border: #e8e0d4; + --chart-tooltip-text: #2c2416; + --chart-review-bg: #fff8e1; + --chart-review-border: #ffc107; + --chart-highlight-bg: #f8f5f0; + + /* 导航栏 */ + --nav-bg: rgba(255, 255, 255, 0.92); + --nav-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); + + /* 模态遮罩 */ + --modal-overlay: rgba(0, 0, 0, 0.5); + + /* 主题切换按钮 */ + --toggle-bg: #f0ebe3; + --toggle-icon-sun: #f59e0b; + --toggle-icon-moon: #6b5d4f; + + /* 评分标签 */ + --rating-color: #e65100; + --rating-bg: #fff3e0; + --rating-star: #ff9800; + + /* 徽章 */ + --badge-finished-bg: #4caf50; + --badge-top-bg: rgba(255, 215, 0, 0.9); + --badge-top-text: #5d4037; + --rank-bg: rgba(0, 0, 0, 0.7); + --rank-text: #ffd700; +} + +[data-theme="dark"] { + /* 黑暗模式 - 暖色深调,保持文学质感 */ + --bg-primary: #1a1612; + --bg-card: #252019; + --bg-secondary: #2d2820; + --bg-hero: linear-gradient(135deg, #3d2e1a 0%, #5c3f1e 50%, #4a3015 100%); + --bg-hero-overlay: rgba(255, 255, 255, 0.03); + + --text-primary: #e8e0d4; + --text-secondary: #b0a698; + --text-light: #8a7f70; + + --accent: #d4925a; + --accent-light: #e8a87c; + --accent-dark: #b87436; + --accent-bg: rgba(212, 146, 90, 0.12); + --accent-bg-hover: rgba(212, 146, 90, 0.18); + + --border: #3a342c; + --border-light: #2d2820; + + --shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + --shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6); + + --alert-bg: #3d2f15; + --alert-border: #8a6d1e; + --alert-text: #f0d77a; + + /* ECharts 专用 */ + --chart-axis: #b0a698; + --chart-axis-line: #3a342c; + --chart-split-line: #2d2820; + --chart-label: #b0a698; + --chart-border-color: #252019; + --chart-tooltip-bg: rgba(37, 32, 25, 0.95); + --chart-tooltip-border: #3a342c; + --chart-tooltip-text: #e8e0d4; + --chart-review-bg: #2d2515; + --chart-review-border: #66521e; + --chart-highlight-bg: #2d2820; + + /* 导航栏 */ + --nav-bg: rgba(26, 22, 18, 0.92); + --nav-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + + /* 模态遮罩 */ + --modal-overlay: rgba(0, 0, 0, 0.7); + + /* 主题切换按钮 */ + --toggle-bg: #2d2820; + --toggle-icon-sun: #f59e0b; + --toggle-icon-moon: #d4925a; + + /* 评分标签 */ + --rating-color: #ff9800; + --rating-bg: #3d2e15; + --rating-star: #ffa726; + + /* 徽章 */ + --badge-finished-bg: #2e7d32; + --badge-top-bg: rgba(255, 215, 0, 0.85); + --badge-top-text: #3d2e15; + --rank-bg: rgba(0, 0, 0, 0.8); + --rank-text: #ffd700; +} + +/* ===== 全局过渡 ===== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* 所有主题相关元素平滑过渡 */ +.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card, +.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab, +.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn, +.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item, +.review-item, .detail-progress-info, .top-book-cover-wrapper, +.theme-toggle, .loading-spinner { + transition: background-color 0.3s ease, border-color 0.3s ease, + color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +/* ===== 导航栏 ===== */ +.navbar { + position: sticky; + top: 0; + z-index: 100; + background: var(--nav-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + box-shadow: var(--nav-shadow); +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.nav-logo { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); +} + +.logo-icon { + font-size: 24px; +} + +.nav-links { + display: flex; + gap: 24px; + align-items: center; +} + +.nav-link { + text-decoration: none; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + padding: 6px 12px; + border-radius: var(--radius-sm); + transition: all 0.2s; +} + +.nav-link:hover { + color: var(--accent); + background: var(--accent-bg); +} + +.nav-link.active { + color: var(--accent); + background: var(--accent-bg-hover); +} + +.nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +.nav-update { + font-size: 12px; + color: var(--text-light); +} + +/* ===== 主题切换按钮 ===== */ +.theme-toggle { + width: 40px; + height: 40px; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--toggle-bg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; + color: var(--toggle-icon-moon); + flex-shrink: 0; +} + +.theme-toggle:hover { + border-color: var(--accent); + transform: scale(1.08); +} + +.theme-toggle .icon-sun { display: none; } +.theme-toggle .icon-moon { display: block; } + +[data-theme="dark"] .theme-toggle .icon-sun { display: block; } +[data-theme="dark"] .theme-toggle .icon-moon { display: none; } +[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); } + +/* ===== 顶部常驻搜索框 ===== */ +.global-search-bar { + position: sticky; + top: 60px; + z-index: 90; + background: var(--nav-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + padding: 10px 0; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.global-search-wrapper { + display: flex; + align-items: center; + gap: 0; + max-width: 600px; + margin: 0 auto; + position: relative; +} + +.global-search-icon { + position: absolute; + left: 14px; + font-size: 15px; + opacity: 0.5; + pointer-events: none; +} + +.global-search-input { + flex: 1; + padding: 9px 16px 9px 40px; + border: 1px solid var(--border); + border-right: none; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + font-size: 14px; + background: var(--bg-card); + color: var(--text-primary); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.global-search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} + +.global-search-btn { + padding: 9px 20px; + border: 1px solid var(--accent); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--accent); + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.global-search-btn:hover { + background: var(--accent-dark); +} + +/* ===== 书架分类标签 ===== */ +/* 层级折叠分类标签 */ +.shelf-categories { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + align-items: center; +} + +.shelf-cat-parent { + padding: 5px 14px; + border: 1px solid var(--border); + border-radius: 20px; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-card); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.shelf-cat-parent:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-bg); +} + +.shelf-cat-parent.active { + border-color: var(--accent); + color: #fff; + background: var(--accent); + font-weight: 500; +} + +.shelf-cat-count { + font-size: 11px; + padding: 1px 7px; + border-radius: 10px; + background: var(--accent-bg); + color: var(--accent-dark); +} + +.shelf-cat-parent.active .shelf-cat-count { + background: rgba(255,255,255,0.25); + color: #fff; +} + +.shelf-cat-arrow { + font-size: 9px; + margin-left: 2px; + transition: transform 0.2s; +} + +.shelf-cat-subs { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; + padding: 4px 0 4px 20px; + margin-bottom: 4px; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.shelf-cat-sub { + padding: 3px 12px; + border: 1px solid var(--border); + border-radius: 16px; + font-size: 12px; + color: var(--text-light); + background: var(--bg-primary); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.shelf-cat-sub:hover { + border-color: var(--accent); + color: var(--accent); +} + +.shelf-cat-sub.active { + border-color: var(--accent); + color: #fff; + background: var(--accent); + font-weight: 500; +} + +.shelf-cat-count-sm { + font-size: 10px; + padding: 0 5px; + border-radius: 8px; + background: var(--accent-bg); + color: var(--accent-dark); +} + +.shelf-cat-sub.active .shelf-cat-count-sm { + background: rgba(255,255,255,0.25); + color: #fff; +} + +/* ===== Hero 区 ===== */ +.hero-section { + position: relative; + padding: 80px 0 60px; + overflow: hidden; + background: var(--bg-hero); + transition: background 0.5s ease; +} + +.hero-bg { + position: absolute; + inset: 0; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M50 25 L55 40 L70 40 L58 50 L63 65 L50 55 L37 65 L42 50 L30 40 L45 40 Z'/%3E%3C/g%3E%3C/svg%3E"); + opacity: 0.6; +} + +.hero-content { + position: relative; + text-align: center; + color: #fff; + max-width: 800px; + margin: 0 auto; + padding: 0 24px; +} + +.hero-title { + font-size: 36px; + font-weight: 700; + margin-bottom: 12px; + letter-spacing: 2px; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.hero-subtitle { + font-size: 16px; + opacity: 0.9; + margin-bottom: 40px; +} + +.hero-stats { + display: flex; + justify-content: center; + gap: 48px; + flex-wrap: wrap; +} + +.hero-stat { + text-align: center; +} + +.hero-stat-value { + font-size: 42px; + font-weight: 800; + line-height: 1.2; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.hero-stat-label { + font-size: 14px; + opacity: 0.85; + margin-top: 4px; +} + +/* ===== 统计卡片 ===== */ +.stats-cards-section { + padding: 40px 0 20px; + margin-top: -40px; + position: relative; + z-index: 10; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; +} + +.stat-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); + transition: transform 0.2s, box-shadow 0.3s, background-color 0.3s, border-color 0.3s; + text-align: center; + border: 1px solid var(--border); +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-hover); +} + +.stat-card-icon { + font-size: 32px; + margin-bottom: 12px; +} + +.stat-card-value { + font-size: 28px; + font-weight: 800; + color: var(--accent-dark); + margin-bottom: 4px; +} + +.stat-card-label { + font-size: 13px; + color: var(--text-secondary); +} + +.stat-card-sub { + font-size: 12px; + color: var(--text-light); + margin-top: 4px; +} + +/* ===== Section 标题 ===== */ +.section-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 32px; + color: var(--text-primary); + text-align: center; +} + +/* ===== 图表区 ===== */ +.charts-section { + padding: 60px 0; +} + +.charts-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +.chart-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); + border: 1px solid var(--border); +} + +.chart-card.large { + grid-column: 1 / -1; +} + +.chart-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; +} + +.chart-container { + width: 100%; + height: 320px; +} + +/* ===== 标签页切换 ===== */ +.tab-panel { + display: none; + animation: fadeInTab 0.3s ease; +} + +.tab-panel.active { + display: block; +} + +@keyframes fadeInTab { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== 读书排行(竖向列表) ===== */ +.top-books-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.top-book-row { + display: flex; + align-items: center; + gap: 16px; + background: var(--bg-card); + border-radius: var(--radius); + padding: 12px 16px; + box-shadow: var(--shadow); + border: 1px solid var(--border); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.3s, background-color 0.3s, border-color 0.3s; +} + +.top-book-row:hover { + transform: translateX(4px); + box-shadow: var(--shadow-hover); + border-color: var(--accent); +} + +.top-book-rank-num { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 800; + background: var(--accent-bg); + color: var(--accent-dark); +} + +.top-book-row:nth-child(1) .top-book-rank-num { + background: linear-gradient(135deg, #ffd700, #ffa500); + color: #fff; +} + +.top-book-row:nth-child(2) .top-book-rank-num { + background: linear-gradient(135deg, #c0c0c0, #a8a8a8); + color: #fff; +} + +.top-book-row:nth-child(3) .top-book-rank-num { + background: linear-gradient(135deg, #cd7f32, #b87333); + color: #fff; +} + +.top-book-mini-cover { + flex-shrink: 0; + width: 48px; + height: 64px; + border-radius: 6px; + object-fit: cover; + box-shadow: var(--shadow); + background: var(--bg-primary); +} + +.top-book-row-info { + flex: 1; + min-width: 0; +} + +.top-book-row-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.top-book-row-meta { + display: flex; + align-items: center; + gap: 12px; + margin-top: 4px; + font-size: 12px; + color: var(--text-light); +} + +.top-book-row-time { + font-size: 13px; + color: var(--accent); + font-weight: 600; + flex-shrink: 0; +} + +.top-book-row-tags { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.top-book-tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: var(--accent-bg-hover); + color: var(--accent-dark); +} + +/* ===== 书架 ===== */ +.shelf-section { + padding: 60px 0; +} + +.shelf-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} + +.shelf-filters { + display: flex; + align-items: center; + gap: 16px; +} + +.shelf-search { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 14px; + width: 200px; + background: var(--bg-card); + color: var(--text-primary); +} + +.shelf-search:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} + +.shelf-refresh-btn { + padding: 0 16px; + height: 36px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-card); + cursor: pointer; + transition: color 0.2s, border-color 0.2s, opacity 0.2s; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + flex-shrink: 0; + white-space: nowrap; +} + +.shelf-refresh-btn .btn-icon { + display: inline-block; + font-size: 14px; + line-height: 1; +} + +.shelf-refresh-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.shelf-refresh-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.shelf-refresh-btn.loading { + border-color: var(--accent); + color: var(--accent); +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-secondary); + cursor: pointer; +} + +.shelf-info { + font-size: 13px; + color: var(--text-light); +} + +.book-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.book-card { + cursor: pointer; + transition: transform 0.2s; +} + +.book-card:hover { + transform: translateY(-4px); +} + +.book-cover-wrapper { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: var(--radius-sm); + overflow: hidden; + box-shadow: var(--shadow); + background: var(--bg-primary); +} + +.book-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.book-card:hover .book-cover-img { + transform: scale(1.05); +} + +.book-finished-badge { + position: absolute; + top: 6px; + right: 6px; + background: var(--badge-finished-bg); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: var(--radius-xs); + font-weight: 600; +} + +.book-top-badge { + position: absolute; + top: 6px; + left: 6px; + background: var(--badge-top-bg); + color: var(--badge-top-text); + font-size: 10px; + padding: 2px 6px; + border-radius: var(--radius-xs); + font-weight: 600; +} + +.book-title { + font-size: 13px; + font-weight: 500; + margin-top: 8px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.book-author { + font-size: 11px; + color: var(--text-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ===== 分页 ===== */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 32px; +} + +.page-btn { + padding: 8px 14px; + 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; +} + +.page-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.page-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.page-info { + font-size: 13px; + color: var(--text-light); + margin: 0 8px; +} + +/* ===== 笔记区 ===== */ +.notes-section { + padding: 60px 0; + background: var(--accent-bg); + transition: background-color 0.3s ease; +} + +.notes-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 32px; +} + +.note-stat { + background: var(--bg-card); + border-radius: var(--radius); + padding: 20px; + text-align: center; + box-shadow: var(--shadow); + border: 1px solid var(--border); +} + +.note-stat-value { + font-size: 28px; + font-weight: 800; + color: var(--accent-dark); +} + +.note-stat-label { + font-size: 13px; + color: var(--text-secondary); + margin-top: 4px; +} + +.notebooks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.notebook-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 16px; + display: flex; + gap: 14px; + box-shadow: var(--shadow); + border: 1px solid var(--border); + transition: transform 0.2s, box-shadow 0.3s, background-color 0.3s, border-color 0.3s; +} + +.notebook-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +.notebook-cover { + width: 60px; + height: 80px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + box-shadow: var(--shadow); +} + +.notebook-info { + flex: 1; + min-width: 0; +} + +.notebook-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notebook-author { + font-size: 12px; + color: var(--text-light); + margin-bottom: 8px; +} + +.notebook-stats { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.notebook-stat-item { + font-size: 12px; + color: var(--text-secondary); +} + +.notebook-stat-item strong { + color: var(--accent-dark); +} + +.notebook-progress { + margin-top: 8px; + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} + +.notebook-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s; +} + +/* ===== 页脚 ===== */ +.footer { + padding: 40px 0; + text-align: center; + color: var(--text-light); + font-size: 13px; + border-top: 1px solid var(--border); + background: var(--bg-card); +} + +.footer-hint { + margin-top: 8px; + font-size: 12px; + opacity: 0.7; +} + +/* ===== 数据库连接告警横幅 ===== */ +.db-alert-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: var(--alert-bg); + border-bottom: 2px solid var(--alert-border); + color: var(--alert-text); + font-size: 14px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + animation: slideDown 0.3s ease-out; +} + +.db-alert-icon { + font-size: 20px; +} + +.db-alert-text { + flex-shrink: 0; +} + +.db-alert-detail { + font-size: 12px; + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes slideDown { + from { transform: translateY(-100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* ===== 加载动画 ===== */ +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 60px 0; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + 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 var(--accent-bg); +} + +.discover-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 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.3s, background-color 0.3s, border-color 0.3s; + cursor: pointer; +} + +.discover-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-hover); +} + +.discover-cover { + width: 100%; + aspect-ratio: 3 / 4; + 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: var(--rating-color); + font-weight: 600; +} + +.discover-rating-label { + font-size: 11px; + padding: 2px 6px; + border-radius: var(--radius-xs); + background: var(--rating-bg); + color: var(--rating-color); +} + +.discover-reading-count { + font-size: 11px; + color: var(--text-light); +} + +.discover-reason { + margin-top: 6px; + font-size: 12px; + color: var(--accent); + background: var(--accent-bg); + padding: 4px 8px; + border-radius: var(--radius-xs); +} + +.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: var(--modal-overlay); + 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: var(--shadow-modal); + margin-bottom: 40px; + border: 1px solid var(--border); +} + +.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: var(--accent-bg); + 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(--chart-highlight-bg); + 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: var(--chart-review-bg); + border-radius: var(--radius-sm); + margin-bottom: 8px; + font-size: 14px; + color: var(--text-primary); + line-height: 1.7; + border-left: 3px solid var(--chart-review-border); +} + +.notes-detail-modal .review-meta { + font-size: 11px; + color: var(--text-light); + margin-top: 4px; +} + +.notes-detail-modal .review-star { + color: var(--rating-star); +} + +.notes-detail-modal .empty-state { + text-align: center; + padding: 40px; + color: var(--text-light); + font-size: 14px; +} + +/* ===== 想法详情独立页面 ===== */ +.thoughts-page { + min-height: 100vh; + background: var(--bg-primary); +} + +.thoughts-page-header { + background: var(--bg-card); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); + padding: 16px 0 20px; + position: sticky; + top: 60px; + z-index: 50; +} + +.back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 16px; + 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; + margin-bottom: 16px; +} + +.back-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-bg); +} + +.thoughts-book-header { + display: flex; + gap: 16px; + align-items: center; +} + +.thoughts-book-cover { + width: 64px; + height: 85px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + box-shadow: var(--shadow); +} + +.thoughts-book-title { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.thoughts-book-author { + font-size: 13px; + color: var(--text-light); + margin-top: 4px; +} + +.thoughts-book-stats { + display: flex; + gap: 16px; + margin-top: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.thoughts-book-stats strong { + color: var(--accent-dark); +} + +/* 想法页面工具栏 */ +.thoughts-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin: 24px 0 20px; + flex-wrap: wrap; +} + +.thoughts-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.thoughts-controls { + display: flex; + gap: 10px; + align-items: center; +} + +.thoughts-search-input { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--bg-card); + color: var(--text-primary); + font-size: 14px; + width: 240px; + transition: border-color 0.2s, box-shadow 0.2s; + outline: none; +} + +.thoughts-search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} + +.thoughts-search-input::placeholder { + color: var(--text-light); +} + +.thoughts-sort-select { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--bg-card); + color: var(--text-primary); + font-size: 14px; + cursor: pointer; + outline: none; + transition: border-color 0.2s; +} + +.thoughts-sort-select:hover { + border-color: var(--accent); +} + +.thoughts-sort-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); +} + +.thought-filter-btn { + padding: 6px 18px; + 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; + display: flex; + align-items: center; + gap: 6px; +} + +.thought-filter-btn .filter-count { + font-size: 12px; + background: var(--accent-bg); + color: var(--accent-dark); + padding: 1px 8px; + border-radius: 10px; +} + +.thought-filter-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.thought-filter-btn.active .filter-count { + background: rgba(255,255,255,0.25); + color: #fff; +} + +.thought-filter-btn:hover:not(.active) { + border-color: var(--accent); + color: var(--accent); +} + +/* 分类区块标题 */ +.thoughts-section-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin: 28px 0 16px; + padding-bottom: 10px; + border-bottom: 2px solid var(--border); + display: flex; + align-items: center; + gap: 8px; +} + +.thoughts-section-title .section-badge { + font-size: 12px; + font-weight: 600; + padding: 2px 10px; + border-radius: 10px; +} + +.badge-highlight { + background: var(--accent-bg-hover); + color: var(--accent-dark); +} + +.badge-review { + background: var(--rating-bg); + color: var(--rating-color); +} + +.badge-thought { + background: rgba(76, 175, 80, 0.12); + color: #4caf50; +} + +[data-theme="dark"] .badge-thought { + background: rgba(76, 175, 80, 0.15); + color: #66bb6a; +} + +/* 划线条目 */ +.thought-highlight-item { + padding: 14px 18px; + background: var(--chart-highlight-bg); + border-radius: var(--radius-sm); + margin-bottom: 10px; + font-size: 15px; + color: var(--text-primary); + line-height: 1.8; + border-left: 4px solid var(--accent-light); + transition: background-color 0.3s; +} + +.thought-highlight-meta { + font-size: 12px; + color: var(--text-light); + margin-top: 8px; + display: flex; + gap: 12px; +} + +/* 书评条目 */ +.thought-review-item { + padding: 16px 20px; + background: var(--chart-review-bg); + border-radius: var(--radius-sm); + margin-bottom: 10px; + font-size: 15px; + color: var(--text-primary); + line-height: 1.8; + border-left: 4px solid var(--rating-star); + transition: background-color 0.3s; +} + +.thought-review-stars { + color: var(--rating-star); + font-size: 14px; + margin-bottom: 6px; +} + +.thought-review-meta { + font-size: 12px; + color: var(--text-light); + margin-top: 8px; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +/* 想法条目 */ +.thought-thought-item { + padding: 16px 20px; + background: var(--bg-card); + border-radius: var(--radius-sm); + margin-bottom: 10px; + border-left: 4px solid #4caf50; + transition: background-color 0.3s, border-color 0.3s; +} + +[data-theme="dark"] .thought-thought-item { + border-left-color: #66bb6a; +} + +.thought-thought-abstract { + font-size: 13px; + color: var(--text-light); + background: var(--bg-secondary); + padding: 8px 12px; + border-radius: 6px; + margin-bottom: 10px; + line-height: 1.6; + border-left: 3px solid var(--border); +} + +.thought-thought-content { + font-size: 15px; + color: var(--text-primary); + line-height: 1.8; +} + +.thought-thought-meta { + font-size: 12px; + color: var(--text-light); + margin-top: 8px; + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.thoughts-empty { + text-align: center; + padding: 60px 0; + color: var(--text-light); + font-size: 15px; +} + +/* 章节分组 */ +.thoughts-chapter-group { + margin-bottom: 24px; +} + +.thoughts-chapter-title { + font-size: 14px; + font-weight: 600; + color: var(--accent-dark); + margin-bottom: 10px; + padding: 6px 0 6px 12px; + border-left: 4px solid var(--accent); +} + +/* ===== 回到顶部按钮 ===== */ +.back-to-top { + position: fixed; + bottom: 32px; + right: 32px; + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + font-size: 22px; + line-height: 1; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + z-index: 90; + opacity: 0; + visibility: hidden; + transform: translateY(20px); + transition: opacity 0.3s, visibility 0.3s, transform 0.3s, background-color 0.3s; +} + +.back-to-top.visible { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.back-to-top:hover { + background: var(--accent-dark); + transform: translateY(-2px); +} + +/* 书籍详情弹窗 - 查看想法按钮 */ +.detail-action-thoughts { + background: var(--bg-card); + border: 1px solid var(--accent); + color: var(--accent); +} + +.detail-action-thoughts:hover { + background: var(--accent-bg); +} + +/* ===== 响应式 ===== */ +@media (max-width: 768px) { + .nav-links { + display: none; + } + .hero-title { + font-size: 24px; + } + .hero-stats { + gap: 24px; + } + .hero-stat-value { + font-size: 32px; + } + .charts-grid { + grid-template-columns: 1fr; + } + .chart-card.large { + grid-column: auto; + } + .book-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + } + .top-books-list { + gap: 8px; + } + .top-book-row { + padding: 8px 12px; + gap: 10px; + } + .top-book-mini-cover { + width: 36px; + height: 48px; + } + .top-book-rank-num { + width: 28px; + height: 28px; + font-size: 13px; + } + .nav-update { + display: none; + } + .global-search-wrapper { + max-width: 100%; + } + .thoughts-toolbar { + flex-direction: column; + align-items: stretch; + } + .thoughts-controls { + flex-direction: column; + gap: 8px; + } + .thoughts-search-input { + width: 100%; + } + .thoughts-sort-select { + width: 100%; + } + .shelf-search { + width: 140px; + } + .shelf-categories { + gap: 6px; + } + .shelf-cat-parent { + font-size: 12px; + padding: 4px 10px; + } + .shelf-cat-sub { + font-size: 11px; + padding: 3px 9px; + } + .shelf-cat-subs { + padding-left: 10px; + } + .back-to-top { + bottom: 20px; + right: 20px; + width: 40px; + height: 40px; + font-size: 18px; + } + .thoughts-page-header { + top: 0; + } + .thoughts-book-cover { + width: 48px; + height: 64px; + } + .thoughts-book-title { + font-size: 16px; + } +} diff --git a/legacy/v1/static/js/app.js b/legacy/v1/static/js/app.js new file mode 100644 index 0000000..daf6e68 --- /dev/null +++ b/legacy/v1/static/js/app.js @@ -0,0 +1,1267 @@ +/* ===== 微信读书数据展示 - 前端逻辑 ===== */ + +const API_BASE = ""; + +// ===== 主题管理 ===== + +function getCurrentTheme() { + return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; +} + +function toggleTheme() { + const newTheme = getCurrentTheme() === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", newTheme); + localStorage.setItem("theme", newTheme); + refreshChartsForTheme(); +} + +function getChartColors() { + const cs = getComputedStyle(document.documentElement); + return { + axis: cs.getPropertyValue("--chart-axis").trim(), + axisLine: cs.getPropertyValue("--chart-axis-line").trim(), + splitLine: cs.getPropertyValue("--chart-split-line").trim(), + label: cs.getPropertyValue("--chart-label").trim(), + borderColor: cs.getPropertyValue("--chart-border-color").trim(), + tooltipBg: cs.getPropertyValue("--chart-tooltip-bg").trim(), + tooltipBorder: cs.getPropertyValue("--chart-tooltip-border").trim(), + tooltipText: cs.getPropertyValue("--chart-tooltip-text").trim(), + accent: cs.getPropertyValue("--accent").trim(), + accentLight: cs.getPropertyValue("--accent-light").trim(), + accentDark: cs.getPropertyValue("--accent-dark").trim(), + }; +} + +// ===== 工具函数 ===== + +/** + * 升级微信读书封面图 URL 到高清晰度版本。 + * 微信读书 CDN 的封面图通过文件名前缀区分分辨率: + * t6_ = 低清缩略图(~150px,Retina 屏模糊) + * s_ = 超低清缩略图(~90px,搜索结果常用,更模糊) + * t8_ = 高清版(~500px,适合 2x/3x 屏幕) + * 将 t6_ 和 s_ 前缀统一升级为 t8_ 高清版。 + */ +function hdCover(url) { + if (!url) return ""; + return url.replace(/\/(t6_|s_)/, "/t8_"); +} + +async function fetchAPI(path) { + const resp = await fetch(API_BASE + path); + if (!resp.ok) throw new Error(`API error: ${resp.status}`); + return resp.json(); +} + +function formatHours(seconds) { + if (!seconds) return "0"; + return (seconds / 3600).toFixed(1); +} + +function escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// ===== 数据库健康检查 ===== + +const chartInstances = {}; +let chartsLoaded = false; +let currentThoughtsData = null; +let currentThoughtsCategory = "all"; +let currentThoughtsSort = "time_desc"; +let currentThoughtsSearch = ""; + +function refreshChartsForTheme() { + if (!chartsLoaded) return; + Object.values(chartInstances).forEach((chart) => { + if (chart && !chart.isDisposed()) chart.dispose(); + }); + loadYearlyTrend(); + loadTimeDistribution(); + loadCategories(); + loadAuthors(); +} + +async function checkDBHealth() { + try { + const resp = await fetch(API_BASE + "/api/health"); + const data = await resp.json(); + if (resp.ok && data.database === "connected") { + document.getElementById("dbAlertBanner").style.display = "none"; + return true; + } else { + showDBAlert(data.message || "数据库连接失败"); + return false; + } + } catch (error) { + showDBAlert("无法连接到服务器,请检查后端服务是否运行。"); + return false; + } +} + +function showDBAlert(message) { + const banner = document.getElementById("dbAlertBanner"); + const detail = document.getElementById("dbAlertDetail"); + if (banner) banner.style.display = "flex"; + if (detail) detail.textContent = message; +} + +// ===== 标签页切换 ===== + +function switchTab(tabName) { + // 如果想法详情页正在显示,先关闭它回到主视图 + const thoughtsPage = document.getElementById("thoughtsPage"); + if (thoughtsPage && thoughtsPage.style.display !== "none") { + thoughtsPage.style.display = "none"; + document.getElementById("mainView").style.display = ""; + currentThoughtsData = null; + } + + // 关闭可能打开的弹窗 + closeModal("bookDetailModal"); + + document.querySelectorAll(".tab-panel").forEach((panel) => { + panel.classList.remove("active"); + }); + document.querySelectorAll(".nav-link").forEach((link) => { + link.classList.toggle("active", link.dataset.tab === tabName); + }); + + const target = document.getElementById(`tab-${tabName}`); + if (target) { + target.classList.add("active"); + } + + window.scrollTo({ top: 0, behavior: "smooth" }); + + // 懒加载图表 + if (tabName === "charts" && !chartsLoaded) { + chartsLoaded = true; + loadYearlyTrend(); + loadTimeDistribution(); + loadCategories(); + loadAuthors(); + } + + // 切换到图表时重绘尺寸 + if (tabName === "charts" && chartsLoaded) { + setTimeout(() => { + Object.values(chartInstances).forEach((chart) => { + if (chart && !chart.isDisposed()) chart.resize(); + }); + }, 100); + } +} + +// ===== 回到顶部按钮 ===== + +function initBackToTop() { + const btn = document.getElementById("backToTop"); + window.addEventListener("scroll", () => { + btn.classList.toggle("visible", window.scrollY > 400); + }); +} + +function scrollToTop() { + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +/** + * 在微信读书中打开书籍。 + * 优先使用 API 返回的 deep_link(https://weread.qq.com/... 网页链接), + * 没有则用 book_id 构造网页链接。先尝试 weread:// 深链接唤起 App, + * 1.5 秒后无响应则回退到网页版。 + */ +function openInWeRead(bookId, deepLink) { + const webUrl = deepLink || `https://weread.qq.com/#book/${bookId}`; + const schemeUrl = `weread://reading?bId=${bookId}`; + + // 尝试唤起 App(移动端),同时设置定时器回退到网页版 + let triggered = false; + const fallbackTimer = setTimeout(() => { + if (!triggered) window.open(webUrl, "_blank"); + }, 1500); + + // 监听页面可见性变化(App 唤起成功会切到后台) + const onVisChange = () => { + if (document.hidden) { + triggered = true; + clearTimeout(fallbackTimer); + } + }; + document.addEventListener("visibilitychange", onVisChange); + + // 尝试打开深链接 + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.src = schemeUrl; + document.body.appendChild(iframe); + setTimeout(() => document.body.removeChild(iframe), 2000); + + // 桌面端不会有 visibilitychange,确保 fallback 一定执行 + setTimeout(() => { + document.removeEventListener("visibilitychange", onVisChange); + }, 3000); +} + +// ===== 全局搜索(顶部常驻搜索框) ===== + +function globalSearch() { + const keyword = document.getElementById("globalSearchInput").value.trim(); + if (!keyword) return; + + // 切换到推荐标签页 + switchTab("discover"); + // 切换到搜索子标签 + switchDiscoverTab("search"); + // 填入搜索词并执行搜索 + document.getElementById("searchInput").value = keyword; + doSearch(); +} + +// ===== 书架分类标签(可折叠层级结构) ===== + +let shelfCategoryTree = {}; // { "历史": {count:114, subs:{"世界史":5,...}}, ... } +let shelfActiveCategory = ""; // 完整分类名,如 "历史-中国古代" +let shelfActiveParent = ""; // 父分类名,如 "历史";空表示"全部" +let shelfExpandedParents = new Set(); // 当前展开的父分类 +let shelfSortMode = "category"; // 始终按分类排序,前端不显示切换按钮 + +async function loadShelfCategories() { + try { + const data = await fetchAPI("/api/shelf?page=1&per_page=1"); + shelfCategoryTree = data.category_tree || {}; + renderShelfCategories(); + } catch (e) { + console.error("加载分类失败:", e); + } +} + +function renderShelfCategories() { + const container = document.getElementById("shelfCategories"); + if (!container) return; + + let html = ""; + + // "全部" 按钮 + const allActive = !shelfActiveParent && !shelfActiveCategory; + html += ``; + + // 父分类按钮(按书籍数量降序) + const parents = Object.entries(shelfCategoryTree) + .sort((a, b) => b[1].count - a[1].count); + + for (const [parent, info] of parents) { + const isActive = shelfActiveParent === parent && !shelfActiveCategory; + const isExpanded = shelfExpandedParents.has(parent); + const arrow = Object.keys(info.subs).length > 0 + ? `${isExpanded ? "▼" : "▶"}` + : ""; + + html += ``; + + // 展开的子分类 + if (isExpanded && Object.keys(info.subs).length > 0) { + html += `
`; + for (const [sub, count] of Object.entries(info.subs).sort((a, b) => b[1] - a[1])) { + const fullCat = `${parent}-${sub}`; + const subActive = shelfActiveCategory === fullCat; + html += ``; + } + html += `
`; + } + } + + container.innerHTML = html; +} + +function filterByParent(parent) { + if (parent) { + // 点击父分类:展开/折叠子分类,同时筛选该父分类下的所有书 + if (shelfExpandedParents.has(parent) && shelfActiveParent === parent && !shelfActiveCategory) { + // 已展开且已选中 → 折叠 + shelfExpandedParents.delete(parent); + shelfActiveParent = ""; + shelfActiveCategory = ""; + } else { + // 展开 + 筛选 + shelfExpandedParents.add(parent); + shelfActiveParent = parent; + shelfActiveCategory = ""; + } + } else { + // 点击"全部" + shelfExpandedParents.clear(); + shelfActiveParent = ""; + shelfActiveCategory = ""; + } + renderShelfCategories(); + loadShelf(1); +} + +function filterByCategory(fullCategory) { + shelfActiveCategory = fullCategory; + // 确保父分类已展开 + const parent = fullCategory.split("-")[0]; + shelfExpandedParents.add(parent); + shelfActiveParent = parent; + renderShelfCategories(); + loadShelf(1); +} + +// ===== 书架刷新 ===== + +async function refreshShelf() { + const btn = document.getElementById("shelfRefreshBtn"); + const textEl = btn.querySelector(".btn-text"); + btn.disabled = true; + btn.classList.add("loading"); + textEl.textContent = "同步中..."; + + try { + // Step 1: 从微信读书 API 同步数据到 MySQL + const resp = await fetch(API_BASE + "/api/sync", { method: "POST" }); + const syncResult = await resp.json(); + if (!syncResult.ok) { + throw new Error(syncResult.error || "同步失败"); + } + + // Step 2: 重新加载分类和书架 + textEl.textContent = "加载中..."; + await loadShelfCategories(); + await loadShelf(1); + } catch (e) { + console.error("刷新失败:", e); + alert("刷新失败: " + (e.message || "未知错误")); + } finally { + btn.disabled = false; + btn.classList.remove("loading"); + textEl.textContent = "刷新"; + } +} + +// ===== 初始化 ===== + +document.addEventListener("DOMContentLoaded", async () => { + const isHealthy = await checkDBHealth(); + if (!isHealthy) return; + + loadOverview(); + loadTopBooks(); + loadShelfCategories(); + loadShelf(1); + loadNotebooks(); + loadRecommend(0); + initBackToTop(); + + // 搜索和筛选事件 + document.getElementById("shelfSearch").addEventListener("input", debounce(() => loadShelf(1), 300)); + document.getElementById("finishedOnly").addEventListener("change", () => loadShelf(1)); +}); + +// ===== 总览数据 ===== +async function loadOverview() { + try { + const data = await fetchAPI("/api/overview"); + + const heroStats = document.getElementById("heroStats"); + heroStats.innerHTML = ` +
+
${formatHours(data.total_read_time)}
+
累计阅读 (小时)
+
+
+
${data.read_days}
+
阅读天数
+
+
+
${data.shelf_count}
+
书架藏书
+
+
+
${data.total_notes}
+
读书笔记
+
+ `; + + document.getElementById("heroSubtitle").textContent = + `自 ${data.regist_time} 加入微信读书,已陪伴你 ${data.read_days} 个阅读的日子`; + + const statsGrid = document.getElementById("statsGrid"); + statsGrid.innerHTML = ` +
+
⏱️
+
${data.total_read_time_str}
+
累计阅读时长
+
今年: ${data.year_read_time_str}
+
+
+
📅
+
${data.read_days}
+
累计阅读天数
+
今年: ${data.year_read_days} 天
+
+
+
📚
+
${data.shelf_count}
+
书架书籍
+
已读完: ${data.finished_count} 本
+
+
+
✍️
+
${data.total_notes}
+
读书笔记
+
来自 ${data.notebook_count} 本书
+
+ `; + + const timeStr = `数据更新: ${data.fetch_time}`; + document.getElementById("fetchTime").textContent = timeStr; + document.getElementById("footerTime").textContent = data.fetch_time; + } catch (e) { + console.error("加载总览失败:", e); + } +} + +// ===== 年度趋势图 ===== +async function loadYearlyTrend() { + try { + const data = await fetchAPI("/api/yearly-trend"); + const el = document.getElementById("chart-yearly"); + if (chartInstances.yearly) chartInstances.yearly.dispose(); + const chart = echarts.init(el); + chartInstances.yearly = chart; + + const c = getChartColors(); + const years = data.map((d) => d.year); + const hours = data.map((d) => d.hours); + + chart.setOption({ + tooltip: { + trigger: "axis", + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { color: c.tooltipText }, + formatter: (params) => { + const p = params[0]; + return `${p.name}年
阅读: ${p.value} 小时`; + }, + }, + grid: { left: 50, right: 30, top: 30, bottom: 40 }, + xAxis: { + type: "category", + data: years, + axisLabel: { color: c.axis }, + axisLine: { lineStyle: { color: c.axisLine } }, + }, + yAxis: { + type: "value", + name: "小时", + nameTextStyle: { color: c.axis }, + axisLabel: { color: c.axis }, + splitLine: { lineStyle: { color: c.splitLine } }, + }, + series: [{ + data: hours, + type: "bar", + barWidth: "50%", + itemStyle: { + borderRadius: [8, 8, 0, 0], + color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: c.accentLight }, + { offset: 1, color: c.accent }, + ] }, + }, + label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" }, + }], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载年度趋势失败:", e); + } +} + +// ===== 24小时时段分布 ===== +async function loadTimeDistribution() { + try { + const data = await fetchAPI("/api/time-distribution"); + const el = document.getElementById("chart-hours"); + if (chartInstances.hours) chartInstances.hours.dispose(); + const chart = echarts.init(el); + chartInstances.hours = chart; + + const c = getChartColors(); + const hours = data.map((d) => `${d.hour}时`); + const minutes = data.map((d) => d.duration_min); + + chart.setOption({ + tooltip: { + trigger: "axis", + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { color: c.tooltipText }, + formatter: (params) => { + const p = params[0]; + return `${p.name}
阅读: ${p.value} 分钟`; + }, + }, + grid: { left: 50, right: 20, top: 20, bottom: 40 }, + xAxis: { + type: "category", + data: hours, + axisLabel: { color: c.axis, fontSize: 10, interval: 2 }, + axisLine: { lineStyle: { color: c.axisLine } }, + }, + yAxis: { + type: "value", + name: "分钟", + nameTextStyle: { color: c.axis }, + axisLabel: { color: c.axis }, + splitLine: { lineStyle: { color: c.splitLine } }, + }, + series: [{ + data: minutes, + type: "line", + smooth: true, + symbol: "circle", + symbolSize: 4, + lineStyle: { width: 2, color: c.accent }, + itemStyle: { color: c.accent }, + areaStyle: { + color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: c.accent + "4D" }, + { offset: 1, color: c.accent + "05" }, + ] }, + }, + }], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载时段分布失败:", e); + } +} + +// ===== 偏好分类 ===== +async function loadCategories() { + try { + const data = await fetchAPI("/api/categories"); + const el = document.getElementById("chart-categories"); + if (chartInstances.categories) chartInstances.categories.dispose(); + const chart = echarts.init(el); + chartInstances.categories = chart; + + const c = getChartColors(); + const items = data.slice(0, 8).map((d) => ({ + name: d.category_title, + value: d.reading_time, + })); + + chart.setOption({ + tooltip: { + trigger: "item", + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { color: c.tooltipText }, + formatter: (p) => { + const hours = (p.value / 3600).toFixed(1); + return `${p.name}
阅读: ${hours} 小时 (${p.percent}%)`; + }, + }, + legend: { + type: "scroll", + orient: "vertical", + right: 10, + top: 20, + bottom: 20, + textStyle: { color: c.label, fontSize: 11 }, + }, + series: [{ + type: "pie", + radius: ["40%", "70%"], + center: ["35%", "50%"], + avoidLabelOverlap: false, + itemStyle: { borderRadius: 6, borderColor: c.borderColor, borderWidth: 2 }, + label: { show: false }, + emphasis: { + label: { show: true, fontSize: 14, fontWeight: "bold", color: c.tooltipText }, + }, + data: items, + color: ["#c17b4a", "#e8a87c", "#d4a574", "#b8860b", "#cd853f", "#daa520", "#bc8f8f", "#bdb76b"], + }], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载分类失败:", e); + } +} + +// ===== 偏好作者 ===== +async function loadAuthors() { + try { + const data = await fetchAPI("/api/authors"); + const el = document.getElementById("chart-authors"); + if (chartInstances.authors) chartInstances.authors.dispose(); + const chart = echarts.init(el); + chartInstances.authors = chart; + + const c = getChartColors(); + const top10 = data.slice(0, 10); + const names = top10.map((d) => d.name); + const counts = top10.map((d) => d.count); + + chart.setOption({ + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + backgroundColor: c.tooltipBg, + borderColor: c.tooltipBorder, + textStyle: { color: c.tooltipText }, + formatter: (params) => { + const p = params[0]; + return `${p.name}
阅读: ${p.value} 本`; + }, + }, + grid: { left: 100, right: 40, top: 20, bottom: 20 }, + xAxis: { + type: "value", + axisLabel: { color: c.axis }, + splitLine: { lineStyle: { color: c.splitLine } }, + }, + yAxis: { + type: "category", + data: names.reverse(), + axisLabel: { color: c.axis, fontSize: 12 }, + axisLine: { lineStyle: { color: c.axisLine } }, + }, + series: [{ + type: "bar", + data: counts.reverse(), + barWidth: "50%", + itemStyle: { + borderRadius: [0, 6, 6, 0], + color: { type: "linear", x: 0, y: 0, x2: 1, y2: 0, + colorStops: [ + { offset: 0, color: c.accentLight }, + { offset: 1, color: c.accent }, + ] }, + }, + label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" }, + }], + }); + window.addEventListener("resize", () => chart.resize()); + } catch (e) { + console.error("加载作者失败:", e); + } +} + +// ===== 读书排行(竖向列表) ===== +async function loadTopBooks() { + try { + const data = await fetchAPI("/api/top-books"); + const list = document.getElementById("topBooksGrid"); + + list.innerHTML = data + .map((book, i) => { + const tagsHtml = book.tags + .map((t) => `${t}`) + .join(""); + return ` +
+ ${i + 1} + ${book.title} +
+
${book.title}
+
+ ${book.author} + ${tagsHtml ? `
${tagsHtml}
` : ""} +
+
+
⏱ ${book.read_time_str}
+
`; + }) + .join(""); + } catch (e) { + console.error("加载排行失败:", e); + } +} + +// ===== 书架 ===== +let shelfCurrentPage = 1; + +async function loadShelf(page) { + shelfCurrentPage = page; + const keyword = document.getElementById("shelfSearch").value.trim(); + const finished = document.getElementById("finishedOnly").checked ? "1" : "0"; + const params = new URLSearchParams({ page: page, per_page: 60, finished: finished }); + if (keyword) params.set("keyword", keyword); + if (shelfActiveCategory) { + params.set("category", shelfActiveCategory); + } else if (shelfActiveParent) { + params.set("parent_category", shelfActiveParent); + } + if (shelfSortMode === "category") params.set("sort", "category"); + + try { + const data = await fetchAPI(`/api/shelf?${params}`); + const grid = document.getElementById("bookGrid"); + + document.getElementById("shelfInfo").textContent = + `共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`; + + grid.innerHTML = data.books.map((book) => renderBookCard(book)).join(""); + + renderPagination(page, data.total_pages); + } catch (e) { + console.error("加载书架失败:", e); + } +} + +function renderBookCard(book) { + const badges = []; + if (book.is_top) badges.push(`置顶`); + if (book.finish_reading) badges.push(`已读完`); + return ` +
+
+ ${book.title} + ${badges.join("")} +
+
${book.title}
+
${book.author}
+
`; +} + +function renderPagination(current, total) { + const pag = document.getElementById("pagination"); + if (total <= 1) { pag.innerHTML = ""; return; } + + let html = ``; + const maxButtons = 7; + let start = Math.max(1, current - 3); + let end = Math.min(total, start + maxButtons - 1); + start = Math.max(1, end - maxButtons + 1); + + if (start > 1) { + html += ``; + if (start > 2) html += `...`; + } + for (let i = start; i <= end; i++) { + html += ``; + } + if (end < total) { + if (end < total - 1) html += `...`; + html += ``; + } + html += ``; + pag.innerHTML = html; +} + +function debounce(fn, delay) { + let timer; + return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; +} + +// ===== 笔记概览 ===== +async function loadNotebooks() { + try { + const data = await fetchAPI("/api/notebooks"); + const summary = document.getElementById("notesSummary"); + + const totalHighlights = data.reduce((s, d) => s + d.note_count, 0); + const totalReviews = data.reduce((s, d) => s + d.review_count, 0); + const totalBookmarks = data.reduce((s, d) => s + d.bookmark_count, 0); + + summary.innerHTML = ` +
+
${data.length}
+
有笔记的书
+
+
+
${totalHighlights}
+
划线
+
+
+
${totalReviews}
+
想法/点评
+
+
+
${totalBookmarks}
+
书签
+
+ `; + + const grid = document.getElementById("notebooksGrid"); + grid.innerHTML = data + .map((nb) => { + const progress = nb.reading_progress || 0; + return ` +
+ ${nb.title} +
+
${nb.title}
+
${nb.author}
+
+ 划线 ${nb.note_count} + 想法 ${nb.review_count} + 书签 ${nb.bookmark_count} +
+
+
+
+
+
`; + }) + .join(""); + } catch (e) { + console.error("加载笔记失败:", e); + } +} + +// ===== 书籍详情弹窗 ===== +function openBook(bookId) { + 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('')}
+ ${p.is_start_reading ? ` +
+
+ 阅读进度: ${p.progress}% + ${p.record_reading_time_str ? ' · 累计 ' + p.record_reading_time_str : ''} + ${p.finish_time ? ' · 读完于 ' + p.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 openThoughtsPage(bookId, title, author, cover) { + if (!bookId) return; + + // 隐藏主视图,显示想法页面 + document.getElementById("mainView").style.display = "none"; + document.getElementById("thoughtsPage").style.display = "block"; + window.scrollTo({ top: 0, behavior: "auto" }); + + // 渲染头部 + const header = document.getElementById("thoughtsBookHeader"); + header.innerHTML = ` + ${title} +
+
${title}
+
${author || ''}
+
加载中...
+
+ `; + + const content = document.getElementById("thoughtsContent"); + content.innerHTML = `
`; + + fetchAPI(`/api/book/notes?book_id=${bookId}`) + .then((data) => { + currentThoughtsData = data; + renderThoughtsPage(data); + }) + .catch((e) => { + content.innerHTML = `
加载失败: ${e.message}
`; + }); +} + +function closeThoughtsPage() { + document.getElementById("thoughtsPage").style.display = "none"; + document.getElementById("mainView").style.display = ""; + currentThoughtsData = null; +} + +function renderThoughtsPage(data) { + // 更新统计 + const stats = document.getElementById("thoughtsBookStats"); + + // 分类:划线 / 书评 / 想法 + const highlights = data.highlights || []; + const bookReviews = (data.reviews || []).filter((r) => r.is_finish); + const thoughts = (data.reviews || []).filter((r) => !r.is_finish); + + stats.innerHTML = ` + 划线 ${highlights.length} + 书评 ${bookReviews.length} + 想法 ${thoughts.length} + `; + + const content = document.getElementById("thoughtsContent"); + + if (highlights.length === 0 && bookReviews.length === 0 && thoughts.length === 0) { + content.innerHTML = `
暂无笔记数据
`; + return; + } + + let html = ` +
+
+ + + + +
+
+ + +
+
+
+ `; + + content.innerHTML = html; + currentThoughtsCategory = "all"; + currentThoughtsSort = "time_desc"; + currentThoughtsSearch = ""; + renderThoughtsList("all"); +} + +function filterThoughts(category) { + document.querySelectorAll(".thought-filter-btn").forEach((btn) => { + btn.classList.remove("active"); + }); + event.target.closest(".thought-filter-btn").classList.add("active"); + currentThoughtsCategory = category; + renderThoughtsList(category); +} + +function onThoughtsSearch(val) { + currentThoughtsSearch = val.trim().toLowerCase(); + renderThoughtsList(currentThoughtsCategory); +} + +function onThoughtsSort(val) { + currentThoughtsSort = val; + renderThoughtsList(currentThoughtsCategory); +} + +/** 解析日期字符串为时间戳,无效则返回 0 */ +function parseDate(str) { + if (!str) return 0; + const d = new Date(str.replace(/-/g, "/")); + return isNaN(d.getTime()) ? 0 : d.getTime(); +} + +/** 对数组按排序模式排序,返回新数组 */ +function sortByMode(arr) { + const sorted = [...arr]; + if (currentThoughtsSort === "time_desc") { + sorted.sort((a, b) => parseDate(b.create_time) - parseDate(a.create_time)); + } else if (currentThoughtsSort === "time_asc") { + sorted.sort((a, b) => parseDate(a.create_time) - parseDate(b.create_time)); + } else if (currentThoughtsSort === "chapter") { + sorted.sort((a, b) => { + const ua = parseInt(a.chapter_uid) || 0; + const ub = parseInt(b.chapter_uid) || 0; + if (ua !== ub) return ua - ub; + // 同章节内按 range 起始位置排序 + const ra = a.range ? parseInt(a.range.split("-")[0]) : 0; + const rb = b.range ? parseInt(b.range.split("-")[0]) : 0; + return ra - rb; + }); + } + return sorted; +} + +/** 过滤搜索关键词 */ +function matchSearch(text) { + if (!currentThoughtsSearch) return true; + return (text || "").toLowerCase().includes(currentThoughtsSearch); +} + +function renderThoughtsList(category) { + const data = currentThoughtsData; + if (!data) return; + + // 原始分类 + let highlights = data.highlights || []; + let bookReviews = (data.reviews || []).filter((r) => r.is_finish); + let thoughts = (data.reviews || []).filter((r) => !r.is_finish); + + // 搜索过滤 + highlights = highlights.filter((h) => matchSearch(h.mark_text)); + bookReviews = bookReviews.filter((r) => matchSearch(r.content) || matchSearch(r.abstract)); + thoughts = thoughts.filter((r) => matchSearch(r.content) || matchSearch(r.abstract)); + + // 排序 + highlights = sortByMode(highlights); + bookReviews = sortByMode(bookReviews); + thoughts = sortByMode(thoughts); + + const list = document.getElementById("thoughtsList"); + let html = ""; + + // 划线区 + if (category === "all" || category === "highlight") { + if (highlights.length > 0) { + html += `
📌 划线 ${highlights.length}
`; + if (currentThoughtsSort === "chapter") { + // 按章节分组展示 + const grouped = {}; + highlights.forEach((h) => { + const ch = h.chapter_title || "未知章节"; + if (!grouped[ch]) grouped[ch] = []; + grouped[ch].push(h); + }); + for (const [chTitle, items] of Object.entries(grouped)) { + html += `
`; + html += `
${chTitle}
`; + items.forEach((h) => { + html += ` +
+ ${escapeHtml(h.mark_text)} +
+ ${h.chapter_title ? `${h.chapter_title}` : ""} + ${h.create_time ? `${h.create_time}` : ""} +
+
+ `; + }); + html += `
`; + } + } else { + // 按时间排序,平铺展示(标注章节) + highlights.forEach((h) => { + html += ` +
+ ${escapeHtml(h.mark_text)} +
+ ${h.chapter_title ? `${h.chapter_title}` : ""} + ${h.create_time ? `${h.create_time}` : ""} +
+
+ `; + }); + } + } + } + + // 书评区 + if (category === "all" || category === "review") { + if (bookReviews.length > 0) { + html += `
⭐ 书评 ${bookReviews.length}
`; + bookReviews.forEach((r) => { + const starVal = Math.max(0, Math.min(5, parseInt(r.star) || 0)); + const stars = starVal > 0 ? `
${"★".repeat(starVal)}${"☆".repeat(5 - starVal)}
` : ""; + html += ` +
+ ${stars} + ${escapeHtml(r.content)} +
+ ${r.chapter_name ? `${r.chapter_name}` : ""} + ${r.create_time || ""} +
+
+ `; + }); + } + } + + // 想法区 + if (category === "all" || category === "thought") { + if (thoughts.length > 0) { + html += `
💭 想法 ${thoughts.length}
`; + thoughts.forEach((r) => { + const abstract = r.abstract ? `
${escapeHtml(r.abstract)}
` : ""; + html += ` +
+ ${abstract} +
${escapeHtml(r.content)}
+
+ ${r.chapter_name ? `${r.chapter_name}` : ""} + ${r.create_time || ""} +
+
+ `; + }); + } + } + + if (!html) { + if (currentThoughtsSearch) { + html = `
未找到包含"${escapeHtml(currentThoughtsSearch)}"的内容
`; + } else { + html = `
该分类暂无内容
`; + } + } + + list.innerHTML = html; +} + +// ===== 弹窗控制 ===== +function showModal(id) { + document.getElementById(id).classList.add("active"); + document.body.style.overflow = "hidden"; +} + +function closeModal(id) { + document.getElementById(id).classList.remove("active"); + document.body.style.overflow = ""; +} + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeModal("bookDetailModal"); + if (document.getElementById("thoughtsPage").style.display !== "none") { + closeThoughtsPage(); + } + } +}); + +// ===== 推荐发现 ===== +let recommendMaxIdx = 0; + +function switchDiscoverTab(tab) { + const recPanel = document.getElementById("recommendPanel"); + const searchPanel = document.getElementById("searchPanel"); + const tabRec = document.getElementById("tabRecommend"); + const tabSearch = document.getElementById("tabSearch"); + + if (tab === "recommend") { + recPanel.style.display = ""; + searchPanel.style.display = "none"; + tabRec.classList.add("active"); + tabSearch.classList.remove("active"); + } else { + recPanel.style.display = "none"; + searchPanel.style.display = ""; + tabRec.classList.remove("active"); + tabSearch.classList.add("active"); + } +} + +async function loadRecommend(maxIdx) { + const grid = document.getElementById("recommendGrid"); + grid.innerHTML = `
`; + + 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/legacy/v1/templates/index.html b/legacy/v1/templates/index.html new file mode 100644 index 0000000..803e317 --- /dev/null +++ b/legacy/v1/templates/index.html @@ -0,0 +1,211 @@ + + + + + + 我的读书数据 + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+

阅读,是一场漫长的旅行

+

正在加载数据...

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

📊 阅读数据可视化

+
+
+

年度阅读时长趋势

+
+
+
+

24小时阅读时段分布

+
+
+
+

偏好阅读分类

+
+
+
+

偏好作者 TOP 10

+
+
+
+
+
+ + +
+
+

🏆 阅读时长排行

+
+
+
+ + +
+
+

📚 我的书架

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

✍️ 笔记概览

+
+
+
+
+ + +
+
+

🔍 推荐发现

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

数据来源:微信读书 · 数据存储:本地 MySQL · 更新时间:

+ +
+
+ + + + diff --git a/start_dashboard.sh b/start_dashboard.sh new file mode 100755 index 0000000..e06b743 --- /dev/null +++ b/start_dashboard.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# ========================================================================== +# 阅间 · 微信读书看板 v2 —— 启动脚本 +# -------------------------------------------------------------------------- +# 用法: +# ./start_dashboard.sh # 默认 8770 端口 +# PORT=8080 ./start_dashboard.sh # 指定端口(注意 8080 可能被旧实例占用) +# +# 需要设置 WEREAD_API_KEY;MySQL 不可用时服务仍会启动(部分功能降级)。 +# ========================================================================== +set -euo pipefail + +cd "$(dirname "$0")" + +PYTHON_BIN="${PYTHON_BIN:-$HOME/.workbuddy/binaries/python/envs/default/bin/python}" +if [ ! -x "$PYTHON_BIN" ]; then + PYTHON_BIN="$(command -v python3)" +fi + +export WEREAD_API_KEY="${WEREAD_API_KEY:-}" +export PORT="${PORT:-8770}" + +if [ -z "$WEREAD_API_KEY" ]; then + echo "[警告] WEREAD_API_KEY 未设置,书籍详情 / 笔记 / 推荐 / 搜索等实时接口将不可用" + echo " export WEREAD_API_KEY=wrk-xxxxxxxx" +fi + +echo "[信息] Python: $PYTHON_BIN" +echo "[信息] 启动端口: $PORT" +exec "$PYTHON_BIN" app.py diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..c40ae2d --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,11 @@ + +:root{--bg:#f7f8fa;--panel:#fff;--text:#242a34;--muted:#9298a4;--line:#edf0f3;--accent:#356ce5;--soft:#edf3ff;--sidebar:#fff;--shadow:0 8px 32px #24355005;--quote:#f8f6f1;--green:#449682} +[data-theme=dark]{--bg:#14171e;--panel:#1d222c;--text:#e7eaf0;--muted:#8892a4;--line:#2c323f;--accent:#80a5ff;--soft:#26344e;--sidebar:#191d26;--shadow:0 8px 32px #0001;--quote:#292820;--green:#75c6ac;color-scheme:dark} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;transition:background .25s,color .25s}button,input{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:4px}button{border:0;background:none}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}.sidebar{position:fixed;inset:0 auto 0 0;width:218px;padding:39px 23px 24px;background:var(--sidebar);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:5}.logo{display:flex;align-items:center;gap:11px;font-size:24px;font-weight:650;letter-spacing:2px}.logo-mark{background:#356ce5;color:white;width:37px;height:37px;display:grid;place-items:center;border-radius:12px}.logo-mark svg{width:25px;height:25px}.logo small{display:block;letter-spacing:2px;font-size:9px;color:var(--muted);margin-top:3px;font-weight:500}.navlabel{font-size:10px;color:var(--muted);letter-spacing:1.7px;margin:53px 14px 15px}.nav{display:flex;flex-direction:column;gap:9px}.nav button{text-align:left;padding:14px 17px;border-radius:9px;display:flex;align-items:center;gap:13px;color:#818997}.nav button.active{color:var(--accent);background:var(--soft);font-weight:600}.nav button:hover{background:var(--soft)}.nav em{margin-left:auto;font-size:10px;font-style:normal;background:var(--line);padding:2px 6px;border-radius:4px}.sidebar-bottom{margin-top:auto}.connection{padding:16px 12px;border:1px solid var(--line);border-radius:10px;font-size:12px;line-height:1.9}.dot{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:7px}.connection small{display:block;font-size:10px;color:var(--muted)}.profile{display:flex;align-items:center;gap:10px;margin-top:24px;padding:0 8px}.avatar{width:33px;height:33px;border-radius:50%;display:grid;place-items:center;background:#e4eadf;color:#5e7252;font-family:Georgia;font-size:18px}.profile small{display:block;font-size:10px;color:var(--muted);margin-top:4px}.main{margin-left:218px;padding:0 40px 32px;max-width:1800px}.topbar{height:91px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.breadcrumb{font-size:12px;color:var(--muted)}.breadcrumb span{color:var(--text);margin-left:12px}.top-actions{display:flex;align-items:center;gap:20px}.searchbox{display:flex;align-items:center;gap:10px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:9px 12px}.searchbox input{background:none;border:0;outline:none;width:180px;color:var(--text);font-size:11px}.key{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:2px 4px}.iconbtn{padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.welcome{display:flex;justify-content:space-between;align-items:center;margin:33px 0 27px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);margin-bottom:10px}h1{font-size:27px;letter-spacing:.5px;margin:0 0 10px;font-weight:600}h1 span{font-family:Georgia;font-size:25px;color:#c3a977}p{margin:0;color:var(--muted);font-size:12px;line-height:1.8}.welcome-right{display:flex;gap:12px;align-items:center}.demo-tag{font-size:10px;padding:6px 9px;background:var(--quote);border:1px solid var(--line);border-radius:5px;color:var(--muted)}.primary{background:#356ce5;color:white;padding:11px 16px;border-radius:7px;font-size:12px}.primary svg{width:15px;height:15px;margin-right:6px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:23px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stat{padding:21px 23px}.stat-label{display:flex;justify-content:space-between;align-items:center;color:var(--muted);font-size:12px}.stat-label svg{width:17px;height:17px;color:#9aa8bf}.stat-value{font-family:Georgia,serif;font-size:34px;letter-spacing:-1px;margin:14px 0 12px}.stat-value small{font-family:"PingFang SC",sans-serif;font-size:11px;color:var(--muted);letter-spacing:0;margin-left:5px}.stat-foot{font-size:10px;color:var(--muted)}.stat-foot b{color:var(--green);font-weight:500;margin-right:7px}.columns{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:23px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.section-head h2{font-size:15px;font-weight:600;margin:0}.section-head h2 small{font-weight:400;color:var(--muted);font-size:10px;margin-left:10px}.text-button{font-size:11px;color:var(--muted);padding:3px}.text-button:hover{color:var(--accent)}.trend{padding:24px 25px;margin-bottom:23px}.segmented{background:var(--bg);padding:3px;display:flex;border-radius:6px}.segmented button{font-size:10px;padding:5px 12px;color:var(--muted);border-radius:4px}.segmented button.active{background:var(--panel);color:var(--accent);box-shadow:0 1px 5px #0001}.trend-total{font-family:Georgia;font-size:25px;margin-bottom:10px}.trend-total small{font-family:"PingFang SC";font-size:10px;color:var(--muted);margin-left:9px}.chart{height:130px;position:relative;padding-left:30px}.gridlines{position:absolute;inset:0 0 15px 30px;display:flex;flex-direction:column;justify-content:space-between}.gridlines i{border-top:1px dashed var(--line);position:relative}.gridlines i:before{content:attr(data-label);position:absolute;left:-29px;top:-6px;color:var(--muted);font-size:9px;font-style:normal}.bars{display:flex;align-items:end;justify-content:space-around;height:100%;gap:14px;position:relative;padding-bottom:15px}.bar{height:var(--h);max-width:33px;flex:1;background:#dbe6fc;border-radius:4px 4px 0 0;position:relative;transition:height .35s}.bar:nth-child(5){background:#4d7ce7}.bar span{position:absolute;top:calc(100% + 9px);left:50%;transform:translateX(-50%);font-size:9px;color:var(--muted);white-space:nowrap}.bar:hover{background:#7199ef}.bar:hover:before{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);white-space:nowrap;background:var(--text);color:var(--panel);padding:5px 7px;font-size:10px;border-radius:4px}.shelf{padding:24px 25px}.tabs{display:flex;gap:25px;border-bottom:1px solid var(--line);margin-top:-4px;margin-bottom:24px}.tabs button{padding:0 0 12px;font-size:11px;color:var(--muted);border-bottom:2px solid transparent}.tabs button.active{color:var(--accent);border-bottom-color:var(--accent)}.books{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:23px}.book{text-align:left;padding:0;min-width:0;transition:transform .2s}.book:hover{transform:translateY(-5px)}.cover{height:160px;width:112px;max-width:100%;border-radius:2px 5px 5px 2px;position:relative;padding:15px 12px;overflow:hidden;box-shadow:5px 8px 13px #17212c20,inset 4px 0 3px #ffffff25;display:flex;flex-direction:column;color:#fff}.cover:after{content:"";position:absolute;inset:0;border-left:4px solid #0002;pointer-events:none}.cover .tiny{font-size:6px;letter-spacing:1px;opacity:.8}.cover .title{font-family:"Songti SC","STSong",serif;line-height:1.3;font-size:22px;margin-top:15px;font-weight:700}.cover .author{font-size:7px;margin-top:10px}.cover .publisher{font-size:6px;margin-top:auto;letter-spacing:1px}.c1{background:#efe9d8;color:#374432}.c1 .title{font-size:19px;letter-spacing:1px;margin-top:15px;z-index:1}.c1:before{content:"";position:absolute;width:130px;height:110px;border:1px solid #73876a66;border-radius:50%;left:-29px;bottom:-24px;box-shadow:0 0 0 8px #72826313,0 0 0 18px #72826313,0 0 0 30px #72826313}.c2{background:#182b3b}.c2 .title{font-size:37px;letter-spacing:6px}.c2:before{content:"";position:absolute;left:20px;bottom:-42px;width:115px;height:115px;border:1px solid #b8855144;border-radius:50%;box-shadow:0 0 0 8px #b8855122,0 0 0 17px #b8855111}.c3{background:#db502e}.c3 .title{font-size:19px;color:#fff3d7}.c3:before{content:"";position:absolute;width:60px;height:60px;background:#f4c66a;bottom:13px;right:-8px;border-radius:50%}.c4{background:#f0eee2;color:#282722}.c4 .title{font-size:24px}.c4:before{content:"";position:absolute;width:90px;height:90px;bottom:-24px;right:-25px;background:repeating-linear-gradient(45deg,#3d6274 0 5px,transparent 5px 10px);transform:rotate(-15deg)}.book h3{font-size:12px;font-weight:500;margin:14px 0 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.book p{font-size:10px}.progress{height:3px;background:var(--line);border-radius:3px;margin-top:13px}.progress i{display:block;height:100%;background:#8aa7d8;width:var(--progress)}.book-meta{font-size:9px;color:var(--muted);display:flex;justify-content:space-between;margin-top:7px}.rightcol{display:flex;flex-direction:column;gap:23px}.goal{padding:24px}.goal-ring{margin:12px auto 16px;width:142px;height:142px;border-radius:50%;background:conic-gradient(#679483 0 216deg,var(--line) 216deg);padding:9px;transform:rotate(-90deg)}.ring-inner{width:100%;height:100%;border-radius:50%;background:var(--panel);display:flex;flex-direction:column;align-items:center;justify-content:center;transform:rotate(90deg)}.ring-inner strong{font-family:Georgia;font-size:36px;font-weight:400}.ring-inner small{color:var(--muted);font-size:10px;margin-top:5px}.goal-caption{text-align:center;font-size:11px;margin-bottom:18px}.goal-caption span{color:var(--green)}.goal-footer{padding-top:15px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font-size:10px;color:var(--muted)}.goal-footer b{color:var(--text);font-weight:500}.notes{padding:24px}.note{border-left:2px solid #d7bd7e;padding-left:13px;margin:0 0 19px}.note blockquote{font-family:"Songti SC","STSong",serif;font-size:13px;line-height:1.9;margin:0 0 11px}.note small{font-size:9px;color:var(--muted)}.note:last-child{margin-bottom:0}.quote{padding:20px 24px;background:var(--quote);border-radius:12px;position:relative}.quote .eyebrow{font-size:9px;letter-spacing:1.5px}.quote p{font-family:"Songti SC",serif;color:var(--text);font-size:13px;line-height:1.9}.quote small{display:block;text-align:right;margin-top:12px;color:var(--muted);font-size:9px}.footer{display:flex;justify-content:space-between;margin-top:24px;font-size:9px;color:var(--muted)}.empty{padding:40px;text-align:center;color:var(--muted);grid-column:1/-1}.notes-page{display:grid;gap:20px}.notes-page .note{padding:20px;margin:0}.hidden{display:none!important}dialog{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:18px;padding:32px;width:520px;max-width:90vw;box-shadow:0 30px 100px #0003}dialog::backdrop{background:#10182877;backdrop-filter:blur(5px)}.dialog-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.dialog-top h2{margin:0;font-size:18px}.detail-body{display:flex;gap:26px;margin-bottom:22px}.detail-info{flex:1}.detail-info h3{font-size:22px;margin:8px 0}.detail-info p{margin:8px 0}.detail-desc{font-size:13px;line-height:2;color:var(--muted)}#toast{position:fixed;bottom:30px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--panel);padding:13px 23px;border-radius:10px;z-index:30;font-size:12px}.fade{animation:fade .5s ease both}@keyframes fade{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}@media(min-width:1500px){.main{padding-left:55px;padding-right:55px}.columns{grid-template-columns:minmax(0,1fr) 330px}.cover{height:185px;width:130px}.chart{height:160px}}@media(max-width:1150px){.sidebar{width:180px;padding-left:16px;padding-right:16px}.main{margin-left:180px;padding:0 24px 24px}.columns{grid-template-columns:minmax(0,1fr) 250px;gap:16px}.books{gap:12px}.cover{height:140px}.stats{gap:12px}.stat{padding:18px 15px}.shelf,.trend{padding:20px}.searchbox input{width:130px}.welcome-right .demo-tag{display:none}}@media(max-width:900px){.stats{grid-template-columns:repeat(2,1fr)}.sidebar{width:76px;padding:28px 12px}.logo{justify-content:center}.logo>div:last-child,.navlabel,.nav button span,.nav em,.sidebar-bottom{display:none}.nav{margin-top:45px}.nav button{padding:14px;justify-content:center}.main{margin-left:76px;padding:0 22px 24px}.columns{grid-template-columns:1fr}.rightcol{display:grid;grid-template-columns:1fr 1fr}.quote{grid-column:1/-1}.cover{height:165px}.welcome-right .primary{font-size:0}.welcome-right .primary svg{margin:0}.stat-value{font-size:28px}.topbar{height:75px}}@media(max-width:600px){.main{padding:0 16px 20px;margin-left:60px}.sidebar{width:60px;padding:25px 6px}.logo-mark{width:33px;height:33px}.nav button{padding:12px}.searchbox{padding:8px}.searchbox input{width:112px}.key,.breadcrumb,.top-actions>.avatar{display:none}.top-actions{width:100%;justify-content:space-between;gap:8px}.welcome{margin-top:25px}h1{font-size:21px}.welcome p{font-size:10px}.eyebrow{font-size:8px}.stats{grid-template-columns:repeat(2,1fr)}.stat{padding:16px}.stat-value{margin:10px 0}.rightcol{display:flex}.books{grid-template-columns:repeat(2,1fr);gap:24px 15px}.cover{height:160px;width:112px}.section-head h2 small{display:none}.tabs{gap:18px}.footer{font-size:8px}.footer span:last-child{display:none}.detail-body{gap:16px}.detail-info h3{font-size:18px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} +/* 阅读目标与内容页:与既有主题变量共用色彩。 */ +:root{--muted:#717b8b}[data-theme=dark]{--muted:#a2adbf}h3{font-size:14px;margin:0}button:disabled{opacity:.45;cursor:not-allowed}.demo-banner{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:18px}.welcome{margin-top:26px}.page-actions{display:flex;align-items:center;gap:10px}.secondary{padding:10px 15px;border:1px solid var(--line);background:var(--panel);border-radius:7px;font-size:12px}.pill{display:inline-block;padding:5px 8px;font-size:10px;border-radius:5px;background:var(--soft);color:var(--accent);white-space:nowrap}.pill.green{background:#6a9a8219;color:var(--green)}.pill.amber{background:#c3923919;color:#b78a3f}.hint{font-size:11px;color:var(--muted);line-height:1.8}.panel{padding:24px}.panel+.panel{margin-top:20px}.toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:0 0 22px}.toolbar .tabs{margin:0;flex-wrap:wrap}.toolbar input,.toolbar select,.field input,select{border:1px solid var(--line);border-radius:7px;padding:10px 12px;background:var(--panel);color:var(--text);font:inherit;font-size:12px}.toolbar select{max-width:100%}.field{display:flex;flex-direction:column;gap:8px;font-size:12px;margin-bottom:18px}.field input{width:100%;background:var(--bg)}.date-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px}.metrics-row{display:flex;gap:24px;padding:18px 0;margin-bottom:10px;color:var(--muted);font-size:11px}.metrics-row strong{font-family:Georgia;font-size:28px;color:var(--text);margin-right:6px;font-weight:400}.goals-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.goal-card{padding:25px;position:relative;overflow:hidden}.goal-card:before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--green)}.goal-card h3{font-size:19px;margin:17px 0 8px;word-break:break-word}.goal-card .section-head{margin-bottom:0}.goal-date{font-size:11px;color:var(--muted)}.cover-stack{display:flex;gap:0;margin:24px 0 20px;height:92px;padding-left:4px}.mini-cover{width:66px;height:92px;padding:10px 8px;border-radius:2px 4px 4px 2px;box-shadow:4px 5px 10px #0002;display:flex;flex-direction:column;flex-shrink:0;position:relative;overflow:hidden;border-left:3px solid #0002}.cover-stack .mini-cover{margin-left:-4px;transform:rotate(-3deg);transform-origin:bottom}.cover-stack .mini-cover:nth-child(2n){transform:rotate(4deg)}.mini-cover b{font-family:'Songti SC',serif;font-size:12px;position:relative;z-index:1;line-height:1.5}.mini-cover small{font-size:6px;position:relative;margin-top:auto}.stack-more{align-self:center;margin-left:18px;color:var(--muted);font-size:12px}.goal-progress{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--muted);margin:12px 0 9px}.goal-progress strong{font-size:22px;color:var(--text);font-family:Georgia;font-weight:400}.goal-card .progress{height:5px;margin:0 0 19px}.goal-card .progress i{background:var(--green)}.goal-card-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding-top:16px}.goal-card-footer small{font-size:10px;color:var(--muted)}.goal-card-footer button{font-size:11px;color:var(--accent)}.goal-intro{display:grid;grid-template-columns:1fr auto;gap:28px;background:var(--quote);padding:26px 30px;margin-bottom:24px;border:1px solid var(--line);border-radius:12px}.goal-intro h2{font-family:'Songti SC',serif;font-size:23px;font-weight:500;margin:0 0 10px}.goal-intro p{max-width:500px}.goal-intro .symbol{font-family:Georgia;font-size:65px;align-self:center;color:#819583}.goal-summary-row{display:block;text-align:left;width:100%;padding:16px 0;border-top:1px solid var(--line)}.goal-summary-row strong{font-size:12px;font-weight:500}.goal-summary-row small{display:block;margin-top:7px;color:var(--muted);font-size:10px}.goal-summary-row .progress{margin-top:10px}.list-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.list-card{padding:21px;display:block;text-align:left;transition:transform .2s}.list-card:hover{transform:translateY(-3px)}.list-art{height:117px;display:flex;align-items:center;justify-content:center;background:var(--quote);border-radius:7px;margin-bottom:18px}.list-art .mini-cover:nth-child(2){transform:translateY(-5px);margin:0 -4px;z-index:1}.list-card h3{font-size:15px;margin-bottom:8px}.list-card p{font-size:11px}.list-card>small{display:block;color:var(--muted);font-size:10px;margin-top:15px}.full-books{grid-template-columns:repeat(5,minmax(0,1fr));gap:26px}.full-books .cover{height:168px;width:118px}.row-list{display:flex;flex-direction:column}.book-row{display:flex;align-items:center;gap:15px;padding:14px 0;border-bottom:1px solid var(--line)}.book-row:last-child{border:0}.book-row .mini-cover{width:42px;height:58px;padding:5px}.book-row .mini-cover b{font-size:8px}.book-row .mini-cover small{display:none}.book-row-text{flex:1;min-width:0}.book-row-text b{font-size:12px;font-weight:500}.book-row-text p{font-size:10px;margin-top:4px}.book-row .text-button{color:var(--accent)}.book-row-status{font-size:11px;color:var(--green)}.book-row .progress{max-width:200px}.notebook-grid{display:grid;grid-template-columns:230px minmax(0,1fr);gap:24px}.notebook-menu button{display:block;width:100%;text-align:left;border-radius:8px;padding:16px;margin-bottom:7px;border:1px solid transparent}.notebook-menu button.active{background:var(--soft);border-color:var(--line)}.notebook-menu button b{font-size:12px;font-weight:500}.notebook-menu button small{display:block;font-size:10px;margin-top:6px;color:var(--muted)}.note-content{padding:22px;margin:0 0 15px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.note-content blockquote{margin:15px 0;font-family:'Songti SC',serif;font-size:16px;line-height:1.9}.note-content small{color:var(--muted);font-size:10px}.note-original{background:var(--quote);border-left:2px solid #b6a475;padding:12px;font-size:12px;line-height:1.9;margin-top:12px}.detail-tabs{display:flex;gap:19px;border-bottom:1px solid var(--line);margin:25px 0 18px;overflow:auto}.detail-tabs button{white-space:nowrap;font-size:12px;color:var(--muted);padding:0 0 12px;border-bottom:2px solid transparent}.detail-tabs button.active{color:var(--accent);border-color:var(--accent)}#detail{width:760px;max-height:90vh}#goal-editor{width:700px;max-height:90vh}.picker-head{margin:22px 0 10px}.picker-list{max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-top:12px}.pick-row{display:flex;align-items:center;gap:12px;padding:12px 15px;border-bottom:1px solid var(--line);cursor:pointer}.pick-row:last-child{border:0}.pick-row input{accent-color:#356ce5;width:16px;height:16px}.pick-row b{font-size:12px;font-weight:500}.pick-row small{display:block;color:var(--muted);font-size:10px;margin-top:4px}.pick-row .pill{margin-left:auto}.picker-lists{display:flex;gap:7px;flex-wrap:wrap;margin:12px 0}.picker-lists button{border:1px solid var(--line);border-radius:6px;font-size:11px;padding:8px;background:var(--bg)}.picker-lists button.active{background:var(--soft);color:var(--accent)}.editor-footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding-top:20px;margin-top:12px}.editor-footer>span{font-size:10px;color:var(--muted)}.form-error{color:#bd5151;font-size:12px;margin:12px 0}.insight-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-top:22px}.insight h3{margin-bottom:20px}.insight-line{display:flex;justify-content:space-between;gap:12px;padding:10px 0;color:var(--muted);font-size:11px}.insight-line b{color:var(--text);font-weight:500}.insight .progress{margin-top:5px}.section-space{margin-top:24px}.discovery-hero{padding:27px;background:var(--quote);border-radius:10px;margin-bottom:24px}.discovery-hero h2{font-family:'Songti SC',serif;font-size:22px;font-weight:500;margin:0 0 8px}.status-box{text-align:center;padding:65px 20px}.status-box h3{margin:12px}.status-box button{margin-top:18px}.type-chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.type-chips button{padding:8px 11px;font-size:11px;border-radius:7px;border:1px solid var(--line)}.type-chips button.active{background:var(--soft);color:var(--accent);border-color:transparent}.review-row{border-bottom:1px solid var(--line);padding:18px 0}.review-row p{font-size:13px;color:var(--text);margin-top:12px}.review-row .meta{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted)}.detail-subtitle{margin:15px 0}.book-details-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px}.book-details-grid span{color:var(--muted)}.chapter-row{padding:14px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;font-size:12px}.media-card{display:flex;gap:18px;align-items:center;padding:24px}.media-icon{width:65px;height:65px;flex-shrink:0;background:var(--soft);color:var(--accent);border-radius:14px;display:grid;place-items:center;font-size:27px}.hour-bars{height:58px;display:flex;align-items:end;gap:3px}.hour-bars i{height:var(--height);flex:1;background:#84a4d8;border-radius:2px 2px 0 0}.insight-detail{margin-top:20px}.insight-detail summary{cursor:pointer;font-size:12px;color:var(--accent);padding:15px 0}.source-tag{font-size:10px;color:var(--muted);margin:8px 0}.goal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:22px}.detail-empty{padding:35px;color:var(--muted);font-size:12px;text-align:center}.cover .title{flex-shrink:0}.cover .tiny{font-size:5px}.c1 .title,.c3 .title{font-size:17px}.cover .author{font-size:6px}.cover .publisher{font-size:5px}.trend-total small{display:inline-block;font-size:11px}.trend{margin-bottom:0}.shelf{margin-top:22px}.list-index{color:var(--muted);font-size:10px;margin-right:5px}.summary-goals{margin-bottom:0} +@media(max-width:1200px){.full-books{grid-template-columns:repeat(4,minmax(0,1fr))}.list-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.goals-grid{grid-template-columns:1fr}.columns>.rightcol{display:flex}.notebook-grid{grid-template-columns:1fr}.notebook-menu{display:flex;overflow:auto}.notebook-menu button{min-width:160px}.full-books{grid-template-columns:repeat(3,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sidebar .nav{gap:9px}.welcome{align-items:flex-start;gap:15px}.welcome h1{line-height:1.4}.welcome .primary{white-space:nowrap}.goal-intro{padding:22px}.demo-banner span:last-child{display:none}}@media(max-width:600px){.full-books{grid-template-columns:repeat(2,minmax(0,1fr))}.list-grid,.insight-grid{grid-template-columns:1fr}.goal-intro{grid-template-columns:1fr}.goal-intro .symbol{display:none}.goal-card{padding:20px}.welcome{flex-direction:column}.welcome .page-actions{align-self:flex-end}.panel{padding:18px}.date-fields{grid-template-columns:1fr;gap:0}.detail-body>.cover{width:95px;flex-shrink:0}.detail-body{gap:15px}.detail-info h3{font-size:18px}.book-details-grid{grid-template-columns:1fr}.toolbar input{width:100%}.metrics-row{gap:16px;flex-wrap:wrap}.metrics-row strong{font-size:23px}.goal-intro h2{font-size:20px}.cover-stack .mini-cover{width:57px}.stack-more{font-size:10px;margin-left:10px}.demo-banner{font-size:9px}.goal-card h3{font-size:17px}dialog{padding:20px}.editor-footer{gap:10px}.book-row{gap:9px}.book-row-text b{font-size:11px}} +.compact-value{font-family:Georgia,'PingFang SC',sans-serif;font-size:25px;line-height:1.5;letter-spacing:-.5px}.stat-foot{line-height:1.7}.insight-detail .insight-grid{margin-top:0}.sidebar .nav button[aria-current=page]{color:var(--accent)} +@media(max-width:600px){.compact-value{font-size:20px}.list-art .mini-cover{width:56px}.full-books .cover{width:105px;height:159px}.goal-card .section-head{gap:10px}.book-row .mini-cover{display:none}.detail-tabs{gap:16px}.date-fields input{min-height:40px}.detail-info .primary{font-size:11px;padding:9px}} +.mini-cover.c2,.mini-cover.c3{color:#fff5df} +.export-preview{width:100%;height:320px;margin-top:20px;border:1px solid var(--line);border-radius:8px;padding:18px;background:var(--bg);color:var(--text);font:12px/1.9 'PingFang SC',sans-serif;resize:vertical} diff --git a/static/js/app.js b/static/js/app.js index daf6e68..9a27f00 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,1267 +1,664 @@ -/* ===== 微信读书数据展示 - 前端逻辑 ===== */ +/* ========================================================================== + 阅间 · 微信读书看板 v2 —— 交互逻辑 + -------------------------------------------------------------------------- + 视觉结构、类名、交互控件完全沿用「阅间」设计稿(web-demo)。 + 差异只在于数据来源:设计稿读取 demo-data.js 的静态示例, + 这里改为调用 /api/v2/* 实时接口。 + ========================================================================== */ -const API_BASE = ""; +const $ = selector => document.querySelector(selector); +const esc = value => + String(value == null ? '' : value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); +const findBook = id => books.find(book => book.id === id); +const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id)); +const today = () => new Date().toLocaleDateString('sv-SE'); +const storageKey = 'weread-goals-v2'; +const PICKER_LIMIT = 60; +const SHELF_PAGE = 48; -// ===== 主题管理 ===== +/* ------------------------------- 运行时状态 ------------------------------ */ -function getCurrentTheme() { - return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; +let page = 'overview', shelfTab = 'all', discoverTab = 'books', searchQuery = '', period = 'week'; +let noteBook = 'all', noteType = 'all', goalFilter = 'all'; +let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0; +let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30; +let editingGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = []; +let toastTimer, storageWarning = ''; + +/* 接口数据缓存 */ +let books = []; +let booklists = []; +let shelfData = null; +let notebooksData = null; +let statusData = null; +let dashboardData = {}; +let notesData = {}; +let bookData = {}; +let discoverData = {}; +let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存 */ + +/* 加载状态:idle | loading | ready | error */ +const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} }; +const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} }; + +let goals = []; +try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed = JSON.parse(saved); + if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid'); + goals = parsed; + } +} catch { storageWarning = '本地目标暂时无法读取,已重置为空白;原存储未修改。'; } + +/* ------------------------------- 通用组件 ------------------------------- */ + +function toast(message) { + clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden'); + toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500); +} +function persistGoals(next) { + try { localStorage.setItem(storageKey, JSON.stringify(next)); } + catch { $('#goal-error').textContent = '浏览器无法保存数据,请允许本地存储后重试。'; return false; } + goals = next; return true; +} +function statusBox(kind, title, text, action) { + const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO'; + const button = action ? `` : ''; + return `
${eyebrow}

${esc(title)}

${esc(text || '')}

${button}
`; +} +function setTheme(theme) { + document.documentElement.dataset.theme = theme; + $('#theme').innerHTML = ``; + $('#theme').setAttribute('aria-label', `切换至${theme === 'dark' ? '亮' : '暗'}色主题`); +} +try { setTheme(localStorage.getItem('weread-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); } +$('#theme').onclick = () => { + const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme); + try { localStorage.setItem('weread-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); } +}; + +/* 封面:沿用设计稿的示意封面(4 种底色 + 书名短行) */ +function cover(book, mini = false) { + const short = book.short || book.title || ''; + if (mini) return ``; + return `
${esc(book.en || '')}${esc(short).replaceAll('\n', '
')}
${esc(book.author)}READING SPACE · 藏书
`; +} +function scoreText(book) { + return book.score ? `评分 ${Math.round(Number(book.score) * 10)}/100` : '暂无评分'; +} +function stateText(book) { + if (book.status === 'done') return '已读完'; + if (book.status === 'wish') return '想读'; + return book.progress > 0 ? `已读 ${book.progress}%` : '在读'; +} +function bookCard(book) { + return ``; +} +function bookRow(book, source = '') { + return `
${cover(book, true)}
${esc(book.title)}

${esc(book.author)}${source ? ' · ' + esc(source) : ''}

${esc(stateText(book))}
`; +} +function welcome(title, subtitle, action = '') { + return `
A LITTLE READING, EVERY DAY

${esc(title)}

${esc(subtitle)}

${action}
`; +} +const pageNames = { overview: '阅读概览', shelf: '我的书架', goals: '阅读目标', notes: '笔记与划线', discover: '发现好书' }; +function navigate(next) { + page = next; $('#crumb').textContent = pageNames[page]; + document.querySelectorAll('[data-page]').forEach(button => { button.classList.toggle('active', button.dataset.page === page); button.setAttribute('aria-current', button.dataset.page === page ? 'page' : 'false'); }); + renderPage(); +} +function renderPage() { + const views = { overview: renderOverview, shelf: renderShelf, goals: renderGoals, notes: renderNotes, discover: renderDiscover }; + $('#page-content').innerHTML = views[page](); +} +function tabs(items, selected, action) { + return `
${items.map(([id, label]) => ``).join('')}
`; } -function toggleTheme() { - const newTheme = getCurrentTheme() === "dark" ? "light" : "dark"; - document.documentElement.setAttribute("data-theme", newTheme); - localStorage.setItem("theme", newTheme); - refreshChartsForTheme(); +/* ------------------------------- 数据加载 ------------------------------- */ + +async function ensureStatus() { + try { + statusData = await WeApi.status(); + const counts = statusData.counts || {}; + const shelfNav = document.querySelector('[data-page="shelf"] em'); + if (shelfNav && counts.ebooks) shelfNav.textContent = counts.ebooks; + const conn = $('#conn-state'); + if (conn) conn.textContent = statusData.ok ? '已连接微信读书 · 数据实时同步' : '微信读书接口暂不可用'; + const foot = $('#footer-state'); + if (foot) foot.textContent = statusData.fetch_time ? `数据快照 ${statusData.fetch_time}` : '数据来自微信读书个人账号'; + } catch (error) { + const conn = $('#conn-state'); + if (conn) conn.textContent = '接口连接失败:' + error.message; + } } -function getChartColors() { - const cs = getComputedStyle(document.documentElement); - return { - axis: cs.getPropertyValue("--chart-axis").trim(), - axisLine: cs.getPropertyValue("--chart-axis-line").trim(), - splitLine: cs.getPropertyValue("--chart-split-line").trim(), - label: cs.getPropertyValue("--chart-label").trim(), - borderColor: cs.getPropertyValue("--chart-border-color").trim(), - tooltipBg: cs.getPropertyValue("--chart-tooltip-bg").trim(), - tooltipBorder: cs.getPropertyValue("--chart-tooltip-border").trim(), - tooltipText: cs.getPropertyValue("--chart-tooltip-text").trim(), - accent: cs.getPropertyValue("--accent").trim(), - accentLight: cs.getPropertyValue("--accent-light").trim(), - accentDark: cs.getPropertyValue("--accent-dark").trim(), - }; +async function ensureShelf(force = false) { + if (loadState.shelf === 'loading') return; + if (shelfData && !force) return; + loadState.shelf = 'loading'; loadError.shelf = ''; + if (['shelf', 'overview', 'goals'].includes(page)) renderPage(); + try { + const data = await WeApi.shelf('all', { progress: true }); + shelfData = data; + books = data.books || []; + booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` })); + loadState.shelf = 'ready'; + } catch (error) { + loadError.shelf = error.message; loadState.shelf = 'error'; + } + if (['shelf', 'overview', 'goals'].includes(page)) renderPage(); } -// ===== 工具函数 ===== - -/** - * 升级微信读书封面图 URL 到高清晰度版本。 - * 微信读书 CDN 的封面图通过文件名前缀区分分辨率: - * t6_ = 低清缩略图(~150px,Retina 屏模糊) - * s_ = 超低清缩略图(~90px,搜索结果常用,更模糊) - * t8_ = 高清版(~500px,适合 2x/3x 屏幕) - * 将 t6_ 和 s_ 前缀统一升级为 t8_ 高清版。 - */ -function hdCover(url) { - if (!url) return ""; - return url.replace(/\/(t6_|s_)/, "/t8_"); +async function ensureDashboard(target = period, force = false) { + if (loadState.dashboard[target] === 'loading') return; + if (dashboardData[target] && !force) return; + loadState.dashboard[target] = 'loading'; loadError.dashboard[target] = ''; + if (page === 'overview' && period === target) renderPage(); + try { + dashboardData[target] = await WeApi.dashboard(target); + loadState.dashboard[target] = 'ready'; + } catch (error) { + loadError.dashboard[target] = error.message; loadState.dashboard[target] = 'error'; + } + if (page === 'overview' && period === target) renderPage(); } -async function fetchAPI(path) { - const resp = await fetch(API_BASE + path); - if (!resp.ok) throw new Error(`API error: ${resp.status}`); - return resp.json(); +async function ensureNotebooks(force = false) { + if (loadState.notes.__list === 'loading') return; + if (notebooksData && !force) return; + loadState.notes.__list = 'loading'; + try { notebooksData = await WeApi.notebooks(); loadState.notes.__list = 'ready'; } + catch (error) { loadError.notes.__list = error.message; loadState.notes.__list = 'error'; } + if (page === 'notes') renderPage(); } -function formatHours(seconds) { - if (!seconds) return "0"; - return (seconds / 3600).toFixed(1); +async function ensureNotes(bookId, force = false) { + if (loadState.notes[bookId] === 'loading') return; + if (notesData[bookId] && !force) return; + loadState.notes[bookId] = 'loading'; loadError.notes[bookId] = ''; + if (page === 'notes' && noteBook === bookId) renderPage(); + try { + notesData[bookId] = await WeApi.notes(bookId, 4); + loadState.notes[bookId] = 'ready'; + } catch (error) { loadError.notes[bookId] = error.message; loadState.notes[bookId] = 'error'; } + if (page === 'notes' && noteBook === bookId) renderPage(); } -function escapeHtml(text) { - if (!text) return ""; - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; +async function ensureBook(bookId, force = false) { + if (!bookId) return; + if (loadState.book[bookId] === 'loading') return; + if (bookData[bookId] && !force) return; + loadState.book[bookId] = 'loading'; loadError.book[bookId] = ''; + renderBookDetail(); + try { bookData[bookId] = await WeApi.book(bookId); loadState.book[bookId] = 'ready'; } + catch (error) { loadError.book[bookId] = error.message; loadState.book[bookId] = 'error'; } + renderBookDetail(); } -// ===== 数据库健康检查 ===== - -const chartInstances = {}; -let chartsLoaded = false; -let currentThoughtsData = null; -let currentThoughtsCategory = "all"; -let currentThoughtsSort = "time_desc"; -let currentThoughtsSearch = ""; - -function refreshChartsForTheme() { - if (!chartsLoaded) return; - Object.values(chartInstances).forEach((chart) => { - if (chart && !chart.isDisposed()) chart.dispose(); - }); - loadYearlyTrend(); - loadTimeDistribution(); - loadCategories(); - loadAuthors(); +function discoverKey() { + if (discoverTab === 'recommend') return 'recommend'; + if (discoverTab === 'lists') return `${searchScope}:lists`; + return `${searchScope}:${discoverTab}:${searchQuery}`; } - -async function checkDBHealth() { - try { - const resp = await fetch(API_BASE + "/api/health"); - const data = await resp.json(); - if (resp.ok && data.database === "connected") { - document.getElementById("dbAlertBanner").style.display = "none"; - return true; - } else { - showDBAlert(data.message || "数据库连接失败"); - return false; - } - } catch (error) { - showDBAlert("无法连接到服务器,请检查后端服务是否运行。"); - return false; - } -} - -function showDBAlert(message) { - const banner = document.getElementById("dbAlertBanner"); - const detail = document.getElementById("dbAlertDetail"); - if (banner) banner.style.display = "flex"; - if (detail) detail.textContent = message; -} - -// ===== 标签页切换 ===== - -function switchTab(tabName) { - // 如果想法详情页正在显示,先关闭它回到主视图 - const thoughtsPage = document.getElementById("thoughtsPage"); - if (thoughtsPage && thoughtsPage.style.display !== "none") { - thoughtsPage.style.display = "none"; - document.getElementById("mainView").style.display = ""; - currentThoughtsData = null; - } - - // 关闭可能打开的弹窗 - closeModal("bookDetailModal"); - - document.querySelectorAll(".tab-panel").forEach((panel) => { - panel.classList.remove("active"); - }); - document.querySelectorAll(".nav-link").forEach((link) => { - link.classList.toggle("active", link.dataset.tab === tabName); - }); - - const target = document.getElementById(`tab-${tabName}`); - if (target) { - target.classList.add("active"); - } - - window.scrollTo({ top: 0, behavior: "smooth" }); - - // 懒加载图表 - if (tabName === "charts" && !chartsLoaded) { - chartsLoaded = true; - loadYearlyTrend(); - loadTimeDistribution(); - loadCategories(); - loadAuthors(); - } - - // 切换到图表时重绘尺寸 - if (tabName === "charts" && chartsLoaded) { - setTimeout(() => { - Object.values(chartInstances).forEach((chart) => { - if (chart && !chart.isDisposed()) chart.resize(); - }); - }, 100); - } -} - -// ===== 回到顶部按钮 ===== - -function initBackToTop() { - const btn = document.getElementById("backToTop"); - window.addEventListener("scroll", () => { - btn.classList.toggle("visible", window.scrollY > 400); - }); -} - -function scrollToTop() { - window.scrollTo({ top: 0, behavior: "smooth" }); -} - -/** - * 在微信读书中打开书籍。 - * 优先使用 API 返回的 deep_link(https://weread.qq.com/... 网页链接), - * 没有则用 book_id 构造网页链接。先尝试 weread:// 深链接唤起 App, - * 1.5 秒后无响应则回退到网页版。 - */ -function openInWeRead(bookId, deepLink) { - const webUrl = deepLink || `https://weread.qq.com/#book/${bookId}`; - const schemeUrl = `weread://reading?bId=${bookId}`; - - // 尝试唤起 App(移动端),同时设置定时器回退到网页版 - let triggered = false; - const fallbackTimer = setTimeout(() => { - if (!triggered) window.open(webUrl, "_blank"); - }, 1500); - - // 监听页面可见性变化(App 唤起成功会切到后台) - const onVisChange = () => { - if (document.hidden) { - triggered = true; - clearTimeout(fallbackTimer); - } - }; - document.addEventListener("visibilitychange", onVisChange); - - // 尝试打开深链接 - const iframe = document.createElement("iframe"); - iframe.style.display = "none"; - iframe.src = schemeUrl; - document.body.appendChild(iframe); - setTimeout(() => document.body.removeChild(iframe), 2000); - - // 桌面端不会有 visibilitychange,确保 fallback 一定执行 - setTimeout(() => { - document.removeEventListener("visibilitychange", onVisChange); - }, 3000); -} - -// ===== 全局搜索(顶部常驻搜索框) ===== - -function globalSearch() { - const keyword = document.getElementById("globalSearchInput").value.trim(); - if (!keyword) return; - - // 切换到推荐标签页 - switchTab("discover"); - // 切换到搜索子标签 - switchDiscoverTab("search"); - // 填入搜索词并执行搜索 - document.getElementById("searchInput").value = keyword; - doSearch(); -} - -// ===== 书架分类标签(可折叠层级结构) ===== - -let shelfCategoryTree = {}; // { "历史": {count:114, subs:{"世界史":5,...}}, ... } -let shelfActiveCategory = ""; // 完整分类名,如 "历史-中国古代" -let shelfActiveParent = ""; // 父分类名,如 "历史";空表示"全部" -let shelfExpandedParents = new Set(); // 当前展开的父分类 -let shelfSortMode = "category"; // 始终按分类排序,前端不显示切换按钮 - -async function loadShelfCategories() { - try { - const data = await fetchAPI("/api/shelf?page=1&per_page=1"); - shelfCategoryTree = data.category_tree || {}; - renderShelfCategories(); - } catch (e) { - console.error("加载分类失败:", e); - } -} - -function renderShelfCategories() { - const container = document.getElementById("shelfCategories"); - if (!container) return; - - let html = ""; - - // "全部" 按钮 - const allActive = !shelfActiveParent && !shelfActiveCategory; - html += ``; - - // 父分类按钮(按书籍数量降序) - const parents = Object.entries(shelfCategoryTree) - .sort((a, b) => b[1].count - a[1].count); - - for (const [parent, info] of parents) { - const isActive = shelfActiveParent === parent && !shelfActiveCategory; - const isExpanded = shelfExpandedParents.has(parent); - const arrow = Object.keys(info.subs).length > 0 - ? `${isExpanded ? "▼" : "▶"}` - : ""; - - html += ``; - - // 展开的子分类 - if (isExpanded && Object.keys(info.subs).length > 0) { - html += `
`; - for (const [sub, count] of Object.entries(info.subs).sort((a, b) => b[1] - a[1])) { - const fullCat = `${parent}-${sub}`; - const subActive = shelfActiveCategory === fullCat; - html += ``; - } - html += `
`; - } - } - - container.innerHTML = html; -} - -function filterByParent(parent) { - if (parent) { - // 点击父分类:展开/折叠子分类,同时筛选该父分类下的所有书 - if (shelfExpandedParents.has(parent) && shelfActiveParent === parent && !shelfActiveCategory) { - // 已展开且已选中 → 折叠 - shelfExpandedParents.delete(parent); - shelfActiveParent = ""; - shelfActiveCategory = ""; - } else { - // 展开 + 筛选 - shelfExpandedParents.add(parent); - shelfActiveParent = parent; - shelfActiveCategory = ""; - } +async function ensureDiscover(force = false) { + const key = discoverKey(); + if (loadState.discover[key] === 'loading') return; + if (discoverData[key] && !force) return; + loadState.discover[key] = 'loading'; loadError.discover[key] = ''; + if (page === 'discover') renderPage(); + try { + if (searchScope === 'shelf' || (!searchQuery && (discoverTab === 'books' || discoverTab === 'all'))) { + discoverData[key] = { local: true, books: books, authors: [], lists: [] }; + } else if (discoverTab === 'recommend') { + discoverData[key] = await WeApi.recommend(0); } else { - // 点击"全部" - shelfExpandedParents.clear(); - shelfActiveParent = ""; - shelfActiveCategory = ""; + discoverData[key] = await WeApi.search(searchQuery || '文学', discoverTab); } - renderShelfCategories(); - loadShelf(1); + loadState.discover[key] = 'ready'; + } catch (error) { loadError.discover[key] = error.message; loadState.discover[key] = 'error'; } + if (page === 'discover') renderPage(); } -function filterByCategory(fullCategory) { - shelfActiveCategory = fullCategory; - // 确保父分类已展开 - const parent = fullCategory.split("-")[0]; - shelfExpandedParents.add(parent); - shelfActiveParent = parent; - renderShelfCategories(); - loadShelf(1); +/* ------------------------------- 阅读概览 ------------------------------- */ + +function periodHint(p) { + const now = new Date(); + if (p === 'week') return '本周 · 周一至今'; + if (p === 'month') return `${now.getFullYear()} 年 ${now.getMonth() + 1} 月`; + if (p === 'year') return `${now.getFullYear()} 年 · 按月`; + return '全部年份 · 按年'; } -// ===== 书架刷新 ===== +function renderTrend(data) { + const values = data.trend.values || []; + const labels = data.trend.labels || []; + const unit = data.trend.unit; + const max = Math.max(10, Math.ceil(Math.max(...values, 1) / 10) * 10); + const gridlines = [max, Math.round((max * 2) / 3), Math.round(max / 3), 0] + .map(n => ``).join(''); + const bars = values.map((v, i) => ``).join(''); + const axis = period === 'year' ? '按月' : period === 'overall' ? '按年' : '按日'; + return `

阅读的日常 每一天,都算数

${axis}
${esc(data.stats[0].value)}阅读与收听合计
${gridlines}
${bars || '该周期暂无阅读记录'}
`; +} -async function refreshShelf() { - const btn = document.getElementById("shelfRefreshBtn"); - const textEl = btn.querySelector(".btn-text"); - btn.disabled = true; - btn.classList.add("loading"); - textEl.textContent = "同步中..."; +function summaryGoals() { + return `

我的阅读目标

${goals.slice(0, 3).map(goal => { const m = goalMetrics(goal); return ``; }).join('') || '

还没有阅读目标,创建一份属于你的清单。

'}
`; +} - try { - // Step 1: 从微信读书 API 同步数据到 MySQL - const resp = await fetch(API_BASE + "/api/sync", { method: "POST" }); - const syncResult = await resp.json(); - if (!syncResult.ok) { - throw new Error(syncResult.error || "同步失败"); - } +function renderOverview() { + const data = dashboardData[period]; + if (!data && loadError.dashboard[period]) return statusBox('error', '暂时无法获取阅读统计', loadError.dashboard[period], 'retry-overview'); + if (!data) { ensureDashboard(period); return statusBox('loading', '正在读取阅读统计…', '数据来自你的微信读书账号。'); } - // Step 2: 重新加载分类和书架 - textEl.textContent = "加载中..."; - await loadShelfCategories(); - await loadShelf(1); - } catch (e) { - console.error("刷新失败:", e); - alert("刷新失败: " + (e.message || "未知错误")); - } finally { - btn.disabled = false; - btn.classList.remove("loading"); - textEl.textContent = "刷新"; + const reading = books.filter(book => book.status === 'reading').slice(0, 6); + const stats = data.stats.map(s => `
${esc(s.label)}
${s.value}
${esc(s.hint)}
`).join(''); + + const topBooks = data.top_books.length + ? data.top_books.map((b, i) => `
${i + 1}. ${esc(b.title)}${esc(b.read_time_str)}
`).join('') + : '

该周期还没有阅读时长记录。

'; + + const cats = data.categories.items.length + ? data.categories.items.map(c => `
${esc(c.title)}相对偏好 ${c.val}
`).join('') + : '

接口未返回该周期的偏好分类。

'; + + const hours = data.time_distribution.values || []; + const hourMax = data.time_distribution.max || 1; + const hourBars = hours.length + ? hours.map(v => ``).join('') + : '接口未返回时段分布'; + const peak = data.time_distribution.peak_hour; + + const authorLines = data.authors.length + ? data.authors.map(a => `
${esc(a.name)}${a.count} 本${a.read_time ? ' · ' + esc(a.read_time) : ''}
`).join('') + : '

接口未返回该周期的偏好作者。

'; + const publisherLines = data.publishers.length + ? data.publishers.map(p => `
${esc(p.name)}${p.count} 本
`).join('') + : '

接口未返回偏好出版社。

'; + const cpLines = data.cps.length + ? data.cps.map(c => `
${esc(c.name)}${c.count} 本
`).join('') + : ''; + const medalLines = data.medals.length + ? data.medals.map(m => `
${esc(m.name)}${esc(m.detail || m.date || '')}
`).join('') + : '
勋章接口未返回
'; + + return welcome('在字里行间,遇见自己', '阅读、目标与灵感,在这里慢慢积累。', '') + + `
${tabs([['week', '本周'], ['month', '本月'], ['year', '本年'], ['overall', '总计']], period, 'period')}${esc(periodHint(period))} · 微信读书统计接口
` + + `
${stats}
` + + `
${renderTrend(data)}

继续阅读

${reading.length ? reading.map(bookCard).join('') : '
书架里还没有在读的书。
'}
` + + `

读得最多

${topBooks}${data.top_books.length ? `

按${esc(periodHint(period))}的阅读时长排序

` : ''}

偏爱的阅读方向

${cats}

相对偏好权重,不是阅读占比

留给自己的时段

${hourBars}
06:0018:00次日 05:00

${esc(data.time_distribution.word || '时段分布')}${peak != null ? ` · 峰值 ${String(peak).padStart(2, '0')}:00` : ''}

` + + `
更多阅读画像 · 作者、出版社、勋章与年度回顾

偏好作者与出版方

${authorLines}${publisherLines}${cpLines}

阅读与收听

微信读书统计接口未返回「文字阅读 / 收听」时长拆分,接入该字段后可直接展示。

阅读总时长${esc(data.stats[0].value)}
有效阅读天数${esc(String(data.stats[1].value).replace(/<[^>]+>/g, ''))}

阅读足迹

好友排行${esc(data.rank.text || '本周暂无排行')}
${medalLines}
`; +} + +/* ------------------------------- 我的书架 ------------------------------- */ + +function listCard(list, index) { + const members = (list.ids || []).map(findBook).filter(Boolean); + return ``; +} +function renderLists(lists) { + return `
${lists.map(listCard).join('') || '
没有匹配的书单。
'}
`; +} + +function renderShelf() { + if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf'); + if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); } + + const counts = shelfData.counts || {}; + const filtered = books.filter(book => shelfTab === 'all' || book.status === shelfTab); + let content; + if (shelfTab === 'lists') content = renderLists(booklists); + else if (shelfTab === 'audio') { + const albums = shelfData.albums || []; + content = albums.length + ? albums.map(album => `
♫

${esc(album.title)}

${esc(album.author || '有声专辑')} · ${album.count} 集

${esc(album.intro || '内容完结不代表已听完;当前不纳入按本阅读目标。')}

`).join('') + : '
书架接口当前没有返回有声书 / 专辑条目。
'; + } else if (shelfTab === 'articles') { + content = `
▤

${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}

这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。

书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。

`; + } else { + const shown = filtered.slice(0, shelfLimit); + content = `
${shown.map(bookCard).join('') || '
这里还没有对应的书。
'}
${filtered.length > shown.length ? `` : ''}
`; + } + + const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`; + return welcome('我的书架', sub) + + tabs([['all', `全部电子书 ${counts.ebooks || 0}`], ['reading', `在读 ${counts.reading || 0}`], ['done', `已读完 ${counts.done || 0}`], ['wish', `想读 ${counts.wish || 0}`], ['lists', `我的书单 ${counts.archives || 0}`], ['audio', `有声书 ${counts.albums || 0}`], ['articles', '文章收藏']], shelfTab, 'shelf-tab') + content; +} + +function openList(id) { + const list = booklists.find(l => l.id === id); + if (!list) return; + const members = (list.ids || []).map(findBook).filter(Boolean); + showDetail('书单详情', `
A COLLECTION OF GOOD BOOKS

${esc(list.name)}

${esc(list.description || '')}

${esc(list.source || '我的书单')} · ${list.book_count || members.length} 本

${members.map(book => bookRow(book)).join('') || '
书单中的书籍当前不在书架上。
'}
`); +} + +/* ------------------------------- 阅读目标 ------------------------------- */ + +function goalMetrics(goal, date = today()) { + const ids = uniqueIds(goal.ids), done = ids.filter(id => findBook(id).status === 'done').length; + const status = ids.length && done === ids.length ? '已完成' : date < goal.start ? '未开始' : date > goal.end ? '已到期' : '进行中'; + return { total: ids.length, done, percent: ids.length ? Math.round((done / ids.length) * 100) : 0, status }; +} +function goalCard(goal) { + const result = goalMetrics(goal); + return `
${result.status}

${esc(goal.name)}

${esc(goal.start)} — ${esc(goal.end)}
${goal.ids.slice(0, 4).map(id => cover(findBook(id), true)).join('')}${result.total > 4 ? `+ ${result.total - 4} 本` : ''}
${result.done} / ${result.total} 本已读完${result.percent}%
`; +} +function renderGoals() { + if (!shelfData) ensureShelf(); + const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成')); + return welcome('把想读,变成读过', '几本书、一份书单,或一段想留给阅读的时间。', '') + + `
YOUR NEXT CHAPTER

目标不必宏大,开始就很好。

每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。

${goals.length}个目标${goals.filter(goal => goalMetrics(goal).status === '已完成').length}个已完成${uniqueIds(goals.flatMap(goal => goal.ids)).length}本不同的书
❧
` + + tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') + + `
${list.map(goalCard).join('') || '
这里还没有目标,创建一份属于你的阅读清单。
'}
`; +} +function openGoalDetail(id) { + const goal = goals.find(g => g.id === id), m = goalMetrics(goal); + showDetail('阅读目标', `
${m.status}${esc(goal.start)} — ${esc(goal.end)}

${esc(goal.name)}

${m.done} / ${m.total}本已读完${m.percent}%已完成

已读完直接计入完成,不受目标日期影响。

${goal.sources.length ? `

加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目

` : ''}
${goal.ids.map(id => bookRow(findBook(id))).join('')}
`); +} +function openGoalEditor(id = null, prefill = [], sources = []) { + $('#detail').close(); editingGoalId = id; + const goal = id ? goals.find(g => g.id === id) : null; + draftIds = new Set(goal ? goal.ids : prefill); draftSources = [...(goal ? goal.sources : sources)]; + $('#editor-title').textContent = goal ? '编辑阅读目标' : '新建阅读目标'; $('#goal-name').value = goal ? goal.name : ''; + $('#goal-start').value = goal ? goal.start : today(); $('#goal-end').value = goal ? goal.end : `${today().slice(0, 4)}-12-31`; + $('#goal-end').min = $('#goal-start').value; $('#goal-end').setCustomValidity(''); $('#goal-name').setCustomValidity(''); + $('#picker-search').value = ''; $('#goal-error').textContent = ''; renderPicker(); $('#goal-editor').showModal(); +} +function selectedGoalBooks() { return uniqueIds([...draftIds]); } +function renderPicker() { + const query = $('#picker-search').value.trim().toLowerCase(); + if (!shelfData) { $('#goal-picker').innerHTML = '

正在同步书架…

'; ensureShelf(); return; } + const matched = books.filter(book => `${book.title}${book.author}${book.category}`.toLowerCase().includes(query)); + const list = matched.slice(0, PICKER_LIMIT); + $('#selection-count').textContent = `已选 ${draftIds.size} 本 · ${[...draftIds].filter(id => findBook(id).status === 'done').length} 本已读完`; + $('#goal-picker').innerHTML = `

快捷加入书单当前书目

${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => ``).join('')}
${list.map(book => ``).join('') || '
没有匹配的书籍
'}
${matched.length > list.length ? `

共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。

` : ''}`; +} +function saveGoal(event) { + event.preventDefault(); + const name = $('#goal-name').value.trim(), start = $('#goal-start').value, end = $('#goal-end').value, ids = selectedGoalBooks(); + if (!name) { $('#goal-name').setCustomValidity('请输入目标名称'); $('#goal-name').reportValidity(); return; } + if (end < start) { $('#goal-error').textContent = '结束日期不能早于开始日期。'; return; } + if (!ids.length) { $('#goal-error').textContent = '请至少选择一本书,或加入一个书单。'; return; } + const goal = { id: editingGoalId || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())), name, start, end, ids, sources: [...new Set(draftSources)] }; + const next = editingGoalId ? goals.map(g => (g.id === editingGoalId ? goal : g)) : [goal, ...goals]; + if (!persistGoals(next)) return; + $('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存,已读完的书已计入完成。'); +} +function chooseTarget(ids, sources = []) { + showDetail('加入阅读目标', `

选择已有目标,或为这些书新建目标。重复书籍只加入一次。

${goals.map(g => `
${esc(g.name)}

${esc(g.start)} — ${esc(g.end)} · ${g.ids.length} 本

`).join('') || '
还没有目标,先新建一个。
'}
`); + pendingBooks = ids; pendingSources = sources; +} +function appendGoal(id) { + const next = goals.map(g => (g.id === id ? { ...g, ids: uniqueIds([...g.ids, ...pendingBooks]), sources: [...new Set([...g.sources, ...pendingSources])] } : g)); + if (!persistGoals(next)) { toast('无法保存到浏览器,请允许本地存储后重试。'); return; } + renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。'); +} + +/* ------------------------------- 笔记与划线 ------------------------------- */ + +function renderNotes() { + if (!notebooksData && loadState.notes.__list !== 'error') { ensureNotebooks(); return statusBox('loading', '正在读取笔记本…', '来自微信读书的笔记本概览。'); } + if (!notebooksData) return statusBox('error', '暂时无法读取笔记本', loadError.notes.__list || '', 'retry-notes'); + + if (!notesData[noteBook] && loadState.notes[noteBook] !== 'loading' && loadState.notes[noteBook] !== 'ready' && loadState.notes[noteBook] !== 'error') ensureNotes(noteBook); + const payload = notesData[noteBook]; + const items = notebooksData.items || []; + const menuBooks = items.slice(0, 10); + const counts = (payload && payload.counts) || { highlights: 0, thoughts: 0, bookmarks: 0 }; + const notes = (payload && payload.notes) || []; + + const shownNotes = filteredNotes(notes); + const noteSlice = shownNotes.slice(0, notesLimit); + + const body = !payload + ? (loadState.notes[noteBook] === 'error' + ? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes') + : statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。')) + : `
${counts.highlights + counts.thoughts + counts.bookmarks}条笔记划线 ${counts.highlights}想法 ${counts.thoughts}书签 ${counts.bookmarks}
` + + tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') + + (noteType === 'bookmark' + ? `
${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。
` + : (noteSlice.map(noteCard).join('') || '
当前筛选没有笔记。
')) + + (noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `` : '') + + `

${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。

`; + + return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '') + + `
${body}
`; +} +function filteredNotes(notes) { + if (noteType === 'all') return notes; + if (noteType === 'highlight') return notes.filter(n => n.type === 'highlight'); + if (noteType === 'thought') return notes.filter(n => n.type === 'thought'); + return []; +} +function noteCard(note) { + return `
${note.type === 'thought' ? '我的想法' : '划线'}
${esc(note.text)}
《${esc(note.book_title || '')}》 · ${esc(note.date || '')}

${esc(note.chapter || '未记录章节')}

${note.id ? `` : ''}
`; +} +function exportNotes() { + const payload = notesData[noteBook] || { notes: [] }; + const all = filteredNotes(payload.notes || []); + const notes = all.slice(0, 300); + const scopeName = noteBook === 'all' ? '全部笔记本(聚合)' : (notebooksData.items.find(item => item.id === noteBook) || {}).title || noteBook; + const header = `# 阅间笔记 · ${scopeName}\n\n导出时间:${today()} 来源:微信读书个人账号\n书签仅提供数量,不导出内容。${all.length > notes.length ? `\n本次导出前 ${notes.length} 条(共 ${all.length} 条),可切换单本笔记本分批导出。` : ''}\n\n`; + const text = header + notes.map(n => `## ${n.book_title || ''}\n\n${n.type === 'thought' ? '想法' : '划线'} · ${n.date || ''}${n.chapter ? ' · ' + n.chapter : ''}\n\n${n.text}`).join('\n\n'); + const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' })); + showDetail('导出笔记', `

已生成${esc(scopeName)}的划线与想法,共 ${notes.length} 条。可下载 Markdown,或直接选中下方内容复制;不含书签内容。

`); + $('#detail').addEventListener('close', () => URL.revokeObjectURL(url), { once: true }); +} + +/* ------------------------------- 发现好书 ------------------------------- */ + +function renderDiscover() { + const options = [['books', '书籍'], ['lists', '书单'], ['recommend', '为你推荐'], ['all', '综合'], ['author', '作者'], ['novel', '网文'], ['audio', '听书'], ['fulltext', '全文'], ['account', '公众号'], ['article', '文章']]; + if (!shelfData) ensureShelf(); + const key = discoverKey(); + const payload = discoverData[key]; + let content = ''; + if (loadError.discover[key]) content = statusBox('error', '暂时无法获取数据', loadError.discover[key], 'retry-discover'); + else if (!payload) { ensureDiscover(); content = statusBox('loading', '正在寻找好书…', searchScope === 'shelf' ? '在你的书架中检索。' : '实时检索微信读书书城。'); } + else if (discoverTab === 'recommend') { + const list = payload.books || []; + const prefer = (dashboardData[period] && dashboardData[period].prefer_words.category) || '你的阅读偏好'; + content = `
PICKED FOR YOUR NEXT CHAPTER

读过的书,会带你遇见下一本。

${esc(prefer)} · 微信读书实时推荐

${list.map(recommendCard).join('') || '
接口暂时没有返回推荐书籍。
'}
`; + } else if (discoverTab === 'lists') { + const lists = (payload.lists && payload.lists.length) ? payload.lists : booklists; + content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || '我的书单' }))) + (payload.lists && payload.lists.length ? '' : '

书城书单搜索接口暂未返回结果,以上为你的书架书单。

'); + } else if (discoverTab === 'author') { + const authors = payload.authors || []; + content = `
${authors.map(author => `
${esc(author.name.slice(0, 1))}
${esc(author.name)}

作者 · 书城检索

`).join('') || '
没有匹配的作者,换个关键词试试。
'}
`; + } else if (['novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) { + const list = payload.books || []; + const names = { novel: '网文', audio: '听书', fulltext: '全文', account: '公众号', article: '文章' }; + content = list.length + ? `
${list.map(searchCard).join('')}
` + : `
「${esc(names[discoverTab])}」搜索:接口当前没有返回条目,可切换其他类型或关键词。
`; + } else if (discoverTab === 'all') { + const list = payload.books || []; + content = `

书籍

${list.slice(0, 8).map(searchCard).join('') || '
没有匹配的书籍。
'}

书单

${renderLists(booklists.slice(0, 3))}`; + } else { + const list = payload.books || []; + const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase())); + content = `
${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '
没有找到相关书籍,试试其他书名或作者。
'}
`; + } + + return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') + + `
` + + tabs(options, discoverTab, 'discover-tab') + `
${content}

${(payload && payload.local) || searchScope === 'shelf' ? '书架检索' : '书城检索'} · 结果实时来自微信读书接口;推荐理由与书单成员以接口返回为准。

`; +} +function searchCard(book) { + const reason = book.reason ? `

${esc(book.reason)}

` : ''; + return ``; +} +function recommendCard(book) { + return ``; +} + +/* ------------------------------- 详情弹窗 ------------------------------- */ + +function showDetail(title, html) { $('#detail-title').textContent = title; $('#detail-content').innerHTML = html; if (!$('#detail').open) $('#detail').showModal(); } + +function openBook(id) { detailBook = id; bookTab = 'info'; selectedChapter = 0; reviewFilter = 0; chapterLimit = 30; renderBookDetail(); } +function renderBookDetail() { + const id = detailBook; + if (!id) return; + if (loadError.book[id]) { showDetail('书籍详情', statusBox('error', '暂时无法读取书籍详情', loadError.book[id], 'retry-book')); return; } + const data = bookData[id]; + if (!data) { ensureBook(id); showDetail('书籍详情', statusBox('loading', '正在读取书籍详情…', '简介、目录、笔记、热门划线与点评。')); return; } + const info = data.book; + const progress = data.progress.progress; + const state = progress >= 100 ? '已读完' : progress > 0 ? `已读 ${progress}%` : '尚未开始阅读'; + const readTimeText = data.progress.record_reading_time ? ` · 累计 ${esc(data.progress.record_reading_time_str)}` : ''; + showDetail('书籍详情', `
${cover(info)}
READING SPACE / BOOK

${esc(info.title)}

${esc(info.author)}${info.translator ? ' · 译者 ' + esc(info.translator) : ''}

${info.rating ? `评分 ${Math.round(info.rating)}/100 · ${info.rating_count} 人评价` : '暂无评分'}

${state}${readTimeText}

${info.deep_link ? ` 在微信读书中打开 ↗` : ''}
${[['info', '简介'], ['chapters', '目录'], ['notes', '我的笔记'], ['popular', '热门划线'], ['reviews', '读者点评'], ['similar', '相似推荐']].map(([tab, label]) => ``).join('')}
${renderBookTab(data)}
`); +} +function renderBookTab(data) { + const info = data.book; + if (bookTab === 'info') return `

${esc(info.intro || '接口未返回简介。')}

分类:${esc(info.category || '未分类')}
最近阅读:${esc(data.progress.update_time || '暂无记录')}
出版社:${esc(info.publisher || '未提供')}
出版日期:${esc(info.publish_time || '未提供')}
字数:${info.word_count ? `${Math.round(info.word_count / 10000)} 万字` : '未提供'}
ISBN:${esc(info.isbn || '未提供')}
`; + if (bookTab === 'chapters') { + const chapters = data.chapters.slice(0, chapterLimit); + return `

共 ${data.chapter_total} 章,当前显示 ${chapters.length} 章;点击可查看该章热门划线。

${chapters.map(chapter => `
${String(chapter.idx).padStart(2, '0')}   ${esc(chapter.title)}${chapter.words ? ` · ${Math.max(1, Math.round(chapter.words / 1000))} 千字` : ''}
`).join('') || '
接口未返回目录。
'}${data.chapters.length > chapters.length ? `` : ''}`; + } + if (bookTab === 'notes') { + const notes = data.notes.items || []; + const counts = data.notes.counts; + return `
${counts.highlights}条划线${counts.thoughts}条想法${counts.bookmarks}个书签
${notes.map(note => `
${note.type === 'thought' ? '个人想法' : '划线'}
${esc(note.text)}
${esc(note.date || '')}${note.chapter ? ' · ' + esc(note.chapter) : ''}${note.url ? `` : ''}
`).join('') || '
这本书暂无个人笔记。
'}`; + } + if (bookTab === 'popular') { + const marks = numberData[`hl:${info.id}:${selectedChapter}`] || data.best_bookmarks; + const items = (marks.items || []).slice(0, 20); + const chapterOptions = [['0', '全部章节']].concat(data.chapters.slice(0, 40).map(chapter => [String(chapter.uid), chapter.title])); + return `
${selectedChapter ? esc((data.chapters.find(chapter => String(chapter.uid) === String(selectedChapter)) || {}).title || '所选章节') : '全书'} · 热门划线最多展示 20 条
${items.map(item => `
${item.count} 人划线
${esc(item.text)}
${esc(item.chapter || '')}${marks.total ? ` · 全书共 ${marks.total} 条热门划线` : ''}
`).join('') || '
接口未返回热门划线。
'}

章节热度提供人数与位置;原文来自热门划线接口。

`; + } + if (bookTab === 'reviews') { + const payload = numberData[`rv:${info.id}:${reviewFilter}`] || { items: data.reviews.items, count: data.reviews.count }; + const list = payload.items || []; + return tabs([[0, '全部'], [1, '推荐'], [4, '一般'], [2, '不行'], [3, '最新']], reviewFilter, 'review-filter') + + `

共 ${payload.count || list.length} 条公开点评

` + + (list.map(review => `
${esc(review.name)}${review.is_finish ? ' · 已读完' : ''}${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}

${esc(review.text)}

${esc(review.date || '')}${review.chapter ? ' · ' + esc(review.chapter) : ''}
`).join('') || '
该筛选下接口没有返回点评。
'); + } + const similar = data.similar || []; + return `

相似推荐 · 实时来自微信读书「相似书籍」接口

${similar.map(bookCard).join('') || '
接口未返回相似推荐。
'}
`; +} +function openThought(text, subtitle) { showDetail('想法详情', `
内容
${esc(text)}
${esc(subtitle)}
`); } +async function openThoughtDetail(reviewId, bookId) { + showDetail('想法详情', statusBox('loading', '正在读取想法详情…', '正文、点赞与评论。')); + try { + const data = await WeApi.thought(reviewId); + showDetail('想法详情', `
我的想法
${esc(data.text)}
${data.abstract ? `
划线原文:${esc(data.abstract)}
` : ''}${esc(data.date || '')}${data.chapter ? ' · ' + esc(data.chapter) : ''}
${data.like_count}个赞${data.comment_count}条评论
${(data.comments || []).map(comment => `
${esc(comment.name)}${esc(comment.date || '')}

${esc(comment.text)}

`).join('') || '

这条想法还没有评论。

'}
${bookId ? `` : ''}
`); + } catch (error) { + showDetail('想法详情', statusBox('error', '暂时无法读取想法详情', error.message, '')); + } +} +async function openHighlightThoughts(bookId, chapterUid, range) { + showDetail('划线下的想法', statusBox('loading', '正在读取他人的想法…', '来自划线定位接口。')); + try { + const data = await WeApi.underlineThoughts(bookId, chapterUid, range); + const items = data.items || []; + showDetail('划线下的想法', `

该划线范围内取到 ${items.length} 条想法

` + (items.map(item => `
${esc(item.name)}${esc(item.date || '')}

${esc(item.text)}

`).join('') || '
这条划线下暂无他人想法。
')); + } catch (error) { + showDetail('划线下的想法', statusBox('error', '暂时无法读取划线下想法', error.message, '')); + } +} + +/* ------------------------------- 行为分发 ------------------------------- */ + +function handleAction(button) { + const action = button.dataset.action, id = button.dataset.id; + switch (action) { + case 'page': navigate(id); break; + case 'new-goal': ensureShelf(); openGoalEditor(); break; + case 'edit-goal': openGoalEditor(id); break; + case 'goal-detail': openGoalDetail(id); break; + case 'goal-filter': goalFilter = id; renderPage(); break; + case 'close-detail': $('#detail').close(); break; + case 'close-editor': $('#goal-editor').close(); break; + case 'pick-list': { + const list = booklists.find(l => l.id === id); + if (list) { list.ids.forEach(bookId => draftIds.add(bookId)); if (!draftSources.includes(list.name)) draftSources.push(list.name); } + renderPicker(); break; } + case 'book': openBook(id); break; + case 'list': openList(id); break; + case 'add-book': chooseTarget([id]); break; + case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; } + case 'append-goal': appendGoal(id); break; + case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break; + case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break; + case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break; + case 'period': period = id; renderPage(); break; + case 'discover-tab': discoverTab = id; renderPage(); break; + case 'retry-overview': ensureDashboard(period, true); break; + case 'retry-shelf': ensureShelf(true); break; + case 'retry-notes': if (!notebooksData) ensureNotebooks(true); else ensureNotes(noteBook, true); break; + case 'retry-discover': ensureDiscover(true); break; + case 'retry-book': ensureBook(detailBook, true); break; + case 'notebook': noteBook = id; noteType = 'all'; notesLimit = 30; renderPage(); break; + case 'note-type': noteType = id; notesLimit = 30; renderPage(); break; + case 'notes-more': notesLimit += 30; renderPage(); break; + case 'export-notes': exportNotes(); break; + case 'personal-note': { + const note = ((notesData[noteBook] || {}).notes || []).find(n => n.id === id); + if (!note) break; + if (note.type === 'thought') openThoughtDetail(note.id, note.bookId); + else openThought(note.text, `${note.book_title || ''} · ${note.chapter || ''} · ${note.date || ''}`); + break; + } + case 'book-tab': bookTab = id; renderBookDetail(); break; + case 'chapter': selectedChapter = id; bookTab = 'popular'; renderBookDetail(); if (bookData[detailBook] && !numberData[`hl:${detailBook}:${id}`]) WeApi.highlights(detailBook, id).then(res => { numberData[`hl:${detailBook}:${id}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); break; + case 'chapter-more': chapterLimit += 30; renderBookDetail(); break; + case 'review-filter': reviewFilter = Number(id); renderBookDetail(); WeApi.reviews(detailBook, reviewFilter, 10).then(res => { numberData[`rv:${detailBook}:${reviewFilter}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); break; + case 'review-detail': openThought(button.dataset.text, `${button.dataset.name} · 公开点评`); break; + case 'highlight-thoughts': openHighlightThoughts(button.dataset.id, button.dataset.chapter, button.dataset.range); break; + case 'author-books': searchQuery = button.dataset.name; discoverTab = 'books'; searchScope = 'store'; $('#global-search').value = searchQuery; navigate('discover'); break; + case 'article-search': searchQuery = ''; discoverTab = 'article'; navigate('discover'); break; + case 'year-report': { + const year = dashboardData[period] && dashboardData[period].year_summary; + if (!year || !year.duration_str) { toast('年度数据暂时不可用'); break; } + showDetail('年度回顾', `
${year.year} / A YEAR IN BOOKS

翻过的每一页,都算数。

${esc(year.duration_str)} · ${year.read_days} 个阅读日${year.read_count ? ' · 读过 ' + esc(year.read_count) : ''}${year.finish_count ? ' · 读完 ' + esc(year.finish_count) : ''}

${esc(year.prefer_category || '')}${year.prefer_time ? ',' + esc(year.prefer_time) : ''}。以上来自微信读书阅读统计接口。

${(year.top_books || []).map(book => `
${esc(book.title)}${esc(book.read_time_str)}
`).join('')}`); + break; + } + } } - -// ===== 初始化 ===== - -document.addEventListener("DOMContentLoaded", async () => { - const isHealthy = await checkDBHealth(); - if (!isHealthy) return; - - loadOverview(); - loadTopBooks(); - loadShelfCategories(); - loadShelf(1); - loadNotebooks(); - loadRecommend(0); - initBackToTop(); - - // 搜索和筛选事件 - document.getElementById("shelfSearch").addEventListener("input", debounce(() => loadShelf(1), 300)); - document.getElementById("finishedOnly").addEventListener("change", () => loadShelf(1)); +document.addEventListener('click', event => { + const action = event.target.closest('[data-action]'); + if (action) { handleAction(action); return; } + const nav = event.target.closest('[data-page]'); + if (nav) { searchQuery = ''; $('#global-search').value = ''; navigate(nav.dataset.page); } }); - -// ===== 总览数据 ===== -async function loadOverview() { - try { - const data = await fetchAPI("/api/overview"); - - const heroStats = document.getElementById("heroStats"); - heroStats.innerHTML = ` -
-
${formatHours(data.total_read_time)}
-
累计阅读 (小时)
-
-
-
${data.read_days}
-
阅读天数
-
-
-
${data.shelf_count}
-
书架藏书
-
-
-
${data.total_notes}
-
读书笔记
-
- `; - - document.getElementById("heroSubtitle").textContent = - `自 ${data.regist_time} 加入微信读书,已陪伴你 ${data.read_days} 个阅读的日子`; - - const statsGrid = document.getElementById("statsGrid"); - statsGrid.innerHTML = ` -
-
⏱️
-
${data.total_read_time_str}
-
累计阅读时长
-
今年: ${data.year_read_time_str}
-
-
-
📅
-
${data.read_days}
-
累计阅读天数
-
今年: ${data.year_read_days} 天
-
-
-
📚
-
${data.shelf_count}
-
书架书籍
-
已读完: ${data.finished_count} 本
-
-
-
✍️
-
${data.total_notes}
-
读书笔记
-
来自 ${data.notebook_count} 本书
-
- `; - - const timeStr = `数据更新: ${data.fetch_time}`; - document.getElementById("fetchTime").textContent = timeStr; - document.getElementById("footerTime").textContent = data.fetch_time; - } catch (e) { - console.error("加载总览失败:", e); - } -} - -// ===== 年度趋势图 ===== -async function loadYearlyTrend() { - try { - const data = await fetchAPI("/api/yearly-trend"); - const el = document.getElementById("chart-yearly"); - if (chartInstances.yearly) chartInstances.yearly.dispose(); - const chart = echarts.init(el); - chartInstances.yearly = chart; - - const c = getChartColors(); - const years = data.map((d) => d.year); - const hours = data.map((d) => d.hours); - - chart.setOption({ - tooltip: { - trigger: "axis", - backgroundColor: c.tooltipBg, - borderColor: c.tooltipBorder, - textStyle: { color: c.tooltipText }, - formatter: (params) => { - const p = params[0]; - return `${p.name}年
阅读: ${p.value} 小时`; - }, - }, - grid: { left: 50, right: 30, top: 30, bottom: 40 }, - xAxis: { - type: "category", - data: years, - axisLabel: { color: c.axis }, - axisLine: { lineStyle: { color: c.axisLine } }, - }, - yAxis: { - type: "value", - name: "小时", - nameTextStyle: { color: c.axis }, - axisLabel: { color: c.axis }, - splitLine: { lineStyle: { color: c.splitLine } }, - }, - series: [{ - data: hours, - type: "bar", - barWidth: "50%", - itemStyle: { - borderRadius: [8, 8, 0, 0], - color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, - colorStops: [ - { offset: 0, color: c.accentLight }, - { offset: 1, color: c.accent }, - ] }, - }, - label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" }, - }], - }); - window.addEventListener("resize", () => chart.resize()); - } catch (e) { - console.error("加载年度趋势失败:", e); - } -} - -// ===== 24小时时段分布 ===== -async function loadTimeDistribution() { - try { - const data = await fetchAPI("/api/time-distribution"); - const el = document.getElementById("chart-hours"); - if (chartInstances.hours) chartInstances.hours.dispose(); - const chart = echarts.init(el); - chartInstances.hours = chart; - - const c = getChartColors(); - const hours = data.map((d) => `${d.hour}时`); - const minutes = data.map((d) => d.duration_min); - - chart.setOption({ - tooltip: { - trigger: "axis", - backgroundColor: c.tooltipBg, - borderColor: c.tooltipBorder, - textStyle: { color: c.tooltipText }, - formatter: (params) => { - const p = params[0]; - return `${p.name}
阅读: ${p.value} 分钟`; - }, - }, - grid: { left: 50, right: 20, top: 20, bottom: 40 }, - xAxis: { - type: "category", - data: hours, - axisLabel: { color: c.axis, fontSize: 10, interval: 2 }, - axisLine: { lineStyle: { color: c.axisLine } }, - }, - yAxis: { - type: "value", - name: "分钟", - nameTextStyle: { color: c.axis }, - axisLabel: { color: c.axis }, - splitLine: { lineStyle: { color: c.splitLine } }, - }, - series: [{ - data: minutes, - type: "line", - smooth: true, - symbol: "circle", - symbolSize: 4, - lineStyle: { width: 2, color: c.accent }, - itemStyle: { color: c.accent }, - areaStyle: { - color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, - colorStops: [ - { offset: 0, color: c.accent + "4D" }, - { offset: 1, color: c.accent + "05" }, - ] }, - }, - }], - }); - window.addEventListener("resize", () => chart.resize()); - } catch (e) { - console.error("加载时段分布失败:", e); - } -} - -// ===== 偏好分类 ===== -async function loadCategories() { - try { - const data = await fetchAPI("/api/categories"); - const el = document.getElementById("chart-categories"); - if (chartInstances.categories) chartInstances.categories.dispose(); - const chart = echarts.init(el); - chartInstances.categories = chart; - - const c = getChartColors(); - const items = data.slice(0, 8).map((d) => ({ - name: d.category_title, - value: d.reading_time, - })); - - chart.setOption({ - tooltip: { - trigger: "item", - backgroundColor: c.tooltipBg, - borderColor: c.tooltipBorder, - textStyle: { color: c.tooltipText }, - formatter: (p) => { - const hours = (p.value / 3600).toFixed(1); - return `${p.name}
阅读: ${hours} 小时 (${p.percent}%)`; - }, - }, - legend: { - type: "scroll", - orient: "vertical", - right: 10, - top: 20, - bottom: 20, - textStyle: { color: c.label, fontSize: 11 }, - }, - series: [{ - type: "pie", - radius: ["40%", "70%"], - center: ["35%", "50%"], - avoidLabelOverlap: false, - itemStyle: { borderRadius: 6, borderColor: c.borderColor, borderWidth: 2 }, - label: { show: false }, - emphasis: { - label: { show: true, fontSize: 14, fontWeight: "bold", color: c.tooltipText }, - }, - data: items, - color: ["#c17b4a", "#e8a87c", "#d4a574", "#b8860b", "#cd853f", "#daa520", "#bc8f8f", "#bdb76b"], - }], - }); - window.addEventListener("resize", () => chart.resize()); - } catch (e) { - console.error("加载分类失败:", e); - } -} - -// ===== 偏好作者 ===== -async function loadAuthors() { - try { - const data = await fetchAPI("/api/authors"); - const el = document.getElementById("chart-authors"); - if (chartInstances.authors) chartInstances.authors.dispose(); - const chart = echarts.init(el); - chartInstances.authors = chart; - - const c = getChartColors(); - const top10 = data.slice(0, 10); - const names = top10.map((d) => d.name); - const counts = top10.map((d) => d.count); - - chart.setOption({ - tooltip: { - trigger: "axis", - axisPointer: { type: "shadow" }, - backgroundColor: c.tooltipBg, - borderColor: c.tooltipBorder, - textStyle: { color: c.tooltipText }, - formatter: (params) => { - const p = params[0]; - return `${p.name}
阅读: ${p.value} 本`; - }, - }, - grid: { left: 100, right: 40, top: 20, bottom: 20 }, - xAxis: { - type: "value", - axisLabel: { color: c.axis }, - splitLine: { lineStyle: { color: c.splitLine } }, - }, - yAxis: { - type: "category", - data: names.reverse(), - axisLabel: { color: c.axis, fontSize: 12 }, - axisLine: { lineStyle: { color: c.axisLine } }, - }, - series: [{ - type: "bar", - data: counts.reverse(), - barWidth: "50%", - itemStyle: { - borderRadius: [0, 6, 6, 0], - color: { type: "linear", x: 0, y: 0, x2: 1, y2: 0, - colorStops: [ - { offset: 0, color: c.accentLight }, - { offset: 1, color: c.accent }, - ] }, - }, - label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" }, - }], - }); - window.addEventListener("resize", () => chart.resize()); - } catch (e) { - console.error("加载作者失败:", e); - } -} - -// ===== 读书排行(竖向列表) ===== -async function loadTopBooks() { - try { - const data = await fetchAPI("/api/top-books"); - const list = document.getElementById("topBooksGrid"); - - list.innerHTML = data - .map((book, i) => { - const tagsHtml = book.tags - .map((t) => `${t}`) - .join(""); - return ` -
- ${i + 1} - ${book.title} -
-
${book.title}
-
- ${book.author} - ${tagsHtml ? `
${tagsHtml}
` : ""} -
-
-
⏱ ${book.read_time_str}
-
`; - }) - .join(""); - } catch (e) { - console.error("加载排行失败:", e); - } -} - -// ===== 书架 ===== -let shelfCurrentPage = 1; - -async function loadShelf(page) { - shelfCurrentPage = page; - const keyword = document.getElementById("shelfSearch").value.trim(); - const finished = document.getElementById("finishedOnly").checked ? "1" : "0"; - const params = new URLSearchParams({ page: page, per_page: 60, finished: finished }); - if (keyword) params.set("keyword", keyword); - if (shelfActiveCategory) { - params.set("category", shelfActiveCategory); - } else if (shelfActiveParent) { - params.set("parent_category", shelfActiveParent); - } - if (shelfSortMode === "category") params.set("sort", "category"); - - try { - const data = await fetchAPI(`/api/shelf?${params}`); - const grid = document.getElementById("bookGrid"); - - document.getElementById("shelfInfo").textContent = - `共 ${data.total} 本 · 第 ${page}/${data.total_pages} 页`; - - grid.innerHTML = data.books.map((book) => renderBookCard(book)).join(""); - - renderPagination(page, data.total_pages); - } catch (e) { - console.error("加载书架失败:", e); - } -} - -function renderBookCard(book) { - const badges = []; - if (book.is_top) badges.push(`置顶`); - if (book.finish_reading) badges.push(`已读完`); - return ` -
-
- ${book.title} - ${badges.join("")} -
-
${book.title}
-
${book.author}
-
`; -} - -function renderPagination(current, total) { - const pag = document.getElementById("pagination"); - if (total <= 1) { pag.innerHTML = ""; return; } - - let html = ``; - const maxButtons = 7; - let start = Math.max(1, current - 3); - let end = Math.min(total, start + maxButtons - 1); - start = Math.max(1, end - maxButtons + 1); - - if (start > 1) { - html += ``; - if (start > 2) html += `...`; - } - for (let i = start; i <= end; i++) { - html += ``; - } - if (end < total) { - if (end < total - 1) html += `...`; - html += ``; - } - html += ``; - pag.innerHTML = html; -} - -function debounce(fn, delay) { - let timer; - return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; -} - -// ===== 笔记概览 ===== -async function loadNotebooks() { - try { - const data = await fetchAPI("/api/notebooks"); - const summary = document.getElementById("notesSummary"); - - const totalHighlights = data.reduce((s, d) => s + d.note_count, 0); - const totalReviews = data.reduce((s, d) => s + d.review_count, 0); - const totalBookmarks = data.reduce((s, d) => s + d.bookmark_count, 0); - - summary.innerHTML = ` -
-
${data.length}
-
有笔记的书
-
-
-
${totalHighlights}
-
划线
-
-
-
${totalReviews}
-
想法/点评
-
-
-
${totalBookmarks}
-
书签
-
- `; - - const grid = document.getElementById("notebooksGrid"); - grid.innerHTML = data - .map((nb) => { - const progress = nb.reading_progress || 0; - return ` -
- ${nb.title} -
-
${nb.title}
-
${nb.author}
-
- 划线 ${nb.note_count} - 想法 ${nb.review_count} - 书签 ${nb.bookmark_count} -
-
-
-
-
-
`; - }) - .join(""); - } catch (e) { - console.error("加载笔记失败:", e); - } -} - -// ===== 书籍详情弹窗 ===== -function openBook(bookId) { - 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('')}
- ${p.is_start_reading ? ` -
-
- 阅读进度: ${p.progress}% - ${p.record_reading_time_str ? ' · 累计 ' + p.record_reading_time_str : ''} - ${p.finish_time ? ' · 读完于 ' + p.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 openThoughtsPage(bookId, title, author, cover) { - if (!bookId) return; - - // 隐藏主视图,显示想法页面 - document.getElementById("mainView").style.display = "none"; - document.getElementById("thoughtsPage").style.display = "block"; - window.scrollTo({ top: 0, behavior: "auto" }); - - // 渲染头部 - const header = document.getElementById("thoughtsBookHeader"); - header.innerHTML = ` - ${title} -
-
${title}
-
${author || ''}
-
加载中...
-
- `; - - const content = document.getElementById("thoughtsContent"); - content.innerHTML = `
`; - - fetchAPI(`/api/book/notes?book_id=${bookId}`) - .then((data) => { - currentThoughtsData = data; - renderThoughtsPage(data); - }) - .catch((e) => { - content.innerHTML = `
加载失败: ${e.message}
`; - }); -} - -function closeThoughtsPage() { - document.getElementById("thoughtsPage").style.display = "none"; - document.getElementById("mainView").style.display = ""; - currentThoughtsData = null; -} - -function renderThoughtsPage(data) { - // 更新统计 - const stats = document.getElementById("thoughtsBookStats"); - - // 分类:划线 / 书评 / 想法 - const highlights = data.highlights || []; - const bookReviews = (data.reviews || []).filter((r) => r.is_finish); - const thoughts = (data.reviews || []).filter((r) => !r.is_finish); - - stats.innerHTML = ` - 划线 ${highlights.length} - 书评 ${bookReviews.length} - 想法 ${thoughts.length} - `; - - const content = document.getElementById("thoughtsContent"); - - if (highlights.length === 0 && bookReviews.length === 0 && thoughts.length === 0) { - content.innerHTML = `
暂无笔记数据
`; - return; - } - - let html = ` -
-
- - - - -
-
- - -
-
-
- `; - - content.innerHTML = html; - currentThoughtsCategory = "all"; - currentThoughtsSort = "time_desc"; - currentThoughtsSearch = ""; - renderThoughtsList("all"); -} - -function filterThoughts(category) { - document.querySelectorAll(".thought-filter-btn").forEach((btn) => { - btn.classList.remove("active"); - }); - event.target.closest(".thought-filter-btn").classList.add("active"); - currentThoughtsCategory = category; - renderThoughtsList(category); -} - -function onThoughtsSearch(val) { - currentThoughtsSearch = val.trim().toLowerCase(); - renderThoughtsList(currentThoughtsCategory); -} - -function onThoughtsSort(val) { - currentThoughtsSort = val; - renderThoughtsList(currentThoughtsCategory); -} - -/** 解析日期字符串为时间戳,无效则返回 0 */ -function parseDate(str) { - if (!str) return 0; - const d = new Date(str.replace(/-/g, "/")); - return isNaN(d.getTime()) ? 0 : d.getTime(); -} - -/** 对数组按排序模式排序,返回新数组 */ -function sortByMode(arr) { - const sorted = [...arr]; - if (currentThoughtsSort === "time_desc") { - sorted.sort((a, b) => parseDate(b.create_time) - parseDate(a.create_time)); - } else if (currentThoughtsSort === "time_asc") { - sorted.sort((a, b) => parseDate(a.create_time) - parseDate(b.create_time)); - } else if (currentThoughtsSort === "chapter") { - sorted.sort((a, b) => { - const ua = parseInt(a.chapter_uid) || 0; - const ub = parseInt(b.chapter_uid) || 0; - if (ua !== ub) return ua - ub; - // 同章节内按 range 起始位置排序 - const ra = a.range ? parseInt(a.range.split("-")[0]) : 0; - const rb = b.range ? parseInt(b.range.split("-")[0]) : 0; - return ra - rb; - }); - } - return sorted; -} - -/** 过滤搜索关键词 */ -function matchSearch(text) { - if (!currentThoughtsSearch) return true; - return (text || "").toLowerCase().includes(currentThoughtsSearch); -} - -function renderThoughtsList(category) { - const data = currentThoughtsData; - if (!data) return; - - // 原始分类 - let highlights = data.highlights || []; - let bookReviews = (data.reviews || []).filter((r) => r.is_finish); - let thoughts = (data.reviews || []).filter((r) => !r.is_finish); - - // 搜索过滤 - highlights = highlights.filter((h) => matchSearch(h.mark_text)); - bookReviews = bookReviews.filter((r) => matchSearch(r.content) || matchSearch(r.abstract)); - thoughts = thoughts.filter((r) => matchSearch(r.content) || matchSearch(r.abstract)); - - // 排序 - highlights = sortByMode(highlights); - bookReviews = sortByMode(bookReviews); - thoughts = sortByMode(thoughts); - - const list = document.getElementById("thoughtsList"); - let html = ""; - - // 划线区 - if (category === "all" || category === "highlight") { - if (highlights.length > 0) { - html += `
📌 划线 ${highlights.length}
`; - if (currentThoughtsSort === "chapter") { - // 按章节分组展示 - const grouped = {}; - highlights.forEach((h) => { - const ch = h.chapter_title || "未知章节"; - if (!grouped[ch]) grouped[ch] = []; - grouped[ch].push(h); - }); - for (const [chTitle, items] of Object.entries(grouped)) { - html += `
`; - html += `
${chTitle}
`; - items.forEach((h) => { - html += ` -
- ${escapeHtml(h.mark_text)} -
- ${h.chapter_title ? `${h.chapter_title}` : ""} - ${h.create_time ? `${h.create_time}` : ""} -
-
- `; - }); - html += `
`; - } - } else { - // 按时间排序,平铺展示(标注章节) - highlights.forEach((h) => { - html += ` -
- ${escapeHtml(h.mark_text)} -
- ${h.chapter_title ? `${h.chapter_title}` : ""} - ${h.create_time ? `${h.create_time}` : ""} -
-
- `; - }); - } - } - } - - // 书评区 - if (category === "all" || category === "review") { - if (bookReviews.length > 0) { - html += `
⭐ 书评 ${bookReviews.length}
`; - bookReviews.forEach((r) => { - const starVal = Math.max(0, Math.min(5, parseInt(r.star) || 0)); - const stars = starVal > 0 ? `
${"★".repeat(starVal)}${"☆".repeat(5 - starVal)}
` : ""; - html += ` -
- ${stars} - ${escapeHtml(r.content)} -
- ${r.chapter_name ? `${r.chapter_name}` : ""} - ${r.create_time || ""} -
-
- `; - }); - } - } - - // 想法区 - if (category === "all" || category === "thought") { - if (thoughts.length > 0) { - html += `
💭 想法 ${thoughts.length}
`; - thoughts.forEach((r) => { - const abstract = r.abstract ? `
${escapeHtml(r.abstract)}
` : ""; - html += ` -
- ${abstract} -
${escapeHtml(r.content)}
-
- ${r.chapter_name ? `${r.chapter_name}` : ""} - ${r.create_time || ""} -
-
- `; - }); - } - } - - if (!html) { - if (currentThoughtsSearch) { - html = `
未找到包含"${escapeHtml(currentThoughtsSearch)}"的内容
`; - } else { - html = `
该分类暂无内容
`; - } - } - - list.innerHTML = html; -} - -// ===== 弹窗控制 ===== -function showModal(id) { - document.getElementById(id).classList.add("active"); - document.body.style.overflow = "hidden"; -} - -function closeModal(id) { - document.getElementById(id).classList.remove("active"); - document.body.style.overflow = ""; -} - -document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - closeModal("bookDetailModal"); - if (document.getElementById("thoughtsPage").style.display !== "none") { - closeThoughtsPage(); - } - } +document.addEventListener('change', event => { + if (event.target.matches('[data-pick-book]')) { const id = event.target.dataset.pickBook; event.target.checked ? draftIds.add(id) : draftIds.delete(id); renderPicker(); } + if (event.target.id === 'chapter-filter') { + selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail(); + if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); + } + if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); } }); +$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; }; +$('#goal-name').oninput = () => $('#goal-name').setCustomValidity(''); +$('#goal-form').onsubmit = saveGoal; +$('#picker-search').oninput = renderPicker; +$('#global-search').oninput = event => { + searchQuery = event.target.value.trim(); + if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) discoverTab = 'books'; + navigate('discover'); +}; +document.addEventListener('input', event => { + if (event.target.id === 'discover-search') { + const position = event.target.selectionStart; searchQuery = event.target.value; $('#global-search').value = searchQuery; renderPage(); + $('#discover-search').focus(); $('#discover-search').setSelectionRange(position, position); + } +}); +document.addEventListener('keydown', event => { if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); $('#global-search').focus(); } }); +document.querySelectorAll('dialog').forEach(dialog => dialog.addEventListener('click', event => { + const rect = dialog.getBoundingClientRect(); + if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) dialog.close(); +})); -// ===== 推荐发现 ===== -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(""); +async function boot() { + renderPage(); + await Promise.all([ensureStatus(), ensureShelf()]); + ensureDashboard(period); + if (page !== 'overview') renderPage(); } +boot(); diff --git a/static/js/demo-data.js b/static/js/demo-data.js new file mode 100644 index 0000000..ea9740a --- /dev/null +++ b/static/js/demo-data.js @@ -0,0 +1,25 @@ +// 所有数据均为设计示例,不来自真实账号。 +const books = [{title:'也许你该找个人聊聊',short:'也许你该\n找个人\n聊聊',author:'洛莉·戈特利布',en:'MAYBE YOU SHOULD TALK TO SOMEONE',cover:'c1',progress:68,status:'reading',score:'9.1',desc:'一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。'},{title:'三体',short:'三体',author:'刘慈欣',en:'THE THREE-BODY PROBLEM',cover:'c2',progress:42,status:'reading',score:'9.4',desc:'从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。'},{title:'蛤蟆先生去看心理医生',short:'蛤蟆先生\n去看\n心理医生',author:'罗伯特·戴博德',en:'COUNSELLING FOR TOADS',cover:'c3',progress:85,status:'reading',score:'8.8',desc:'借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。'},{title:'人生海海',short:'人生\n海海',author:'麦家',en:'LIFE IS A VAST SEA',cover:'c4',progress:26,status:'reading',score:'9.0',desc:'在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。'},{title:'活着',short:'活着',author:'余华',en:'TO LIVE',cover:'c3',progress:100,status:'done',score:'9.5',desc:'一个人与时代相遇的故事,关于生命的韧性与日常的重量。'},{title:'被讨厌的勇气',short:'被讨厌\n的勇气',author:'岸见一郎 / 古贺史健',en:'THE COURAGE TO BE DISLIKED',cover:'c1',progress:100,status:'done',score:'8.6',desc:'以对话的方式讨论人际关系、自我接纳与选择的自由。'},{title:'悉达多',short:'悉达多',author:'赫尔曼·黑塞',en:'SIDDHARTHA',cover:'c4',progress:0,status:'wish',score:'9.2',desc:'一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。'},{title:'置身事内',short:'置身\n事内',author:'兰小欢',en:'INSIDE THE SYSTEM',cover:'c2',progress:0,status:'wish',score:'9.3',desc:'从具体的经济生活出发,理解政府与经济发展的关系。'}]; +books.forEach((book, index) => { book.id = `b${index}`; }); +const booklists = [ + {id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']}, + {id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']}, + {id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']}, + {id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']} +]; +const initialGoals = [ + {id:'g1',name:'秋季文学计划',start:'2026-09-01',end:'2026-11-30',ids:['b3','b4','b6','b1'],sources:['在故事里,读懂人生']}, + {id:'g2',name:'给内心一点生长的空间',start:'2026-09-01',end:'2026-10-31',ids:['b0','b2','b5'],sources:['心理学,向内走一程']}, + {id:'g3',name:'读完的书,成为自己的底气',start:'2026-08-01',end:'2026-08-31',ids:['b4','b5'],sources:[]} +]; +const personalNotes = [ + {id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'}, + {id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'}, + {id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'}, + {id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'} +]; +const publicReviews = [ + {id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'}, + {id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'}, + {id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'} +]; diff --git a/static/js/weapi.js b/static/js/weapi.js new file mode 100644 index 0000000..3f7a98d --- /dev/null +++ b/static/js/weapi.js @@ -0,0 +1,125 @@ +/* ========================================================================== + 阅间 · 数据层(v2) + -------------------------------------------------------------------------- + 把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。 + 设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据, + 这里替换为真实接口调用,字段名与设计稿保持一致。 + ========================================================================== */ +(function (global) { + 'use strict'; + + const BASE = '/api/v2'; + + async function request(path, params) { + const url = new URL(BASE + path, global.location.origin); + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value); + }); + const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } }); + let payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + if (!response.ok) { + const message = (payload && (payload.error || payload.detail)) || `请求失败(${response.status})`; + const err = new Error(message); + err.status = response.status; + err.payload = payload; + throw err; + } + return payload; + } + + /* ------------------------------ 工具函数 ------------------------------ */ + + function durationText(seconds) { + const total = Math.max(0, Math.round(Number(seconds) || 0)); + const hours = Math.floor(total / 3600); + const minutes = Math.round((total % 3600) / 60); + return hours ? `${hours}小时${minutes}分钟` : `${minutes}分钟`; + } + + function percentText(value) { + const num = Number(value) || 0; + return `${num}%`; + } + + /* ------------------------------ 接口封装 ------------------------------ */ + + const WeApi = { + durationText, + percentText, + + status() { + return request('/status'); + }, + + /** + * 书架数据 + * @param {string} tab all|reading|done|wish + * @param {{keyword?:string, limit?:number, offset?:number, progress?:boolean}} options + */ + shelf(tab, options) { + const opts = options || {}; + return request('/shelf', { + tab: tab || 'all', + keyword: opts.keyword || '', + limit: opts.limit, + offset: opts.offset, + progress: opts.progress ? 1 : 0, + }); + }, + + dashboard(period) { + return request('/dashboard', { period: period || 'week' }); + }, + + notebooks() { + return request('/notebooks'); + }, + + /** bookId 传 'all' 时聚合笔记最多的前 limit 本 */ + notes(bookId, limit) { + return request('/notes', { book_id: bookId, limit: limit }); + }, + + book(bookId) { + return request('/book/' + encodeURIComponent(bookId)); + }, + + reviews(bookId, type, count) { + return request(`/book/${encodeURIComponent(bookId)}/reviews`, { type: type || 0, count: count || 10 }); + }, + + highlights(bookId, chapterUid) { + return request(`/book/${encodeURIComponent(bookId)}/highlights`, { chapter_uid: chapterUid || 0 }); + }, + + underlineThoughts(bookId, chapterUid, range) { + return request(`/book/${encodeURIComponent(bookId)}/underline-thoughts`, { + chapter_uid: chapterUid, + range: range, + }); + }, + + thought(reviewId) { + return request('/thought/' + encodeURIComponent(reviewId)); + }, + + archives() { + return request('/archives'); + }, + + recommend(maxIdx) { + return request('/recommend', { maxIdx: maxIdx || 0 }); + }, + + search(keyword, scope) { + return request('/search', { keyword: keyword, scope: scope || 'books' }); + }, + }; + + global.WeApi = WeApi; +})(window); diff --git a/templates/index.html b/templates/index.html index 803e317..b7e4462 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,211 +1,16 @@ - - + + - - - 我的读书数据 - - - - + +阅间 · 微信读书看板 + - - - - - - - - - - -
- - -
-
-
-
-

阅读,是一场漫长的旅行

-

正在加载数据...

-
-
-
-
-
-
-
-
-
- - -
-
-

📊 阅读数据可视化

-
-
-

年度阅读时长趋势

-
-
-
-

24小时阅读时段分布

-
-
-
-

偏好阅读分类

-
-
-
-

偏好作者 TOP 10

-
-
-
-
-
- - -
-
-

🏆 阅读时长排行

-
-
-
- - -
-
-

📚 我的书架

-
-
- - - -
-
-
-
-
- -
-
- - -
-
-

✍️ 笔记概览

-
-
-
-
- - -
-
-

🔍 推荐发现

-
- - -
-
-
- - -
-
-
- -
-
- -
- - - - - - - - - - - -
-
-

数据来源:微信读书 · 数据存储:本地 MySQL · 更新时间:

- -
-
- - - - + + +
L
正在连接微信读书…阅读目标保存在当前浏览器
阅间 · 与好书相伴,与自己相遇数据来自微信读书个人账号
+

书籍详情

+
MAKE ROOM FOR READING

新建阅读目标

想读些什么?

选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。

+ \ No newline at end of file