diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6eb4138 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Deploy WeReadBoard + +on: + push: + branches: + - dev + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + concurrency: + group: deploy-dev + cancel-in-progress: true + + steps: + - name: Prepare SSH + run: | + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + - name: Deploy + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_PORT: ${{ secrets.SERVER_PORT }} + SERVER_USER: ${{ secrets.SERVER_USER }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} + SERVICE_NAME: ${{ vars.SERVICE_NAME }} + DEPLOY_BRANCH: ${{ github.ref_name }} + run: | + if [ -z "$SERVER_PORT" ]; then + SERVER_PORT="22" + fi + if [ -z "$DEPLOY_PATH" ]; then + DEPLOY_PATH="/opt/weread-board/WeReadBoard" + fi + if [ -z "$SERVICE_NAME" ]; then + SERVICE_NAME="weread.service" + fi + + ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" \ + "cd '$DEPLOY_PATH' && + git fetch origin && + git checkout '$DEPLOY_BRANCH' && + git pull --ff-only origin '$DEPLOY_BRANCH' && + chmod +x ./deploy.sh && + SKIP_GIT_PULL=1 DEPLOY_BRANCH='$DEPLOY_BRANCH' SERVICE_NAME='$SERVICE_NAME' ./deploy.sh" diff --git a/.gitignore b/.gitignore index 7363bf8..efc2d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ Thumbs.db # Env .env .server.pid + +# WorkBuddy workspace memory +# Tool artifacts +.qoder/ +.workbuddy/ diff --git a/README.md b/README.md index ce09b81..190cc0b 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,25 @@ 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` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。 + +## 自动部署 + +服务器部署使用根目录的 `deploy.sh`: + +```bash +DEPLOY_BRANCH=dev ./deploy.sh +``` + +脚本会拉取指定分支,自动创建 `.venv`,安装 `requirements.txt`,执行数据库迁移,然后重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,默认监听 `dev` 分支。 ## 功能模块 diff --git a/api_v2.py b/api_v2.py new file mode 100644 index 0000000..72cc365 --- /dev/null +++ b/api_v2.py @@ -0,0 +1,1974 @@ +#!/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 contextlib import suppress +from datetime import date, datetime, timedelta, timezone +from functools import wraps + +import pymysql +from flask import Blueprint, jsonify, make_response, request +from werkzeug.exceptions import BadRequest + +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", "124.222.38.85"), + "port": int(os.environ.get("WEREAD_DB_PORT", 13306)), + "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 upsert_recommended_book(conn, values): + """按 book_id 更新推荐缓存,避免同一本书每次推荐都新增一行。""" + if not conn: + return + columns = ( + "book_id, title, author, cover, intro, category, reason, reading_count, " + "new_rating, new_rating_count, rating_title, deep_link, search_idx, price, " + "pay_type, book_type, batch_id" + ) + try: + with conn.cursor() as cur: + cur.execute("SELECT id FROM recommended_books WHERE book_id=%s ORDER BY id LIMIT 1", (values[0],)) + row = cur.fetchone() + if row: + cur.execute( + "UPDATE recommended_books SET title=%s, author=%s, cover=%s, intro=%s, category=%s, " + "reason=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, rating_title=%s, " + "deep_link=%s, search_idx=%s, price=%s, pay_type=%s, book_type=%s, batch_id=%s, " + "updated_at=NOW() WHERE id=%s", + (*values[1:], row[0]), + ) + else: + cur.execute( + f"INSERT INTO recommended_books ({columns}) VALUES ({','.join(['%s'] * 17)})", + values, + ) + cur.execute( + "INSERT INTO content_logs " + "(event_type, batch_id, book_id, search_idx) VALUES ('recommendation',%s,%s,%s)", + (values[16], values[0], values[12] or 0), + ) + conn.commit() + except Exception: + pass + + +def upsert_search_result(conn, session_id, values): + """按 keyword + book_id 更新搜索缓存,避免重复搜索产生重复行。""" + if not conn or not session_id: + return + keyword, book_id = values[0], values[1] + try: + with conn.cursor() as cur: + cur.execute( + "SELECT id FROM search_results WHERE keyword=%s AND book_id=%s ORDER BY id LIMIT 1", + (keyword, book_id), + ) + row = cur.fetchone() + if row: + cur.execute( + "UPDATE search_results SET session_id=%s, title=%s, author=%s, cover=%s, intro=%s, " + "publisher=%s, category=%s, reading_count=%s, new_rating=%s, new_rating_count=%s, " + "rating_title=%s, deep_link=%s, pay_type=%s, price=%s, soldout=%s, search_idx=%s, " + "result_scope=%s, result_title=%s, scope_count=%s, current_count=%s, updated_at=NOW() " + "WHERE id=%s", + (session_id, *values[2:], row[0]), + ) + else: + cur.execute( + "INSERT INTO search_results " + "(session_id, keyword, book_id, title, author, cover, intro, publisher, category, " + "reading_count, new_rating, new_rating_count, rating_title, deep_link, pay_type, price, " + "soldout, search_idx, result_scope, result_title, scope_count, current_count) " + f"VALUES ({','.join(['%s'] * 22)})", + (session_id, *values), + ) + cur.execute( + "INSERT INTO content_logs " + "(event_type, session_id, keyword, book_id, search_idx) VALUES ('search',%s,%s,%s,%s)", + (session_id, keyword, book_id, values[16] or 0), + ) + conn.commit() + except Exception: + pass + + +# ====================================================================== +# 格式化与展示辅助 +# ====================================================================== + + +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 + + +# ====================================================================== +# 接口:阅读目标(必须持久化,不使用可降级数据库入口) +# ====================================================================== + + +def with_goals_db(f): + """目标及成员共用事务;数据库失败必须让前端保留原表单。""" + @wraps(f) + def wrapper(*args, **kwargs): + conn = None + try: + conn = pymysql.connect(**MYSQL_CONFIG) + response = make_response(f(conn, *args, **kwargs)) + if request.method != "GET": + if response.status_code < 400: + conn.commit() + else: + conn.rollback() + return response + except Exception as error: + if conn is not None: + # 断线时回滚也可能失败;仍返回原操作错误,由关闭连接释放事务。 + with suppress(pymysql.MySQLError): + conn.rollback() + if isinstance(error, BadRequest): + return jsonify({"error": error.description}), 400 + if isinstance(error, pymysql.MySQLError): + return jsonify({"error": "阅读目标数据库暂不可用,请稍后重试"}), 503 + raise + finally: + if conn is not None: + conn.close() + return wrapper + + +def validate_goal(data): + if not isinstance(data, dict): + raise BadRequest("阅读目标必须是 JSON 对象") + goal_id = data.get("id") + if not isinstance(goal_id, str) or not 1 <= len(goal_id) <= 64: + raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串") + name = data.get("name") + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 60: + raise BadRequest("目标名称去除首尾空格后须为 1 至 60 个字符") + dates = [] + for field, label in (("start", "开始"), ("end", "结束")): + value = data.get(field) + try: + parsed = date.fromisoformat(value) if isinstance(value, str) else None + except ValueError: + parsed = None + if parsed is None or parsed.isoformat() != value or parsed.year < 1000: + raise BadRequest(f"{label}日期必须是有效的 YYYY-MM-DD 日期,年份须为 1000 至 9999") + dates.append(value) + if dates[0] > dates[1]: + raise BadRequest("结束日期不能早于开始日期") + ids = data.get("ids") + if not isinstance(ids, list) or not ids: + raise BadRequest("目标至少需要一本书,ids 必须是非空数组") + if any(not isinstance(book_id, str) or not 1 <= len(book_id) <= 64 for book_id in ids): + raise BadRequest("书籍 ID 必须是 1 至 64 个字符的字符串") + sources = data.get("sources", []) + if not isinstance(sources, list) or any(not isinstance(source, str) for source in sources): + raise BadRequest("书单来源 sources 必须是字符串数组") + return {"id": goal_id, "name": name.strip(), "start": dates[0], "end": dates[1], + "ids": list(dict.fromkeys(ids)), "sources": list(dict.fromkeys(sources))} + + +def insert_goal(cur, goal, skip_existing=False): + sql = "INSERT INTO reading_goals (id, name, start_date, end_date, sources) VALUES (%s,%s,%s,%s,%s)" + if skip_existing: + # 仅同 ID 已存在时跳过,不覆盖原目标;不用 INSERT IGNORE 掩盖其他写入错误。 + sql += " ON DUPLICATE KEY UPDATE id=id" + cur.execute(sql, (goal["id"], goal["name"], goal["start"], goal["end"], + json.dumps(goal["sources"], ensure_ascii=False))) + if skip_existing and cur.rowcount == 0: + return False + cur.executemany("INSERT INTO reading_goal_books (goal_id, book_id) VALUES (%s,%s)", + [(goal["id"], book_id) for book_id in goal["ids"]]) + return True + + +@v2_bp.route("/api/v2/goals", methods=["GET"]) +@with_goals_db +def api_v2_goals(conn): + rows = query_all(conn, + "SELECT g.id, g.name, g.start_date, g.end_date, g.sources, b.book_id " + "FROM reading_goals g LEFT JOIN reading_goal_books b ON b.goal_id=g.id " + "ORDER BY g.created_at, g.id, b.book_id") + goals = {} + for row in rows: + goal_id = row["id"] + if goal_id not in goals: + goals[goal_id] = {"id": goal_id, "name": row["name"], + "start": row["start_date"].isoformat(), "end": row["end_date"].isoformat(), + "ids": [], "sources": json.loads(row["sources"])} + # 不关联书架缓存,已移出书架或尚未同步的书籍仍保留在目标分母中。 + if row["book_id"] is not None: + goals[goal_id]["ids"].append(row["book_id"]) + return jsonify({"goals": list(goals.values())}) + + +@v2_bp.route("/api/v2/goals", methods=["POST"]) +@with_goals_db +def api_v2_create_goal(conn): + goal = validate_goal(request.get_json(silent=True)) + try: + with conn.cursor() as cur: + insert_goal(cur, goal) + except pymysql.err.IntegrityError as error: + if error.args[0] == 1062: + return jsonify({"error": "该目标 ID 已存在,请刷新后重试"}), 409 + raise + return jsonify({"goal": goal}), 201 + + +@v2_bp.route("/api/v2/goals/", methods=["PUT"]) +@with_goals_db +def api_v2_update_goal(conn, goal_id): + goal = validate_goal(request.get_json(silent=True)) + if goal["id"] != goal_id: + raise BadRequest("目标 ID 与请求路径不一致") + with conn.cursor() as cur: + # 锁定父记录,避免并发编辑把两次提交的书籍成员混在一起。 + cur.execute("SELECT id FROM reading_goals WHERE id=%s FOR UPDATE", (goal_id,)) + if cur.fetchone() is None: + return jsonify({"error": "阅读目标不存在"}), 404 + cur.execute("UPDATE reading_goals SET name=%s, start_date=%s, end_date=%s, sources=%s WHERE id=%s", + (goal["name"], goal["start"], goal["end"], + json.dumps(goal["sources"], ensure_ascii=False), goal_id)) + cur.execute("DELETE FROM reading_goal_books WHERE goal_id=%s", (goal_id,)) + cur.executemany("INSERT INTO reading_goal_books (goal_id, book_id) VALUES (%s,%s)", + [(goal_id, book_id) for book_id in goal["ids"]]) + return jsonify({"goal": goal}) + + +@v2_bp.route("/api/v2/goals/", methods=["DELETE"]) +@with_goals_db +def api_v2_delete_goal(conn, goal_id): + if not 1 <= len(goal_id) <= 64: + raise BadRequest("目标 ID 必须是 1 至 64 个字符的字符串") + with conn.cursor() as cur: + cur.execute("DELETE FROM reading_goals WHERE id=%s", (goal_id,)) + if cur.rowcount == 0: + return jsonify({"error": "阅读目标不存在"}), 404 + return jsonify({"ok": True}) + + +@v2_bp.route("/api/v2/goals/import", methods=["POST"]) +@with_goals_db +def api_v2_import_goals(conn): + data = request.get_json(silent=True) + if not isinstance(data, dict) or not isinstance(data.get("goals"), list): + raise BadRequest("导入内容必须包含 goals 数组") + # 先校验整批,重复 ID 只保留第一条;任何写入失败均回滚整个批次。 + goals = [validate_goal(goal) for goal in data["goals"]] + seen = set() + imported = 0 + with conn.cursor() as cur: + for goal in goals: + if goal["id"] in seen: + continue + seen.add(goal["id"]) + imported += insert_goal(cur, goal, skip_existing=True) + return jsonify({"imported": imported}) + + +# ====================================================================== +# 接口:连接状态 +# ====================================================================== + + +@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) + refresh = request.args.get("refresh", "0") == "1" + try: + data = weapi("/book/recommend", ttl=0 if refresh else 600, count=12, maxIdx=max_idx) + except WeReadError as e: + return jsonify({"error": str(e)}), 502 + + books = [] + batch_id = datetime.now().strftime("%Y%m%d%H%M%S") + raw_books = data.get("books") or [] + + def enrich_recommendation(book): + info = weapi_safe("/book/info", ttl=1800, bookId=str(book.get("bookId") or "")) or {} + merged = dict(book) + for key in ("cover", "newRating", "newRatingCount", "newRatingDetail", "intro", "category", "deepLink"): + if not merged.get(key) and info.get(key): + merged[key] = info[key] + return merged + + with ThreadPoolExecutor(max_workers=6) as pool: + enriched_books = list(pool.map(enrich_recommendation, raw_books)) + + for book in enriched_books: + rating_detail = book.get("newRatingDetail") or {} + if conn: + upsert_recommended_book( + conn, + ( + book.get("bookId", ""), book.get("title", ""), book.get("author", ""), + book.get("cover", ""), book.get("intro", ""), book.get("category", ""), + book.get("reason", ""), book.get("readingCount", 0), book.get("newRating", 0), + book.get("newRatingCount", 0), (rating_detail or {}).get("title", ""), + book.get("deepLink", ""), book.get("searchIdx", 0), book.get("price"), + book.get("payType"), book.get("type", 0), batch_id, + ), + ) + 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 = [], [], [], [] + session_id = None + if conn: + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO search_sessions (sid, keyword, scope, has_more, max_idx) VALUES (%s,%s,%s,%s,%s)", + (data.get("sid", ""), keyword, scope, data.get("hasMore", 0), 0), + ) + session_id = cur.lastrowid + except Exception: + session_id = None + 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 not in seen_groups: + seen_groups.add(gkey) + if title: + groups.append({"title": title, "scope": group.get("scope"), "count": group.get("scopeCount") or 0}) + items = group.get("books") or [] + if title == "作者" and not items and group.get("scopeCount"): + authors.append({"name": keyword, "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(bi.get("newRating") or 0) / 100, 1) if bi.get("newRating") else None, + "reading_count": item.get("readingCount") or 0, + "deep_link": bi.get("deepLink") or "", + "group": title, + "status": "wish", + "progress": 0, + } + if session_id: + upsert_search_result( + conn, + session_id, + ( + keyword, bid, entry["title"], entry["author"], bi.get("cover", ""), + bi.get("intro", ""), bi.get("publisher", ""), entry["category"], + item.get("readingCount", 0), bi.get("newRating", 0), bi.get("newRatingCount", 0), + (bi.get("newRatingDetail") or {}).get("title", ""), entry["deep_link"], + bi.get("payType"), bi.get("price"), bi.get("soldout", 0), item.get("searchIdx", 0), + group.get("scope"), title, group.get("scopeCount") or 0, group.get("currentCount") or 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..5cfdc84 100644 --- a/app.py +++ b/app.py @@ -18,38 +18,107 @@ import json import os +import ssl import subprocess +import sys import time import urllib.request from datetime import datetime from functools import wraps import pymysql -from flask import Flask, jsonify, render_template, request + +from config_utils import get_env_value + +try: + import certifi + _ssl_context = ssl.create_default_context(cafile=certifi.where()) +except ImportError: + _ssl_context = ssl.create_default_context() +from flask import Flask, g, 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) + # ====================================================================== # 数据库连接配置 # ====================================================================== MYSQL_CONFIG = { - "host": "127.0.0.1", # MySQL 服务器地址 - "port": 3306, # MySQL 端口 - "user": "root", # 数据库用户名 - "password": "258963", # 数据库密码 - "database": "weread_dashboard", # 数据库名称 + # 默认使用远程 MySQL;仍可通过 WEREAD_DB_* 环境变量覆盖 + "host": os.environ.get("WEREAD_DB_HOST", "124.222.38.85"), + "port": int(os.environ.get("WEREAD_DB_PORT", 13306)), + "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", # 字符集(支持完整 Unicode 含 emoji) "cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值 } + +# ====================================================================== +# 网站操作日志 +# ====================================================================== + +@app.before_request +def start_operation_log_timer(): + """记录请求开始时间,供 after_request 计算接口耗时。""" + g.operation_started_at = time.perf_counter() + + +@app.after_request +def write_operation_log(response): + """将 API 请求写入 operation_logs;日志失败不影响业务响应。""" + if not request.path.startswith("/api/"): + return response + + try: + started_at = getattr(g, "operation_started_at", time.perf_counter()) + duration_ms = max(0, int((time.perf_counter() - started_at) * 1000)) + path_parts = [part for part in request.path.split("/") if part] + action = "/".join(path_parts[-2:]) if len(path_parts) >= 2 else request.path + target_type = path_parts[-2] if len(path_parts) >= 2 else None + target_id = path_parts[-1] if len(path_parts) >= 2 and path_parts[-1] != target_type else None + detail = {"query": request.args.to_dict(flat=True)} + conn = pymysql.connect(**MYSQL_CONFIG) + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO operation_logs " + "(operation_type, action, target_type, target_id, keyword, request_path, " + "request_method, status_code, duration_ms, detail_json) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + "api_request", + action, + target_type, + target_id, + request.args.get("keyword"), + request.path, + request.method, + response.status_code, + duration_ms, + json.dumps(detail, ensure_ascii=False), + ), + ) + conn.commit() + finally: + conn.close() + except Exception: + # 日志属于旁路能力,数据库或表异常时不能影响正常页面/API 返回。 + pass + return response + # ====================================================================== # 微信读书 API 配置 # ====================================================================== WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway" -WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "") WEREAD_SKILL_VERSION = "1.0.4" @@ -147,18 +216,22 @@ def weread_api_call(api_name, **kwargs): Raises: Exception: API 调用失败时抛出异常 """ + api_key = get_env_value("WEREAD_API_KEY", "") + if not api_key: + raise RuntimeError("WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc") + 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}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", ) - with urllib.request.urlopen(req, timeout=30) as resp: + with urllib.request.urlopen(req, timeout=30, context=_ssl_context) as resp: return json.loads(resp.read().decode("utf-8")) @@ -1192,14 +1265,14 @@ def api_search(conn): def sync_data(): """运行 fetch_data.py + migrate_to_mysql.py,全量同步数据""" project_dir = os.path.dirname(os.path.abspath(__file__)) - python_bin = os.path.join( - os.path.expanduser("~"), - ".workbuddy/binaries/python/envs/default/bin/python", - ) + python_bin = sys.executable or "python3" env = os.environ.copy() - env["WEREAD_API_KEY"] = env.get("WEREAD_API_KEY", "") + env["WEREAD_API_KEY"] = get_env_value("WEREAD_API_KEY", "") if not env["WEREAD_API_KEY"]: - return jsonify({"ok": False, "error": "WEREAD_API_KEY 未设置"}), 500 + return jsonify({ + "ok": False, + "error": "WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc", + }), 500 results = {"steps": []} try: @@ -1270,5 +1343,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/config_utils.py b/config_utils.py new file mode 100644 index 0000000..9eac5fa --- /dev/null +++ b/config_utils.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +项目配置读取工具。 + +读取顺序: +1. 当前进程环境变量 +2. 项目根目录下的 .env / .env.local +3. 用户常见 shell 配置文件中的 export 语句 +""" + +from pathlib import Path +import os + + +PROJECT_DIR = Path(__file__).resolve().parent +ENV_FILE_NAMES = (".env", ".env.local") +SHELL_RC_FILES = ( + Path("~/.zshrc").expanduser(), + Path("~/.zprofile").expanduser(), + Path("~/.zlogin").expanduser(), + Path("~/.bash_profile").expanduser(), + Path("~/.bashrc").expanduser(), + Path("~/.profile").expanduser(), +) + + +def _parse_assignment(line): + """解析 KEY=VALUE 或 export KEY=VALUE 形式的配置行。""" + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + + if stripped.startswith("export "): + stripped = stripped[7:].strip() + + if "=" not in stripped: + return None + + key, value = stripped.split("=", 1) + key = key.strip() + value = value.strip() + + if not key or any(ch.isspace() for ch in key): + return None + + if value[:1] in {"'", '"'} and value[-1:] == value[:1]: + value = value[1:-1] + else: + value = value.split(" #", 1)[0].strip() + + return key, value + + +def _read_var_from_file(file_path, var_name): + """从文本配置文件里读取指定变量,后出现的值优先。""" + try: + lines = file_path.read_text(encoding="utf-8").splitlines() + except OSError: + return "" + + result = "" + for line in lines: + parsed = _parse_assignment(line) + if not parsed: + continue + key, value = parsed + if key == var_name and value: + result = value + return result + + +def _project_env_files(project_dir=None): + base_dir = Path(project_dir) if project_dir else PROJECT_DIR + return [base_dir / name for name in ENV_FILE_NAMES] + + +def load_project_env(project_dir=None): + """把项目 .env 文件里的变量加载到当前进程环境中。""" + for file_path in _project_env_files(project_dir): + if not file_path.exists(): + continue + try: + lines = file_path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for line in lines: + parsed = _parse_assignment(line) + if not parsed: + continue + key, value = parsed + if key and value and key not in os.environ: + os.environ[key] = value + + +def get_env_value(var_name, default="", project_dir=None): + """按项目约定顺序读取配置值,并回填到当前进程环境。""" + value = os.environ.get(var_name, "") + if value: + return value + + load_project_env(project_dir) + value = os.environ.get(var_name, "") + if value: + return value + + for file_path in _project_env_files(project_dir): + value = _read_var_from_file(file_path, var_name) + if value: + os.environ[var_name] = value + return value + + for file_path in SHELL_RC_FILES: + value = _read_var_from_file(file_path, var_name) + if value: + os.environ[var_name] = value + return value + + return default diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..0f2af9e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# 服务器部署脚本:拉取代码、准备 .venv、安装依赖、执行迁移并重启服务。 +set -euo pipefail + +PROJECT_DIR="${PROJECT_DIR:-$(cd "$(dirname "$0")" && pwd)}" +REMOTE="${REMOTE:-origin}" +BRANCH="${DEPLOY_BRANCH:-${GITHUB_REF_NAME:-}}" +SERVICE_NAME="${SERVICE_NAME:-weread.service}" +VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv}" +ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env}" + +cd "$PROJECT_DIR" + +if [ -z "$BRANCH" ]; then + BRANCH="$(git branch --show-current)" +fi + +if [ -z "$BRANCH" ]; then + echo "未检测到部署分支,请设置 DEPLOY_BRANCH" + exit 1 +fi + +if [ "${SKIP_GIT_PULL:-0}" = "1" ]; then + echo "[1/5] 代码已拉取,跳过 git pull" +else + echo "[1/5] 拉取 $REMOTE/$BRANCH" + git fetch "$REMOTE" + git checkout "$BRANCH" + git pull --ff-only "$REMOTE" "$BRANCH" +fi + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a +elif command -v systemctl >/dev/null 2>&1; then + SERVICE_ENV="$(systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null || true)" + for item in $SERVICE_ENV; do + case "$item" in + *=*) export "$item" ;; + esac + done +fi + +find_python() { + if command -v python3 >/dev/null 2>&1; then + command -v python3 + return + fi + if command -v python >/dev/null 2>&1; then + command -v python + return + fi + echo "未找到 python3 或 python,请先在服务器安装 Python" + exit 1 +} + +echo "[2/5] 检测 Python 虚拟环境" +if [ ! -x "$VENV_DIR/bin/python" ]; then + "$(find_python)" -m venv "$VENV_DIR" +fi + +PYTHON="$VENV_DIR/bin/python" + +echo "[3/5] 安装依赖" +if ! "$PYTHON" -m pip --version >/dev/null 2>&1; then + "$PYTHON" -m ensurepip --upgrade +fi +"$PYTHON" -m pip install -r requirements.txt + +echo "[4/5] 执行数据库迁移" +"$PYTHON" - <<'PY' +from pathlib import Path + +import pymysql + +from api_v2 import MYSQL_CONFIG + +migration = Path("migrations/001_reading_goals.sql") +if migration.exists(): + with pymysql.connect(**MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + for sql in migration.read_text(encoding="utf-8").split(";"): + if sql.strip(): + cur.execute(sql) + conn.commit() +PY + +echo "[5/5] 重启服务 $SERVICE_NAME" +if command -v systemctl >/dev/null 2>&1; then + if [ "$(id -u)" -eq 0 ]; then + systemctl restart "$SERVICE_NAME" + systemctl status "$SERVICE_NAME" --no-pager + else + sudo systemctl restart "$SERVICE_NAME" + sudo systemctl status "$SERVICE_NAME" --no-pager + fi +else + echo "当前系统没有 systemctl,请手动用 $PYTHON app.py 启动" +fi diff --git a/docs/PROJECT_FINAL_DOC.md b/docs/PROJECT_FINAL_DOC.md new file mode 100644 index 0000000..9f6bad7 --- /dev/null +++ b/docs/PROJECT_FINAL_DOC.md @@ -0,0 +1,286 @@ +# 微信读书数据看板最终文档 + +本文档用于替代 `.qoder/` 与 `.workbuddy/` 中分散的工具生成文档,作为当前仓库内的单一最终说明。内容以仓库现状为准,吸收了工具文档中的架构说明、接口说明、数据库分组、部署信息和迭代记录,同时剔除了已经过期或仅对工具自身有意义的中间信息。 + +## 1. 文档与目录约定 + +- 本文件是 `.qoder/repowiki` 与 `.workbuddy/memory` 的合并结果,也是这两个目录的人工可维护替代品。 +- `.qoder/`、`.workbuddy/` 属于外部工具的中间产物,不参与 Git 管理。 +- 深度技术细节仍保留在仓库原有文档中: + - [TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) + - [METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md) + +## 2. 项目定位 + +这是一个基于微信读书 Agent Gateway API 的个人阅读数据看板。系统将微信读书中的书架、阅读统计、笔记、推荐和搜索能力整合到一个本地运行的网站中,提供可视化图表、分页书架、笔记概览和按需详情查询。 + +当前实现采用: + +- 后端:Flask + PyMySQL +- 前端:原生 HTML / CSS / JavaScript + ECharts +- 缓存与采集:SQLite +- 在线查询数据源:MySQL +- 外部能力:微信读书 Agent Gateway API + +## 3. 当前仓库结构 + +核心文件与职责如下: + +- [app.py](/Users/kinghwj/Codes/WeReadBoard/app.py):Flask 后端服务,提供页面和 `/api/*` 接口。 +- [fetch_data.py](/Users/kinghwj/Codes/WeReadBoard/fetch_data.py):全量采集脚本,将微信读书数据写入 SQLite。 +- [migrate_to_mysql.py](/Users/kinghwj/Codes/WeReadBoard/migrate_to_mysql.py):将 SQLite 数据迁移到 MySQL。 +- [schema_mysql.sql](/Users/kinghwj/Codes/WeReadBoard/schema_mysql.sql):MySQL 31 张表建表语句。 +- [config_utils.py](/Users/kinghwj/Codes/WeReadBoard/config_utils.py):运行时配置读取工具。 +- [start_server.sh](/Users/kinghwj/Codes/WeReadBoard/start_server.sh):本地服务生命周期脚本。 +- [templates/index.html](/Users/kinghwj/Codes/WeReadBoard/templates/index.html):页面骨架。 +- [static/js/app.js](/Users/kinghwj/Codes/WeReadBoard/static/js/app.js):前端交互逻辑。 +- [static/css/style.css](/Users/kinghwj/Codes/WeReadBoard/static/css/style.css):主题、布局和组件样式。 + +## 4. 系统架构 + +系统采用“采集 -> 迁移 -> 展示 -> 按需回填”的模式: + +1. `fetch_data.py` 从微信读书 API 拉取全量数据,落入本地 `weread.db`。 +2. `migrate_to_mysql.py` 将 SQLite 中的数据按表迁移到 MySQL `weread_dashboard`。 +3. `app.py` 从 MySQL 读取基础数据,向前端提供 REST API。 +4. 部分详情型接口会在用户点击时再去调用微信读书 API,并把结果缓存回 MySQL。 +5. 前端点击“刷新”时会调用 `/api/sync`,串行执行全量采集与迁移。 + +## 5. 运行时配置 + +### 5.1 微信读书 API Key + +当前代码通过 [config_utils.py](/Users/kinghwj/Codes/WeReadBoard/config_utils.py) 读取 `WEREAD_API_KEY`,优先级如下: + +1. 当前进程环境变量 +2. 项目根目录 `.env` +3. 项目根目录 `.env.local` +4. 用户 shell 配置文件,如 `~/.zshrc`、`~/.zprofile`、`~/.bash_profile` + +这层逻辑的目的,是避免“终端里已经 export,但服务进程没有继承”导致的误报。 + +### 5.2 MySQL 配置 + +当前 MySQL 连接配置仍写在代码中,`app.py` 与 `migrate_to_mysql.py` 各维护一份: + +- host: `127.0.0.1` +- port: `3306` +- user: `root` +- password: `258963` +- database: `weread_dashboard` + +这部分目前能工作,但仍属于后续值得继续抽离的技术债。 + +### 5.3 端口与启动 + +- 默认端口:`8080` +- 可选环境变量:`WEREAD_PORT` +- 启动脚本使用当前机器上可用的 `python3` 或 `python` + +常用命令: + +```bash +pip install -r requirements.txt +python3 app.py +./start_server.sh daemon +./start_server.sh stop +./start_server.sh status +``` + +## 6. 主要功能 + +### 6.1 概览与图表 + +- 总阅读时长、阅读天数、书架数量、笔记总数 +- 年度阅读趋势 +- 24 小时时段分布 +- 偏好分类 +- 偏好作者 +- 阅读时长排行 + +### 6.2 书架 + +- 关键词搜索书名 / 作者 +- 分类筛选与父子分类树 +- 仅看已读完 +- 分页浏览 +- 一键刷新同步微信读书数据 + +### 6.3 笔记与想法 + +- 笔记本概览 +- 点击书籍查看按章节整理的划线、想法和点评 +- 想法详情页按类型筛选 + +### 6.4 发现能力 + +- 为你推荐 +- 搜索书籍 +- 推荐 / 搜索结果写回本地缓存 + +## 7. API 总览 + +当前后端接口主要分为四类: + +### 7.1 系统接口 + +- `GET /api/health`:数据库健康检查 +- `POST /api/sync`:执行全量采集与迁移 + +### 7.2 基础统计接口 + +- `GET /api/overview` +- `GET /api/yearly-trend` +- `GET /api/top-books` +- `GET /api/categories` +- `GET /api/authors` +- `GET /api/time-distribution` +- `GET /api/read-stats` +- `GET /api/archives` + +### 7.3 书架与笔记接口 + +- `GET /api/shelf` +- `GET /api/notebooks` + +### 7.4 按需详情 / 发现接口 + +- `GET /api/book/detail` +- `GET /api/book/notes` +- `GET /api/recommend` +- `GET /api/search` + +接口返回格式、参数细节和字段说明,仍以 [TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) 为准。 + +## 8. 数据模型分组 + +当前 MySQL 使用 31 张表,按职责分为以下几组: + +### 8.1 书架 + +- `books` +- `shelf_albums` +- `shelf_meta` +- `archives` + +### 8.2 阅读统计 + +- `reading_stats` +- `read_times` +- `read_longest` +- `read_stat_items` +- `prefer_categories` +- `prefer_authors` +- `prefer_publishers` +- `prefer_cps` +- `prefer_books` +- `prefer_time` +- `read_rank` +- `medals` +- `year_reports` + +### 8.3 笔记与书籍内容 + +- `notebooks` +- `book_highlights` +- `book_chapters` +- `book_reviews` +- `book_best_bookmarks` +- `book_underlines` +- `public_reviews` + +### 8.4 书籍详情与进度 + +- `book_details` +- `book_progress` + +### 8.5 推荐与搜索 + +- `recommended_books` +- `similar_books` +- `search_sessions` +- `search_results` + +### 8.6 系统元信息 + +- `fetch_meta` + +设计特征: + +- MySQL 统一使用 `utf8mb4` + `InnoDB` +- 大多数时间字段采用“整型时间戳 + `_dt` 可读时间列”的双列模式 +- 每张表统一维护 `created_at` 与 `updated_at` + +完整字段说明见 [METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md)。 + +## 9. 前端实现要点 + +当前前端是单页标签切换式界面,主要包括: + +- 主题切换:支持明暗主题与本地持久化 +- 图表懒加载:首次进入图表页时才初始化 ECharts +- 高清封面策略:低清 URL 自动升级为高清版本 +- 书架筛选:关键词、分类、已读完、分页 +- 想法详情页:从弹窗升级为独立页面 +- 刷新同步:按钮触发 `/api/sync` 后再刷新书架数据 + +## 10. 部署与运维要点 + +- MySQL 需要先执行 [schema_mysql.sql](/Users/kinghwj/Codes/WeReadBoard/schema_mysql.sql) 建库建表。 +- 初次准备数据时,执行: + +```bash +python3 fetch_data.py +python3 migrate_to_mysql.py +``` + +- 日常使用可直接启动 Web 服务,前端点“刷新”会自动触发同步。 +- 若样式或静态资源修改后页面无变化,优先检查: + - 服务是否使用了旧进程 + - 浏览器是否使用了缓存 + +## 11. 合并后的迭代记录 + +以下内容来自 `.workbuddy/memory/2026-07-17.md`,但已按主题重整并去掉工具内部流程描述。 + +### 11.1 数据层演进 + +- 项目由 SQLite 展示切换为 MySQL 展示。 +- 建表从 18 张表扩展到 31 张表,对齐官方接口字段。 +- 迁移脚本重写,支持全量导入与时间字段转换。 + +### 11.2 后端演进 + +- 新增 `db_required` 装饰器统一管理数据库连接。 +- 新增 `/api/health` 用于前端健康检查。 +- 新增 `/api/sync`,打通“采集 -> 迁移 -> 展示”的刷新流程。 +- 新增按需详情接口,失败时可回退本地缓存。 + +### 11.3 前端演进 + +- 新增明暗主题切换和图表配色适配。 +- 阅读排行从卡片改为列表。 +- 想法详情从弹窗改为独立页面。 +- 新增顶部搜索框、书架分类树、推荐与搜索页。 +- 书架增加刷新按钮,并在后续多次修复其加载态抖动问题。 + +### 11.4 启动与配置演进 + +- `start_server.sh` 从硬编码路径改为动态识别项目目录与解释器。 +- `WEREAD_API_KEY` 从单纯依赖当前 shell 环境,演进为支持 `.env` 和 shell 配置回退读取。 + +## 12. 当前已知技术债 + +- MySQL 连接配置仍硬编码在 `app.py` 和 `migrate_to_mysql.py`,存在重复维护。 +- `README.md` 仍保留较早期的 SQLite 描述,后续可与当前架构再对齐。 +- 当前仍以本地脚本为主,未引入更正式的进程管理和自动化部署方案。 + +## 13. 结论 + +现在仓库内推荐遵循的文档与资料体系如下: + +- 最终总说明:本文件 [PROJECT_FINAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/PROJECT_FINAL_DOC.md) +- 技术展开说明:[TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) +- 表结构与字段说明:[METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md) + +`.qoder/` 与 `.workbuddy/` 继续保留给工具使用即可,但不再视为仓库的正式文档来源。 diff --git a/docs/V2_API.md b/docs/V2_API.md new file mode 100644 index 0000000..07d34b6 --- /dev/null +++ b/docs/V2_API.md @@ -0,0 +1,67 @@ +# 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` | | +| 阅读目标 | MySQL `reading_goals` / `reading_goal_books` | 数据库持久化;旧浏览器目标可手动导入 | + +MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。 + +## 接口清单 + +| 方法 | 路径 | 参数 | 说明 | +|------|------|------|------| +| 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 | + +## 与微信读书接口的字段口径 + +### 阅读目标接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/v2/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 | +| POST | `/api/v2/goals` | 新建,成功返回 201 和 `{"goal":{...}}` | +| PUT | `/api/v2/goals/` | 整体替换目标和书目,返回 `{"goal":{...}}` | +| DELETE | `/api/v2/goals/` | 删除目标及其成员,返回 `{"ok":true}` | +| POST | `/api/v2/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` | + +新建、编辑和导入中的目标字段一致: + +```json +{"id":"goal-example","name":"秋季阅读","start":"2026-09-01","end":"2026-09-30","ids":["123","456"],"sources":["文学书单"]} +``` + +ID 和书籍 ID 为 1 至 64 字符的字符串;名称去除首尾空格后为 1 至 60 字符。日期必须为有效的 `YYYY-MM-DD`,年份为 1000 至 9999,结束日期不早于开始日期。`ids` 为非空字符串数组;`sources` 为字符串数组,省略时默认为空。PUT 请求体 ID 必须与路径一致。 + +书籍和来源去重,未知书籍 ID 仍保留。错误返回 `{"error":"中文说明"}`:400 参数非法、404 目标不存在、409 新建 ID 冲突、503 数据库不可用。导入不覆盖已有 ID,失败时回滚整个批次。迁移与验证方式见 [技术指南](technical-guide.md)。 + +### 阅读数据字段 + +- 评分:接口 `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/code-design.md b/docs/code-design.md new file mode 100644 index 0000000..50193b5 --- /dev/null +++ b/docs/code-design.md @@ -0,0 +1,84 @@ +# 阅间 Demo 设计与数据流 + +## 页面分工 + +统计并入阅读概览;导航保留阅读概览、我的书架、阅读目标、笔记与划线、发现好书。概览展示阅读统计和目标摘要,目标页管理多份具体阅读清单。 + +完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。 + +此版本属于 2.0 设计探索。正式版本需要接入真实接口、完成验收并合并到正式 Web 项目的 `master`,随后再创建 `v2.0` 标签。 + +## 数据流 + +`demo-data.js` 提供样例书籍、书单及初始目标。`app.js` 根据当前页面和筛选条件渲染内容;用户输入写入目标草稿,验证通过后保存到浏览器,再更新目标列表。现有 Flask 服务和数据采集代码不参与本轮 Demo。 + +书籍卡片打开详情;详情页可选目录、个人笔记、热门划线、点评、相似书。个人笔记与公开点评使用不同数据集合。热门划线中的想法继续打开单条详情。 + +书籍、书单详情均可加入已有目标,也可带着已选内容创建新目标。 + +## 阅读目标口径与例子 + +目标字段:`id`、`name`、`start`、`end`、`ids`、`sources`。`ids` 存放去重后的书籍 ID;`sources` 记录曾加入的书单名称,作为来源说明,不表示持续关联。 + +例:单独选择已读完的《活着》,再加入包含《活着》《人生海海》《悉达多》的书单。保存后总数为 3 本、已读完为 1 本,进度为 33%。从目标中移除《人生海海》后,进度为 1 / 2,即 50%。 + +完成状态取书籍当前已读状态,不受目标起止日期约束。同一本书可计入多个目标。同一目标内不重复计数。 + +状态判断先检查是否全部读完,再检查日期。例如目标时间为未来一周,但其中两本书都已读完,直接显示已完成;尚未全部完成时,按日期显示未开始、进行中、已到期。 + +书单采用加入时的书目,不自动跟随外部增删。移除目标内一本书,只影响这个目标,不改变书架或其他目标。 + +## 实现位置 + +| 职责 | 位置 | +|---|---| +| 去重与完成率、状态 | `app.js`:`uniqueIds`、`goalMetrics` | +| 新建、编辑、书单混选 | `app.js`:`openGoalEditor`、`renderPicker`、`saveGoal` | +| 加入已有目标 | `app.js`:`chooseTarget`、`appendGoal` | +| 本地持久化与异常恢复 | `app.js`:初始加载、`persistGoals` | +| 页面与详情导航 | `app.js`:`navigate`、`renderPage`、`handleAction` | +| 笔记导出 | `app.js`:`exportNotes` | + +用户输入的目标名称和来源在 HTML 渲染前转义。日期和内容在保存前校验;原生对话框提供焦点管理和 Escape 关闭。 + +## 部署流程 + +GitHub Actions 的 `.github/workflows/deploy.yml` 监听 `dev` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。 + +`deploy.sh` 以当前触发分支为准拉取代码,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,执行 `migrations/001_reading_goals.sql`,最后重启 `weread.service`。 + +例:用户把代码推送到 `dev` 分支,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。 + +## 示例与真实接口的边界 + +概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。 + +书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 ISBN 等字段。 + +正式接口接入单独推进,必须核对实时可用接口、鉴权、分页、错误、升级提示及字段口径;本轮 Demo 的完成状态不得用于宣称这些能力已真实接入。 + +## 正式看板的紧凑布局 + +正式看板在 `static/css/styles.css` 中统一调整页面密度。顶栏高度为桌面 58 px、手机 56 px;标题区采用更短的上下间距,隐藏页面标题前的英文装饰文字。标题、说明、筛选、内容依次展示,操作按钮在桌面与标题并排,手机上自然换行。 + +书架按可用宽度自动排列书籍,每列约 116 px 起步;手机固定两列。真实封面维持原比例,并受所在列宽度约束。例如,书架网格可用宽度为 900 px 时显示多列紧凑排布,手机宽度为 390 px 时显示 2 列,长书名仍使用已有省略规则。 + +目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。 + +## 正式看板的阅读目标持久化 + +正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `api_v2.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。 + +`reading_goals` 保存 `id`、`name`、`start_date`、`end_date`、来源书单名称 `sources` 和创建、修改时间。`reading_goal_books` 保存 `goal_id`、`book_id`,联合主键防止同一目标重复计书。目标 ID 沿用前端生成值,便于导入旧目标。来源名称保存为 JSON 数组,不表示持续关联书单。 + +目标成员只通过外键关联目标。删除目标时级联删除成员,书架不受影响;书籍移出书架时也不删除目标成员。迁移位于 `migrations/001_reading_goals.sql`,只需部署时执行,不在请求中自动建表。 + +页面加载时 GET 目标列表,新建时 POST,编辑及追加书籍时 PUT,删除时 DELETE。表单先校验名称、日期和书目,服务端再次校验。目标与成员在同一事务中提交,编辑时锁定目标父记录并整体替换成员,避免并发请求混入两份书目。多个浏览器同时编辑同一目标时,以最后成功保存的完整内容为准。 + +前端收到提交成功响应后更新内存并关闭表单。保存中阻止重复提交;目标列表未从数据库读取完成前,不打开新建或编辑窗口,避免初始读取失败状态覆盖刚保存的目标。新建重试保持原 ID。数据库错误转换为 503,回滚事务并让前端保留草稿。目标列表不使用进程缓存,刷新即读取数据库。 + +例:创建“秋季阅读”,日期为 `2026-09-01` 至 `2026-09-30`,输入书籍 `["A", "A", "B"]` 和来源 `["文学书单"]`。数据库生成 1 条目标、2 条成员。若 A 已读完而 B 未读完,页面显示 `1 / 2,50%`;B 暂时不在书架中时仍显示 `1 / 2,50%`,B 的资料占位显示“状态待同步”,可在编辑器移除。若用户明确移除 B,则显示 `1 / 1,100%`。 + +完成率和状态不写入目标表。当前已知已读状态用于计算,不要求在目标日期内读完;全部已读完优先显示“已完成”,否则按浏览器本地日期显示未开始、进行中或已到期,起止当天均属于进行中。未知状态不计为已完成,但仍计入分母。 + +旧数据仅在用户点击导入时发送给 `/api/v2/goals/import`。先校验整批,再在事务中逐项插入;相同 ID 已存在时保留数据库内容,批次内重复 ID 只处理第一条。例如库中已有 G1,导入 `[G1, G2, G2]` 返回 `{"imported":1}`,再次导入返回 `{"imported":0}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。 diff --git a/docs/deploy-dev.md b/docs/deploy-dev.md new file mode 100644 index 0000000..86bcda9 --- /dev/null +++ b/docs/deploy-dev.md @@ -0,0 +1,65 @@ +# dev 自动部署 + +GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `dev` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `dev`、安装依赖、执行阅读目标表迁移并重启服务。 + +## GitHub 配置 + +已配置的仓库 Secrets: + +- `SERVER_HOST` +- `SERVER_PORT` +- `SERVER_SSH_KEY` +- `SERVER_USER` + +可选再配置两个仓库 Variables: + +- `DEPLOY_PATH`:服务器上的项目目录,例如 `/opt/WeReadBoard` +- `SERVICE_NAME`:systemd 服务名,例如 `weread.service` + +未配置 Variables 时,workflow 默认使用 `/opt/weread-board/WeReadBoard` 和 `weread.service`。 + +## 服务器要求 + +服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/dev`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。 + +如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。SSH 用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。 + +`deploy.sh` 会执行: + +```bash +git fetch origin +git checkout dev +git pull --ff-only origin dev +python3 -m venv .venv # 仅 .venv 不存在时执行 +.venv/bin/python -m pip install -r requirements.txt +# 读取 api_v2.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql +sudo systemctl restart "$SERVICE_NAME" +``` + +其中迁移实际通过 `api_v2.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。 + +## systemd 示例 + +```ini +[Unit] +Description=WeReadBoard +After=network.target + +[Service] +WorkingDirectory=/opt/weread-board/WeReadBoard +Environment=WEREAD_API_KEY=wrk-xxxxxxxx +Environment=WEREAD_DB_HOST=127.0.0.1 +Environment=WEREAD_DB_PORT=3306 +Environment=WEREAD_DB_USER=weread +Environment=WEREAD_DB_PASSWORD=change-me +Environment=WEREAD_DB_NAME=weread +Environment=PORT=8770 +ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。 diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md new file mode 100644 index 0000000..03597b2 --- /dev/null +++ b/docs/feature-checklist.md @@ -0,0 +1,127 @@ +# 阅间功能目标清单 + +> 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。 + +当前版本定位:本目录中的静态 Demo 是 2.0 设计探索,不是正式 2.0 发布。只有后续真实功能合并到正式 Web 项目的 `master` 并完成验收后,才创建 `v2.0` 标签。 + +## 使用方式 + +- 功能编号固定,后续需求追加新编号,不复用或删除旧编号。 +- 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。 +- 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。 +- 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。 +- 当前范围为优化本地假数据 Demo,不接入账号,不修改原 Python 业务逻辑,不推送。 + +## 已确认业务口径 + +| 编号 | 规则 | +|---|---| +| R01 | 合并阅读概览与阅读统计,统计在概览内展示,不设置重复导航。 | +| R02 | 支持多个阅读目标,每个目标有名称、开始日期、结束日期。结束日期不得早于开始日期。 | +| R03 | 目标可添加具体书籍、一个或多个书单,允许混合选择。 | +| R04 | 同一目标内按书籍 ID 去重;同一本书可属于多个目标。 | +| R05 | 已读完的书直接计为完成,不要求在目标时间范围内读完,不做重读。 | +| R06 | 完成率 = 已读完书籍数 / 去重后目标书籍数。创建目标至少选一本;空目标展示未开始,不除零。 | +| R07 | 时间范围仅安排计划和提示状态,不改变书籍是否读完。全部读完优先显示已完成;否则按未开始、进行中、已到期展示。 | +| R08 | 本版把书单当前成员加入目标并展示来源,保存时提示「加入当前书目」。书单后续变动不自动增删目标书籍;自动跟随属于后续待讨论项。 | +| R09 | Demo 目标保存在当前浏览器,刷新保留;不写回微信读书。现有文档没有目标写回接口。 | +| R10 | 接入时按接口已读完状态判断,不把进度 1 当作完成:书架 finishReading=1;独立进度需要 progress=100 且存在 finishTime。冲突、缺失数据需处理,不伪造已读状态。 | +| R11 | 书架条目数 = books.length + albums.length + (mp 非空 ? 1 : 0)。有声专辑「已完结」不是用户「已读完」。 | +| R12 | 笔记总数 = 划线数 + 想法/点评数 + 书签数。公开点评不计入个人笔记;书签目前仅有数量,无内容。 | +| R13 | 时间戳显示 YYYY-MM-DD;时长按秒转换为小时/分钟;评分按对应接口说明转换,不能混用百分制、五分制。 | +| R14 | 演示数据与真实数据明确区分。无 deepLink 不拼接阅读链接。可选字段缺失不当作 0,也不补造内容。 | + +## 功能目标与追踪 + +以下位置已按本轮实现回填。函数名作为稳定定位依据,行号仅对应本次版本。 + +| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 | +|---|---|---|---|---| +| F01 | 五个入口:概览、书架、目标、笔记、发现;暗亮切换及移动适配 | 已验证(示例) | 不涉及接口 | `web-demo/app.js:54` `navigate`; `web-demo/app.js:30` `setTheme` | +| F02 | 概览内切换周/月/年/总计,展示时长、天数、日均、排行、偏好 | 已验证(示例) | 待实现 | `web-demo/app.js:85` `renderOverview` | +| F03 | 多目标列表、名称、时间范围、封面组合、完成率、到期状态 | 已验证(示例) | 本地功能,待正式持久化 | `web-demo/app.js:76` `renderGoals`; `web-demo/app.js:67` `goalMetrics` | +| F04 | 创建与编辑目标,校验日期和非空内容;刷新保留 | 已验证(示例) | 本地功能,待正式持久化 | `web-demo/app.js:122` `openGoalEditor`; `web-demo/app.js:138` `saveGoal` | +| F05 | 目标混合选择单书与书单,去重,已读直接计完成 | 已验证(示例) | 待接真实书籍状态 | `web-demo/app.js:131` `selectedGoalBooks`; `web-demo/app.js:67` `goalMetrics` | +| F06 | 目标详情查看成员、来源、单本进度;编辑移除成员 | 已验证(示例) | 待接真实书籍状态 | `web-demo/app.js:118` `openGoalDetail`; `web-demo/app.js:122` `openGoalEditor` | +| F07 | 我的书架:在读/已读/想读、书单、有声书和文章收藏入口 | 已验证(示例) | 待实现 | `web-demo/app.js:105` `renderShelf` | +| F08 | 我的书单列表与详情;从书单添加目标 | 已验证(示例) | 待实现 | `web-demo/app.js:104` `renderLists`; `web-demo/app.js:113` `openList` | +| F09 | 发现页:书籍/书单搜索、公开书单详情示意、空结果 | 已验证(示例) | 搜索待实现;公开书单成员待接口验证 | `web-demo/app.js:158` `renderDiscover`; `web-demo/app.js:113` `openList` | +| F10 | 书籍详情:信息、阅读进度、目录;加入目标 | 已验证(示例) | 待实现 | `web-demo/app.js:192` `openBook`; `web-demo/app.js:197` `renderBookTab` | +| F11 | 笔记本、划线与个人想法、书签数量、按书过滤、内容导出 | 内容/导出预览已验证;文件下载待验证 | 待实现;书签内容暂不支持 | `web-demo/app.js:177` `renderNotes`; `web-demo/app.js:184` `exportNotes` | +| F12 | 热门划线、章节热度、划线下想法及想法详情 | 已验证(示例) | 待实现 | `web-demo/app.js:197` `renderBookTab`; `web-demo/app.js:208` `openThought` | +| F13 | 公开点评筛选、点评详情,与个人笔记分离 | 已验证(示例) | 待实现 | `web-demo/app.js:197` `renderBookTab`; `web-demo/app.js:208` `openThought` | +| F14 | 个性化推荐和相似书推荐,可查详情和加入目标 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover`; `web-demo/app.js:197` `renderBookTab` | +| F15 | 搜索其余类型:作者、网文、听书、全文、公众号、文章、综合 | 已验证(示例) | 待实现;非书籍回包结构需实测 | `web-demo/app.js:158` `renderDiscover` | +| F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` | +| F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 | +| F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` | + +## 全部文档接口的落点 + +依据本地 weread-skills 1.0.4 能力文档。未调用实时 `/_list`,因此此表是文档已知能力,不宣称已发现网关所有实际接口。 + +| 接口 | 使用场景 | 约束与后续接入要求 | +|---|---|---| +| /_list | 接入前核对网关可用接口 | 获取后补齐本清单;不可将未验证能力标完成 | +| /shelf/sync | 书架、我的书单、专辑、文章收藏 | archive 包含名称与 bookIds;mp 仅入口,不是文章内容 | +| /store/search | 综合、电子书、网文、听书、作者、全文、书单、公众号、文章 | 显式 scope;分页看 hasMore/searchIdx;书单成员结构待验证 | +| /book/info | 详情资料与评分 | 按返回字段展示,不补造 ISBN 等信息 | +| /book/chapterinfo | 目录与章节选择 | 使用 chapterUid,保留层级和付费状态 | +| /book/getprogress | 书籍进度、已读状态 | 进度 0–100;时长秒;最近阅读优先加载,避免全书架逐本请求 | +| /readdata/detail | 概览全部阅读统计 | 自然周期;总量用 totalReadTime;日均按自然日;preferTime 从 6 点开始;可选数据缺失时隐藏 | +| /user/notebooks | 笔记本与数量 | count + lastSort 分页;总数含书签 | +| /book/bookmarklist | 划线内容 | 不含书签内容 | +| /review/list/mine | 个人想法与点评 | 与划线合并导出;使用 bookid 参数及 synckey 分页 | +| /book/underlines | 章节划线热度 | 只有人数/位置等统计,不含文本 | +| /book/bestbookmarks | 热门划线 | 固定前 20 条,不支持分页 | +| /book/readreviews | 划线下想法 | 使用返回 range、chapterUid;每个 range 独立分页 | +| /review/single | 想法、点评详情 | 富文本正式接入时需净化后渲染 | +| /review/list | 公开点评及筛选 | 双层 review 包装;20/40/60/80/100 对应 1–5 星 | +| /book/recommend | 为你推荐 | 使用 searchIdx 分页 | +| /book/similar | 相似推荐 | 显式 count/maxIdx;后续携带 sessionId | + +## 验收记录 + +2026-09-11 在本地浏览器实测,结果如下。均为假数据 Demo 验证,不代表真实接口验收。 + +| 场景 | 输入或操作 | 实际结果 | +|---|---|---| +| 导航合并 | 查看侧边栏 | 共 5 个入口,无独立阅读统计 | +| 日期校验 | 开始 2026-09-12,结束 2026-09-01 | 原生表单阻止保存,提示结束日期过早 | +| 非空校验 | 只填写名称与日期,未选择书籍 | 提示至少选择一本书或一个书单 | +| 混合去重 | 单独选《活着》,再加入含它的三本书单 | 已选 3 本,1 本已读完,保存显示 33% | +| 保存刷新 | 保存“周末慢读清单”后刷新 | 名称、日期、3 本书与进度保留 | +| 编辑移除 | 从上述目标移除《人生海海》 | 剩余 2 本,1 本完成,显示 50% | +| 已有目标去重 | 重复加入“心理学,向内走一程”书单 | 原目标仍为 3 本,1 本完成 | +| 书单查询 | 发现页选择书单,输入“心理学” | 仅显示匹配书单,详情可加入已有目标 | +| 书籍内容 | 三体 → 目录 → 示例章节 2 → 划线想法 → 详情 | 展示对应入口及示例内容 | +| 点评筛选 | 读者点评选择“一般” | 仅显示三星的一般点评示例 | +| 相似推荐 | 在书籍详情选择相似推荐 | 展示其他 4 本书,可进入详情 | +| 笔记边界 | 全部笔记 → 书签数量 | 总数 10,划线 2、想法 2、书签 6;不显示书签内容 | +| 导出预览 | 导出全部笔记 | 生成 4 条内容:2 条划线、2 条想法,包含演示声明 | +| 文件下载 | 等待浏览器下载事件 | 事件超时,未确认落盘;保留可复制预览和下载链接,后续实测下载 | +| 搜索类型 | 推荐、作者、网文、听书、全文、公众号、文章、综合 | 均切换为对应类型示意 | +| 失败重试 | 预览状态选失败,再点击重试 | 显示失败状态,重试恢复结果 | +| 统计周期 | 切换月、年、总计、周 | 图表与总量切换;年度最终核对为 128小时42分钟 | +| 书架边界 | 我的书单、有声书、文章收藏 | 展示书单、有声信息和收藏入口边界说明 | +| 主题与响应式 | 1440px 桌面、390px 手机,暗亮切换 | 桌面双列目标;手机单列且编辑弹窗可滚动,无横向溢出 | +| 静态检查 | Node 语法检查 app.js 和 demo-data.js | 均通过 | + +可重复手工验收:新建空内容目标检查提示;选择《活着》和文学书单验证 1/3;刷新验证保存;移除一本验证 1/2。不要把验收用目标写入未来真实账号。 + +## 变更记录 + +| 日期 | 变更 | 依据 | +|---|---|---| +| 2026-09-11 | 建立清单;确认多目标、日期范围、单书与书单混合、已读直接完成 | 本任务用户需求 | +| 2026-09-11 | 完成第二版 Demo,回填代码位置与验收;真实接口接入保留待实现 | 本轮浏览器实测 | + +## 后续追加模板 + +复制下面一行到功能目标表,分配下一个编号,并在完成时同步验收记录。 + +| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 | +|---|---|---|---|---| +| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 | + +后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。 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/docs/superpowers/plans/2026-09-12-reading-goals.md b/docs/superpowers/plans/2026-09-12-reading-goals.md new file mode 100644 index 0000000..822ccae --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-reading-goals.md @@ -0,0 +1,18 @@ +# 阅读目标数据库持久化实施计划 + +**目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。 + +**方案:** Flask v2 接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。 + +**技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。 + +## 步骤与验收 + +- [x] 后端:在 `api_v2.py` 增加 GET/POST `/api/v2/goals`、PUT/DELETE `/api/v2/goals/`、POST `/api/v2/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。 +- [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。 +- [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。 +- [x] 边界:缺失书籍仍保留 ID 和目标分母,显示“状态待同步”,允许在编辑器移除;书单来源仅保存名称和加入时书目;日期含起止当天,全部读完优先显示已完成。 +- [x] 文档:同步 `docs/technical-guide.md`、`docs/code-design.md`、`docs/V2_API.md` 及正式页面提示,保留静态 Demo 的独立说明。 +- [x] 验证:使用标准库测试验证非法输入、重复 ID、事务回滚与数据库异常;真实 MySQL 验证新建、重读、编辑、导入和删除,仅操作唯一测试 ID。检查 JavaScript 语法并通过浏览器完成新建、刷新、编辑和清理闭环。 + +不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。 diff --git a/docs/technical-guide.md b/docs/technical-guide.md new file mode 100644 index 0000000..a352567 --- /dev/null +++ b/docs/technical-guide.md @@ -0,0 +1,149 @@ +# 阅间设计 Demo 使用说明 + +## 范围与启动 + +这是独立的静态设计 Demo,不调用微信读书接口,不需要 API Key,也不修改现有 Flask 服务、数据采集脚本和 8080 页面。功能与接口的后续实现进度统一维护在 [功能目标清单](feature-checklist.md)。 + +该 Demo 定位为 2.0 设计探索。当前提交用于保存探索成果,不代表 2.0 正式发布,也不创建 `v2.0` 标签。 + +在项目根目录执行: + +```sh +python3 -m http.server 8765 --bind 127.0.0.1 --directory web-demo +``` + +访问 `http://127.0.0.1:8765`。也可以直接打开 `web-demo/index.html`;推荐通过本地服务器使用,以便浏览器稳定保存目标数据。 + +无需安装前端依赖。页面由以下文件构成: + +| 文件 | 内容 | +|---|---| +| `web-demo/index.html` | 页面外壳、导航、详情与目标编辑弹窗 | +| `web-demo/styles.css` | 暗亮主题、书封示意、响应式排版 | +| `web-demo/demo-data.js` | 书籍、书单、初始目标、笔记与点评样例 | +| `web-demo/app.js` | 页面渲染、目标操作、搜索筛选、导出 | + +## 保存与数据边界 + +目标使用当前来源的 `localStorage` 键 `weread-demo-goals-v2`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。 + +书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。 + +本地存储读取失败时显示示例目标并提示,不自动覆盖原存储;保存失败时保留编辑表单并提示重试。 + +所有阅读数据、书单成员、封面、目录、笔记和点评都是示例。用户编辑的目标只保存在本机,不会写回微信读书。公开书单完整成员获取方式仍需接口验证。 + +## 使用入口 + +- 阅读目标:新建目标,填写日期,勾选书籍或点击书单快捷加入,保存后可再编辑。 +- 我的书架:切换我的书单、有声书、文章收藏与阅读状态。 +- 发现好书:选择搜索类型;右侧预览状态可查看加载、空结果、失败及重试效果。 +- 书籍详情:从卡片进入,切换目录、个人笔记、热门划线、点评、相似推荐。 +- 笔记:按书目和类型筛选,导出当前书目的划线与想法,书签仅显示数量。 + +## 验证 + +语法检查: + +```sh +node --check web-demo/app.js +node --check web-demo/demo-data.js +``` + +交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。 + +## 与 1.0 的关系 + +1.0 继续由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供,运行在 8080 端口。2.0 探索 Demo 位于 `web-demo/`,两者可以独立预览。 + +## 正式看板的紧凑样式 + +正式看板由 `templates/index.html` 和 `static/css/styles.css` 提供,使用现有启动方式,不新增依赖、配置或环境变量。上文的 `web-demo/` 是独立的历史设计 Demo,本次样式调整仅应用于正式看板。 + +刷新正式看板即可加载样式;若浏览器仍显示旧间距,强制刷新页面。检查阅读概览、我的书架、阅读目标在桌面和手机宽度下的展示,并切换暗色主题。目标数据的保存方式由当前应用逻辑决定,样式不参与存储。 + +## 正式看板的阅读目标数据库 + +正式页面的目标通过 `/api/v2/goals` 保存到 MySQL。浏览器只保留编辑草稿和页面状态,刷新或换浏览器后从同一服务读取目标。上文 `web-demo/` 的本地存储说明仅适用于静态 Demo。 + +沿用 `api_v2.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。 + +部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表: + +```sh +python3 - <<'PY' +from pathlib import Path +import pymysql +from api_v2 import MYSQL_CONFIG + +with pymysql.connect(**MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + for sql in Path('migrations/001_reading_goals.sql').read_text().split(';'): + if sql.strip(): + cur.execute(sql) +print('阅读目标表已就绪') +PY +PORT=8080 python3 app.py +``` + +使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.py` 的采集覆盖。 + +## GitHub 自动部署 + +服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、执行阅读目标迁移,并重启 systemd 服务。 + +当前服务器配置可按下列默认值使用: + +```sh +PROJECT_DIR=/opt/weread-board/WeReadBoard +SERVICE_NAME=weread.service +VENV_DIR=/opt/weread-board/WeReadBoard/.venv +``` + +GitHub Actions 文件位于 `.github/workflows/deploy.yml`,默认在 `dev` 分支 push 后触发,也支持手动触发。仓库需要在 `Settings -> Secrets and variables -> Actions -> Secrets` 配置: + +| Secret | 说明 | +|---|---| +| `SERVER_HOST` | 服务器 IP | +| `SERVER_PORT` | SSH 端口,未配置时按 22 处理 | +| `SERVER_USER` | SSH 登录用户 | +| `SERVER_SSH_KEY` | GitHub Actions 登录服务器用的私钥 | + +可选配置放在 `Settings -> Secrets and variables -> Actions -> Variables`: + +| Variable | 说明 | +|---|---| +| `DEPLOY_PATH` | 可选,默认 `/opt/weread-board/WeReadBoard` | +| `SERVICE_NAME` | 可选,默认 `weread.service` | + +如果在服务器手动部署,可执行: + +```sh +cd /opt/weread-board/WeReadBoard +DEPLOY_BRANCH=dev ./deploy.sh +``` + +`deploy.sh` 会优先读取项目根目录 `.env`;没有 `.env` 时,会读取 `weread.service` 当前 `Environment=` 配置。按截图中的 service 写法也能拿到端口、API Key 和数据库配置。若以后改用普通部署用户,需要允许该用户免密执行 `systemctl restart weread.service`。 + +浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals-v2` 原数据保留,`weread-goals-v2-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。 + +保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 ID 已存在,刷新目标列表确认上次请求是否已经保存。数据库不可用或尚未执行迁移时,目标接口返回 503,不退回浏览器保存。 + +验证命令: + +```sh +python3 -m unittest discover -s tests -p 'test_reading_goals*.py' -v +node --test tests/goals_frontend.test.cjs +node --check static/js/app.js +node --check static/js/weapi.js +``` + +真实数据库验证需先执行迁移,再显式运行 `WEREAD_TEST_MYSQL=1 python3 -m unittest discover -s tests -p 'test_reading_goals_mysql.py' -v`。测试创建唯一测试目标并清理,只操作本次生成的 ID。 + +本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。 + +## dev 自动部署 + +`dev` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/dev`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。详细配置见 `docs/deploy-dev.md`。 + +本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 diff --git a/fetch_data.py b/fetch_data.py index 567e8ca..4bca0ac 100644 --- a/fetch_data.py +++ b/fetch_data.py @@ -26,9 +26,10 @@ import time import urllib.request from datetime import datetime, timezone, timedelta +from config_utils import get_env_value + # ===== 配置 ===== API_URL = "https://i.weread.qq.com/api/agent/gateway" -API_KEY = os.environ.get("WEREAD_API_KEY", "") SKILL_VERSION = "1.0.4" DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -54,13 +55,17 @@ def now_str(): def api_call(api_name, **kwargs): """调用微信读书 API""" + api_key = get_env_value("WEREAD_API_KEY", "") + if not api_key: + raise RuntimeError("WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc") + body = {"api_name": api_name, "skill_version": SKILL_VERSION} body.update(kwargs) req = urllib.request.Request( API_URL, data=json.dumps(body).encode("utf-8"), headers={ - "Authorization": f"Bearer {API_KEY}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", @@ -1020,9 +1025,9 @@ def fetch_notebooks(conn): # ===== 主函数 ===== def main(): - if not API_KEY: + if not get_env_value("WEREAD_API_KEY", ""): print("错误: 请先设置 WEREAD_API_KEY 环境变量") - print(" export WEREAD_API_KEY=wrk-xxxxxxxx") + print(" 可配置在当前终端、项目 .env,或 ~/.zshrc") return print("=" * 60) 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/migrate_mysql_to_remote.py b/migrate_mysql_to_remote.py new file mode 100644 index 0000000..1811476 --- /dev/null +++ b/migrate_mysql_to_remote.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""将本地 MySQL 数据迁移到远程 MySQL。 + +默认配置: + 源库:127.0.0.1:3306/weread_dashboard + 目标库:124.222.38.85:13306/weread_dashboard + +密码优先从环境变量读取: + SOURCE_DB_PASSWORD / TARGET_DB_PASSWORD + +脚本不会创建数据库或表;请先在目标 MySQL 执行 schema_mysql.sql。 +目标表会被清空后重新写入,因此执行前请确认目标库中没有需要保留的数据。 +""" + +import argparse +import os +import sys +from typing import Dict, Iterable, List, Tuple + +import pymysql + + +def db_config(prefix: str, defaults: Dict[str, object]) -> Dict[str, object]: + return { + "host": os.getenv(f"{prefix}_DB_HOST", defaults["host"]), + "port": int(os.getenv(f"{prefix}_DB_PORT", defaults["port"])), + "user": os.getenv(f"{prefix}_DB_USER", defaults["user"]), + "password": os.getenv(f"{prefix}_DB_PASSWORD", ""), + "database": os.getenv(f"{prefix}_DB_NAME", defaults["database"]), + "charset": "utf8mb4", + "autocommit": False, + } + + +SOURCE = db_config("SOURCE", { + "host": "127.0.0.1", "port": 3306, "user": "root", "database": "weread_dashboard" +}) +TARGET = db_config("TARGET", { + "host": "124.222.38.85", "port": 13306, "user": "root", "database": "weread_dashboard" +}) + + +def q(identifier: str) -> str: + """安全引用 MySQL 标识符。表名和列名均来自数据库元数据。""" + return "`" + identifier.replace("`", "``") + "`" + + +def tables(conn) -> List[str]: + with conn.cursor() as cur: + cur.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") + return [row[0] for row in cur.fetchall()] + + +def columns(conn, table: str) -> List[str]: + with conn.cursor() as cur: + cur.execute(f"SHOW COLUMNS FROM {q(table)}") + return [row[0] for row in cur.fetchall()] + + +def row_count(conn, table: str) -> int: + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) FROM {q(table)}") + return int(cur.fetchone()[0]) + + +def migrate_table(source, target, table: str, batch_size: int) -> Tuple[int, int]: + source_cols = columns(source, table) + target_cols = set(columns(target, table)) + common = [col for col in source_cols if col in target_cols] + if not common: + raise RuntimeError(f"表 {table} 没有可写入的同名列") + + col_sql = ", ".join(q(col) for col in common) + placeholders = ", ".join(["%s"] * len(common)) + insert_sql = f"INSERT INTO {q(table)} ({col_sql}) VALUES ({placeholders})" + + copied = 0 + with source.cursor() as sc, target.cursor() as tc: + sc.execute(f"SELECT {col_sql} FROM {q(table)}") + while True: + batch = sc.fetchmany(batch_size) + if not batch: + break + tc.executemany(insert_sql, batch) + copied += len(batch) + return copied, len(source_cols) - len(common) + + +def migrate(batch_size: int, only: Iterable[str] = ()) -> None: + source = pymysql.connect(**SOURCE) + target = pymysql.connect(**TARGET) + try: + source_tables = tables(source) + target_tables = set(tables(target)) + selected = list(only) if only else source_tables + missing = [t for t in selected if t not in target_tables] + if missing: + raise RuntimeError("目标库缺少表:" + ", ".join(missing)) + + print(f"源库: {SOURCE['host']}:{SOURCE['port']}/{SOURCE['database']}") + print(f"目标库: {TARGET['host']}:{TARGET['port']}/{TARGET['database']}") + print(f"迁移表数: {len(selected)},批大小: {batch_size}\n") + + with target.cursor() as cur: + cur.execute("SET FOREIGN_KEY_CHECKS = 0") + for table in selected: + before = row_count(source, table) + cur.execute(f"TRUNCATE TABLE {q(table)}") + copied, skipped_cols = migrate_table(source, target, table, batch_size) + target.commit() + after = row_count(target, table) + note = f",跳过源表独有列 {skipped_cols} 个" if skipped_cols else "" + status = "✓" if before == after == copied else "!" + print(f"{status} {table:32s} 源 {before:8d} / 目标 {after:8d}{note}") + cur.execute("SET FOREIGN_KEY_CHECKS = 1") + target.commit() + except Exception: + target.rollback() + raise + finally: + source.close() + target.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description="本地 MySQL → 远程 MySQL 数据迁移") + parser.add_argument("--batch-size", type=int, default=1000) + parser.add_argument("--tables", nargs="*", help="只迁移指定表;不传则迁移源库全部表") + args = parser.parse_args() + if args.batch_size < 1: + parser.error("--batch-size 必须大于 0") + try: + migrate(args.batch_size, args.tables or ()) + print("\n迁移完成。") + return 0 + except pymysql.MySQLError as exc: + print(f"MySQL 错误:{exc}", file=sys.stderr) + return 2 + except Exception as exc: + print(f"迁移失败:{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/migrations/001_reading_goals.sql b/migrations/001_reading_goals.sql new file mode 100644 index 0000000..c50c9b3 --- /dev/null +++ b/migrations/001_reading_goals.sql @@ -0,0 +1,20 @@ +-- 显式执行一次;只创建阅读目标相关表,不修改已有业务表。 +CREATE TABLE IF NOT EXISTS reading_goals ( + id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + name VARCHAR(60) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + sources JSON NOT NULL COMMENT '加入目标时的书单名称,不追踪书单后续变更', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='阅读目标'; + +CREATE TABLE IF NOT EXISTS reading_goal_books ( + goal_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + book_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + PRIMARY KEY (goal_id, book_id), + CONSTRAINT fk_reading_goal_books_goal FOREIGN KEY (goal_id) + REFERENCES reading_goals (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='目标书籍成员;不关联书架表,避免移出书架时丢失目标成员'; diff --git a/requirements.txt b/requirements.txt index 001e7c4..1f9e10e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask>=3.0 +PyMySQL>=1.1 diff --git a/start_dashboard.sh b/start_dashboard.sh new file mode 100755 index 0000000..a63149e --- /dev/null +++ b/start_dashboard.sh @@ -0,0 +1,34 @@ +#!/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")" + +PROJECT_PYTHON="$(pwd)/.venv/bin/python" +PYTHON_BIN="${PYTHON_BIN:-$PROJECT_PYTHON}" +if [ ! -x "$PYTHON_BIN" ]; then + PYTHON_BIN="$HOME/.workbuddy/binaries/python/envs/default/bin/python" +fi +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/start_server.sh b/start_server.sh index 01c7e2e..fa4bbd2 100755 --- a/start_server.sh +++ b/start_server.sh @@ -7,11 +7,17 @@ # ./start_server.sh restart 重启服务 # ./start_server.sh status 查看状态 -PROJECT_DIR="/Users/hwj/WorkBuddy/2026-07-17-09-48-20" -PYTHON="/Users/hwj/.workbuddy/binaries/python/envs/default/bin/python" +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [ -x "$PROJECT_DIR/.venv/bin/python" ]; then + DEFAULT_PYTHON="$PROJECT_DIR/.venv/bin/python" +else + DEFAULT_PYTHON="$(command -v python3 || command -v python)" +fi +PYTHON="${PYTHON:-$DEFAULT_PYTHON}" PID_FILE="$PROJECT_DIR/.server.pid" LOG_FILE="/tmp/weread_server.log" -PORT=8080 +# 默认从 8080 启动;也可通过 PORT 或 WEREAD_PORT 指定端口 +PORT="${PORT:-${WEREAD_PORT:-8080}}" cd "$PROJECT_DIR" || exit 1 @@ -22,10 +28,6 @@ is_running() { return 0 fi fi - # 也检查端口是否被占用 - if lsof -i :$PORT -t >/dev/null 2>&1; then - return 0 - fi return 1 } @@ -37,7 +39,27 @@ get_pid() { return fi fi - lsof -i :$PORT -t 2>/dev/null | head -1 + return 0 +} + +find_free_port() { + while lsof -iTCP:"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1; do + PORT=$((PORT + 1)) + done +} + +get_running_port() { + local pid=$(get_pid) + local running_port + + if [ -n "$pid" ]; then + running_port=$(lsof -Pan -p "$pid" -iTCP -sTCP:LISTEN 2>/dev/null \ + | awk 'NR > 1 {print $9}' \ + | sed -E 's/.*:([0-9]+)$/\1/' \ + | head -1) + fi + + echo "${running_port:-$PORT}" } stop_server() { @@ -58,27 +80,43 @@ stop_server() { start_foreground() { if is_running; then - echo "服务已在运行 (PID: $(get_pid))" + local running_port=$(get_running_port) + echo "服务已在运行 (PID: $(get_pid), 端口: $running_port)" + echo "访问地址: http://127.0.0.1:$running_port" exit 1 fi - echo "启动服务(前台模式)..." - exec "$PYTHON" app.py + if [ -z "$PYTHON" ]; then + echo "未找到可用的 Python 解释器" + exit 1 + fi + echo "启动服务(前台模式,端口: $PORT)..." + echo "访问地址: http://127.0.0.1:$PORT" + PORT="$PORT" WEREAD_PORT="$PORT" exec "$PYTHON" app.py } start_daemon() { + if [ ! -f "$PID_FILE" ]; then + find_free_port + fi if is_running; then - echo "服务已在运行 (PID: $(get_pid))" + local running_port=$(get_running_port) + echo "服务已在运行 (PID: $(get_pid), 端口: $running_port)" + echo "访问地址: http://127.0.0.1:$running_port" exit 1 fi - echo "启动服务(后台守护模式)..." - WEREAD_API_KEY="${WEREAD_API_KEY:-wrk-ZL_oy0woT5yKFaO1jn_15AAA}" \ - nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 & + if [ -z "$PYTHON" ]; then + echo "未找到可用的 Python 解释器" + exit 1 + fi + echo "启动服务(后台守护模式,端口: $PORT)..." + PORT="$PORT" WEREAD_PORT="$PORT" nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 & local pid=$! echo "$pid" > "$PID_FILE" sleep 2 if kill -0 "$pid" 2>/dev/null; then echo "服务已启动 (PID: $pid)" - echo "访问: http://127.0.0.1:$PORT" + echo "端口: $PORT" + echo "访问地址: http://127.0.0.1:$PORT" echo "日志: $LOG_FILE" else echo "启动失败,请查看日志: $LOG_FILE" diff --git a/static/css/style.css b/static/css/style.css index 92e4e86..6d475a0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -748,6 +748,7 @@ body { display: flex; align-items: center; gap: 16px; + flex-wrap: wrap; } .shelf-search { @@ -769,6 +770,7 @@ body { .shelf-refresh-btn { padding: 0 16px; height: 36px; + min-width: 112px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 13px; @@ -781,7 +783,7 @@ body { align-items: center; justify-content: center; gap: 4px; - flex-shrink: 0; + flex: 0 0 112px; white-space: nowrap; } @@ -789,6 +791,15 @@ body { display: inline-block; font-size: 14px; line-height: 1; + flex: 0 0 auto; +} + +.shelf-refresh-btn .btn-text { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 4.5em; + line-height: 1; } .shelf-refresh-btn:hover:not(:disabled) { @@ -801,7 +812,7 @@ body { cursor: wait; } -.shelf-refresh-btn.loading { +.shelf-refresh-btn.is-loading { border-color: var(--accent); color: var(--accent); } @@ -2022,6 +2033,9 @@ body { .shelf-search { width: 140px; } + .shelf-filters { + gap: 10px; + } .shelf-categories { gap: 6px; } diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..8e7063c --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,93 @@ + +: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} +.real-cover{display:block;width:118px;height:168px;object-fit:cover;border-radius:4px;box-shadow:4px 5px 10px #0002;background:var(--quote)} +.mini-real-cover{width:66px;height:92px;flex-shrink:0} +.book-row .mini-real-cover{width:42px;height:58px} +/* 紧凑内容布局:压缩页面介绍,保留正文、书封和操作的可读尺寸。 */ +.topbar{height:58px} +.top-actions{gap:16px} +.demo-banner{margin-top:10px;gap:16px;line-height:1.5} +.welcome{margin:8px 0 10px;gap:14px;align-items:center} +.welcome h1{font-size:23px;line-height:1.35;margin-bottom:3px} +.welcome p{line-height:1.55} +.welcome .eyebrow,.goal-intro .eyebrow,.goal-intro .symbol{display:none} +.page-actions{flex-shrink:0} +.page-actions:empty{display:none} +.toolbar{margin-bottom:10px;gap:8px 16px} +#page-content>.type-chips{margin:0 0 10px;gap:8px} +.toolbar .type-chips{margin:0} +#page-content>.type-chips button,.toolbar .type-chips button{min-height:36px;padding:8px 12px} +.stats{gap:14px;margin-bottom:14px} +.stat{padding:14px 18px;min-width:0} +.stat-value{margin:8px 0} +.compact-value{font-size:24px;overflow-wrap:anywhere} +.columns,.rightcol{gap:16px} +.shelf,.trend,.summary-goals{padding:18px} +.panel{padding:18px} +.section-head{margin-bottom:14px;gap:12px} +.shelf{margin-top:16px} +.books{gap:18px} +.full-books{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:22px 18px} +.book .real-cover{max-width:100%;height:auto;aspect-ratio:118/168} +.book h3{margin:10px 0 4px} +.book p{line-height:1.45} +.book .progress{margin-top:9px} +.summary-goals .status-box{padding:18px 10px;text-align:left} +.summary-goals .status-box h3{margin:6px 0;font-size:13px} +.summary-goals .status-box button{margin-top:10px} +.goal-intro{display:block;padding:12px 16px;margin-bottom:14px} +.goal-intro>div{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:20px;align-items:center} +.goal-intro h2{font-size:16px;margin:0 0 2px} +.goal-intro p{font-size:10px;line-height:1.6} +.goal-intro .metrics-row{grid-column:2;grid-row:1/3;margin:0;padding:0;gap:16px;white-space:nowrap} +.goal-intro .metrics-row span{display:flex;flex-direction:column;gap:2px} +.goal-intro .metrics-row strong{font-size:20px;line-height:1.2;margin:0} +.goals-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;align-items:start} +.goal-card{padding:18px} +.goal-card h3{font-size:17px;margin:10px 0 5px} +.goal-card .cover-stack{margin:14px 0;height:66px} +.goal-card .mini-cover,.goal-card .mini-real-cover{width:47px;height:66px} +.goal-card .progress{margin-bottom:12px} +.goal-card-footer{padding-top:12px;gap:12px;flex-wrap:wrap} +.goal-card-footer button{min-height:32px} +.goal-summary-row{padding:12px 0} +@media(max-width:1500px){.full-books{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:20px 16px}} +@media(max-width:1200px){.full-books{grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:18px 14px}} +@media(max-width:900px){.full-books{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:16px 12px}} +@media(max-width:1150px){ + .goal-intro>div{grid-template-columns:1fr;gap:4px} + .goal-intro .metrics-row{grid-column:auto;grid-row:auto;margin-top:8px;flex-wrap:wrap} + .goal-intro .metrics-row span{display:block} + .goal-intro .metrics-row strong{margin-right:6px} +} +@media(max-width:600px){ + .topbar{height:56px} + .demo-banner{margin-top:8px} + .welcome{align-items:flex-start;gap:12px} + .welcome h1{font-size:20px} + .welcome p{font-size:11px} + .welcome .page-actions{align-self:flex-start} + .primary,.secondary,.iconbtn{min-height:40px} + .stats{gap:12px} + .stat{padding:12px} + .compact-value{font-size:20px} + .shelf,.trend,.summary-goals,.goal-card,.panel{padding:14px} + .full-books{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 12px} + .goal-intro{padding:16px} + .goal-intro .metrics-row{gap:12px} + .goal-intro .metrics-row strong{font-size:22px} + .goal-card .section-head{flex-wrap:wrap} + .goal-card .cover-stack{gap:0} + .goal-card .mini-cover,.goal-card .mini-real-cover{width:44px;height:62px} + .goal-card .cover-stack{height:62px} + .stack-more{margin-left:8px;white-space:nowrap} +} diff --git a/static/js/app.js b/static/js/app.js index daf6e68..5fe3fa2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,1267 +1,813 @@ -/* ===== 微信读书数据展示 - 前端逻辑 ===== */ +/* ========================================================================== + 阅间 · 微信读书看板 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 searchComposing = false, discoverSearchTimer = null; +let recommendOffset = 0; +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, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = []; +let toastTimer, storageWarning = '', goalBusy = false; + +/* 接口数据缓存 */ +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 = [], legacyGoals = []; +try { + const saved = localStorage.getItem(storageKey); + if (saved && !localStorage.getItem(`${storageKey}-imported`)) { + 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'); + legacyGoals = parsed; + } +} catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; } + +/* ------------------------------- 通用组件 ------------------------------- */ + +function toast(message) { + clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden'); + toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500); +} +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('主题已切换,本次浏览有效。'); } +}; + +/* 封面:优先显示微信读书返回的真实封面,加载失败时回退到示意封面 */ +function cover(book, mini = false) { + const short = book.short || book.title || ''; + const rawCoverUrl = book.cover_url || (typeof book.cover === 'string' && /^https?:\/\//i.test(book.cover) ? book.cover : ''); + const coverUrl = rawCoverUrl.replace(/\/(?:t6_|s_)/, '/t8_'); + const coverClass = typeof book.cover === 'string' && !/^https?:\/\//i.test(book.cover) ? book.cover : ''; + const fallback = mini + ? `` + : `
${esc(book.en || '')}${esc(short).replaceAll('\n', '
')}
${esc(book.author)}READING SPACE · 藏书
`; + if (!coverUrl) return fallback; + const className = mini ? 'real-cover mini-real-cover' : 'real-cover'; + return `${esc(book.title)}`; +} +function scoreText(book) { + return book.score ? `评分 ${(Number(book.score) * 10).toFixed(2)}%` : '暂无评分'; +} +function stateText(book) { + if (book.status === 'unknown') return '状态待同步'; + 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', 'discover'].includes(page)) renderPage(); + try { + // 首屏只读取书架基础数据,避免等待逐本补拉进度和评分。 + const data = await WeApi.shelf('all', { progress: false }); + shelfData = data; + books = data.books || []; + booklists = (data.lists || []).map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` })); + loadState.shelf = 'ready'; + const localDiscoverKey = `${searchScope}:books:${searchQuery}`; + if (!searchQuery) { + discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists }; + loadState.discover[localDiscoverKey] = 'ready'; + } + if (['shelf', 'overview', 'goals', 'discover'].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 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(); + // 后台补充评分和阅读进度,完成后静默刷新,不阻塞首屏。 + WeApi.shelf('all', { progress: true }).then(enriched => { + shelfData = enriched; + books = enriched.books || books; + if (enriched.lists) { + booklists = enriched.lists.map(list => ({ ...list, description: list.description || `我的书单 · ${list.book_count} 本` })); + } + if (!searchQuery) { + discoverData[localDiscoverKey] = { local: true, books, authors: [], lists: booklists }; + } + if (['shelf', 'overview', 'goals', 'discover'].includes(page)) renderPage(); + }).catch(() => { + // 评分/进度补充失败时保留已展示的基础书架数据。 }); - loadYearlyTrend(); - loadTimeDistribution(); - loadCategories(); - loadAuthors(); + } catch (error) { + loadError.shelf = error.message; loadState.shelf = 'error'; + } + if (['shelf', 'overview', 'goals'].includes(page)) renderPage(); } -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; - } +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(); } -function showDBAlert(message) { - const banner = document.getElementById("dbAlertBanner"); - const detail = document.getElementById("dbAlertDetail"); - if (banner) banner.style.display = "flex"; - if (detail) detail.textContent = message; +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 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); - } +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 initBackToTop() { - const btn = document.getElementById("backToTop"); - window.addEventListener("scroll", () => { - btn.classList.toggle("visible", window.scrollY > 400); - }); +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(); } -function scrollToTop() { - window.scrollTo({ top: 0, behavior: "smooth" }); +function discoverKey() { + if (discoverTab === 'recommend') return 'recommend'; + if (discoverTab === 'lists') return `${searchScope}:lists`; + return `${searchScope}:${discoverTab}:${searchQuery}`; } - -/** - * 在微信读书中打开书籍。 - * 优先使用 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, refresh = 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') { + discoverData[key] = { local: true, books: books, authors: [], lists: [] }; + } else if (discoverTab === 'recommend') { + discoverData[key] = await WeApi.recommend(recommendOffset, refresh); } 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() { + if (loadState.goals !== 'ready') return goalLoadMessage(); + 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('') || '
书单中的书籍当前不在书架上。
'}
`); +} + +/* ------------------------------- 阅读目标 ------------------------------- */ + +async function ensureGoals() { + if (loadState.goals === 'loading') return; + loadState.goals = 'loading'; loadError.goals = ''; + if (['goals', 'overview'].includes(page)) renderPage(); + try { + const data = await WeApi.goals(); + goals = data.goals; + loadState.goals = 'ready'; + } catch (error) { + loadState.goals = 'error'; loadError.goals = error.message; + } + if (['goals', 'overview'].includes(page)) renderPage(); +} +function goalLoadMessage() { + return loadState.goals === 'error' + ? statusBox('error', '暂时无法读取阅读目标', loadError.goals, 'retry-goals') + : statusBox('loading', '正在读取阅读目标…', '正在加载已保存的阅读清单。'); +} +// 目标列表以数据库为准;读取完成前不打开编辑器,避免加载失败状态覆盖刚保存的目标。 +function requireGoalsReady() { + if (loadState.goals === 'ready') return true; + navigate('goals'); + if (loadState.goals !== 'loading') ensureGoals(); + return false; +} +// 目标成员独立于书架;资料缺失时保留 ID 和分母,不能因同步失败而丢书。 +function goalBook(id) { + return findBook(id) || { id, title: `书籍 ${id}`, author: '暂未获取书籍资料', status: 'unknown', progress: 0 }; +} +function goalMetrics(goal, date = today()) { + const ids = [...new Set(goal.ids)], done = ids.filter(id => goalBook(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(goalBook(id), true)).join('')}${result.total > 4 ? `+ ${result.total - 4} 本` : ''}
${result.done} / ${result.total} 本已读完${result.percent}%
`; +} +function renderGoals() { + if (loadState.goals !== 'ready') return goalLoadMessage(); + if (!shelfData && loadState.shelf !== 'error') 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}个已完成${new Set(goals.flatMap(goal => goal.ids)).size}本不同的书
❧
` + + `${storageWarning ? `

${esc(storageWarning)}

` : ''}${legacyGoals.length ? `

发现此浏览器的 ${legacyGoals.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(goalBook(id))).join('')}
`); +} +function openGoalEditor(id = null, prefill = [], sources = []) { + if (goalBusy) return; + if (!requireGoalsReady()) return; + $('#detail').close(); editingGoalId = id; + // 新建重试沿用同一个 ID,防止响应丢失时重复创建目标。 + draftGoalId = id || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())); + 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 [...draftIds]; } +function renderPicker() { + const query = $('#picker-search').value.trim().toLowerCase(); + if (!shelfData && loadState.shelf !== 'error') ensureShelf(); + const candidates = [...draftIds].map(goalBook).concat(books.filter(book => !draftIds.has(book.id))); + const matched = candidates.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 => goalBook(id).status === 'done').length} 本已读完`; + $('#goal-picker').innerHTML = `${!shelfData ? '

书架尚未同步,已选书籍仍会保留。

' : ''}

快捷加入书单当前书目

${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} 本,可用关键词继续筛选。

` : ''}`; +} +async function saveGoal(event) { + event.preventDefault(); + if (goalBusy) return; + if (loadState.goals !== 'ready') { $('#goal-error').textContent = '阅读目标还在同步,请稍后重试。'; return; } + 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: draftGoalId, name, start, end, ids, sources: [...new Set(draftSources)] }; + goalBusy = true; $('#goal-form').inert = true; $('#goal-error').textContent = '正在保存…'; + try { + const result = await WeApi.saveGoal(goal, Boolean(editingGoalId)); + goals = editingGoalId ? goals.map(g => g.id === goal.id ? result.goal : g) : [result.goal, ...goals]; + $('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存。'); + } catch (error) { + $('#goal-error').textContent = error.message; + } finally { + goalBusy = false; $('#goal-form').inert = false; + } +} +function chooseTarget(ids, sources = []) { + if (loadState.goals !== 'ready') { navigate('goals'); return; } + showDetail('加入阅读目标', `

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

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

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

`).join('') || '
还没有目标,先新建一个。
'}
`); + pendingBooks = ids; pendingSources = sources; +} +async function appendGoal(id) { + if (goalBusy) return; + const goal = goals.find(g => g.id === id); + const next = { ...goal, ids: [...new Set([...goal.ids, ...pendingBooks])], sources: [...new Set([...goal.sources, ...pendingSources])] }; + goalBusy = true; + try { + const result = await WeApi.saveGoal(next, true); + goals = goals.map(g => g.id === id ? result.goal : g); + renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。'); + } catch (error) { + toast(error.message); + } finally { goalBusy = false; } +} +function confirmGoalDelete(id) { + const goal = goals.find(g => g.id === id); + showDetail('删除阅读目标', `

确定删除“${esc(goal.name)}”?删除后无法恢复,书架中的书籍不受影响。

`); +} +async function deleteGoal(id) { + if (goalBusy) return; + goalBusy = true; + try { + await WeApi.deleteGoal(id); + goals = goals.filter(g => g.id !== id); + $('#detail').close(); renderPage(); toast('阅读目标已删除。'); + } catch (error) { + toast(error.message); + } finally { goalBusy = false; } +} +async function importGoals() { + if (goalBusy || !legacyGoals.length) return; + goalBusy = true; + try { + const result = await WeApi.importGoals(legacyGoals); + // 标记迁移完成但保留旧数据,避免删除数据库目标后又从旧备份恢复。 + legacyGoals = []; + try { localStorage.setItem(`${storageKey}-imported`, '1'); } + catch { storageWarning = '旧目标已导入,但浏览器无法记录导入标记;再次导入会保留数据库已有目标。'; } + await ensureGoals(); + toast(`已导入 ${result.imported} 个目标,数据库已有目标已保留,原备份也保留。`); + } catch (error) { + toast(error.message); + } finally { goalBusy = false; if (page === 'goals') renderPage(); } +} + +/* ------------------------------- 笔记与划线 ------------------------------- */ + +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(); + // 默认书籍页直接使用已加载的本地书架,不等待远程书城搜索。 + if (!discoverData[key] && !searchQuery && searchScope === 'shelf') { + discoverData[key] = { local: true, books, authors: [], lists: booklists }; + loadState.discover[key] = 'ready'; + } + 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 = searchScope === 'shelf' ? booklists : (payload.lists || []); + content = renderLists(lists.map(list => ({ ...list, description: list.description || `收录 ${list.book_count || (list.ids || []).length} 本`, source: list.source || (searchScope === 'shelf' ? '我的书单' : '腾讯书城') }))) + (!lists.length && searchScope !== 'shelf' ? '

腾讯书城返回了书单数量,但当前接口没有返回具体书单条目。

' : ''); + } 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 || []; + const remoteLists = payload.lists || []; + content = `

书籍

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

书单

${renderLists(remoteLists)}${!remoteLists.length ? '

腾讯书城未返回具体书单条目。

' : ''}`; + } 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 ? `评分 ${(Number(info.rating) / 10).toFixed(2)}% · ${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; + if (goalBusy && ['new-goal', 'edit-goal', 'close-editor', 'append-goal', 'new-with-selection', 'import-goals', 'delete-goal', 'confirm-delete-goal'].includes(action)) return; + 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 'retry-goals': ensureGoals(); break; + case 'import-goals': importGoals(); break; + case 'delete-goal': confirmGoalDelete(id); break; + case 'confirm-delete-goal': deleteGoal(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 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); 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(); } }); - -// ===== 推荐发现 ===== -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"); - } +$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; }; +$('#goal-name').oninput = () => $('#goal-name').setCustomValidity(''); +$('#goal-form').onsubmit = saveGoal; +$('#picker-search').oninput = renderPicker; +function applyGlobalSearch(value) { + searchQuery = value; + if (!['books', 'all', 'author', 'novel', 'audio', 'fulltext', 'account', 'article'].includes(discoverTab)) discoverTab = 'books'; + navigate('discover'); } +$('#global-search').addEventListener('compositionstart', () => { searchComposing = true; }); +$('#global-search').addEventListener('compositionend', event => { + searchComposing = false; + applyGlobalSearch(event.target.value.trim()); +}); +$('#global-search').oninput = event => { + if (searchComposing || event.isComposing) return; + clearTimeout(discoverSearchTimer); + discoverSearchTimer = setTimeout(() => applyGlobalSearch(event.target.value.trim()), 300); +}; +document.addEventListener('input', event => { + if (event.target.id === 'discover-search') { + if (event.isComposing || searchComposing) return; + const value = event.target.value; + $('#global-search').value = value; + clearTimeout(discoverSearchTimer); + discoverSearchTimer = setTimeout(() => applyGlobalSearch(value), 300); + } +}); +document.addEventListener('compositionstart', event => { + if (event.target.id === 'discover-search') searchComposing = true; +}); +document.addEventListener('compositionend', event => { + if (event.target.id === 'discover-search') { + searchComposing = false; + applyGlobalSearch(event.target.value.trim()); + } +}); +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 => { + if (dialog.id === 'goal-editor' && goalBusy) return; + 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(); +})); +$('#goal-editor').addEventListener('cancel', event => { if (goalBusy) event.preventDefault(); }); -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() { + ensureGoals(); + 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..e08fa54 --- /dev/null +++ b/static/js/weapi.js @@ -0,0 +1,145 @@ +/* ========================================================================== + 阅间 · 数据层(v2) + -------------------------------------------------------------------------- + 把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。 + 设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据, + 这里替换为真实接口调用,字段名与设计稿保持一致。 + ========================================================================== */ +(function (global) { + 'use strict'; + + const BASE = '/api/v2'; + + async function request(path, params, method = 'GET', body) { + 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(), { + method, + headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + 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'); + }, + + goals() { + return request('/goals'); + }, + + saveGoal(goal, editing = false) { + return request(editing ? `/goals/${encodeURIComponent(goal.id)}` : '/goals', null, editing ? 'PUT' : 'POST', goal); + }, + + deleteGoal(id) { + return request(`/goals/${encodeURIComponent(id)}`, null, 'DELETE'); + }, + + importGoals(goals) { + return request('/goals/import', null, 'POST', { goals }); + }, + + /** + * 书架数据 + * @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, refresh = false) { + return request('/recommend', { maxIdx: maxIdx || 0, refresh: refresh ? 1 : 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..4d1a578 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 diff --git a/tests/goals_frontend.test.cjs b/tests/goals_frontend.test.cjs new file mode 100644 index 0000000..1ac9038 --- /dev/null +++ b/tests/goals_frontend.test.cjs @@ -0,0 +1,71 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function app() { + const nodes = new Map(); + const node = selector => { + if (!nodes.has(selector)) nodes.set(selector, { + value: '', textContent: '', innerHTML: '', disabled: false, + classList: { add() {}, remove() {}, toggle() {} }, + setAttribute() {}, addEventListener() {}, setCustomValidity() {}, reportValidity() {}, + showModal() { this.open = true; }, close() { this.open = false; }, + }); + return nodes.get(selector); + }; + const pending = () => new Promise(() => {}); + const context = vm.createContext({ + document: { querySelector: node, querySelectorAll: () => [], addEventListener() {}, documentElement: { dataset: {} } }, + localStorage: { getItem: () => null }, + WeApi: { status: pending, shelf: pending, dashboard: pending, goals: pending }, + crypto: { randomUUID: () => 'test-goal' }, + setTimeout() {}, clearTimeout() {}, console, + }); + vm.runInContext(fs.readFileSync('static/js/app.js', 'utf8'), context); + return { context, node, run: code => vm.runInContext(code, context) }; +} + +test('目标书籍不在当前书架时仍保留总数,并可打开和编辑', () => { + const { run, node } = app(); + run(`loadState.goals = 'ready'; + books = [{id:'known',title:'已读书',status:'done'}]; + goals = [{id:'g',name:'阅读',start:'2026-09-01',end:'2026-09-30',ids:['known','missing'],sources:[]}]; + shelfData = {};`); + assert.equal(run('goalMetrics(goals[0]).total'), 2); + assert.equal(run('goalMetrics(goals[0]).percent'), 50); + run("openGoalDetail('g'); openGoalEditor('g');"); + assert.match(node('#goal-picker').innerHTML, /状态待同步/); + assert.equal(run("selectedGoalBooks().includes('missing')"), true); +}); + +test('数据库保存失败时保留原目标和编辑草稿,允许重试', async () => { + const { context, run, node } = app(); + run(`loadState.goals = 'ready'; + shelfData = {}; books = [{id:'known',title:'一本书',status:'done'}]; + goals = [{id:'g',name:'原名称',start:'2026-09-01',end:'2026-09-30',ids:['known'],sources:[]}]; + openGoalEditor('g');`); + node('#goal-name').value = '新名称'; + context.WeApi.saveGoal = async () => { throw new Error('数据库暂不可用'); }; + await run('saveGoal({preventDefault(){}})'); + assert.equal(run('goals[0].name'), '原名称'); + assert.equal(node('#goal-editor').open, true); + assert.equal(node('#goal-name').value, '新名称'); + assert.match(node('#goal-error').textContent, /数据库暂不可用/); + context.WeApi.saveGoal = async goal => ({ goal }); + await run('saveGoal({preventDefault(){}})'); + assert.equal(run('goals[0].name'), '新名称'); + assert.equal(node('#goal-editor').open, false); +}); + +test('目标列表未完成加载时不打开编辑器', () => { + const { run, node } = app(); + run(`page = 'overview'; + loadState.goals = 'loading'; + shelfData = {}; + books = [{id:'known',title:'一本书',status:'done'}]; + openGoalEditor(null, ['known'], []);`); + assert.notEqual(node('#goal-editor').open, true); + assert.equal(run('page'), 'goals'); + assert.match(node('#page-content').innerHTML, /正在读取阅读目标/); +}); diff --git a/tests/test_reading_goals.py b/tests/test_reading_goals.py new file mode 100644 index 0000000..0663b82 --- /dev/null +++ b/tests/test_reading_goals.py @@ -0,0 +1,164 @@ +"""阅读目标接口回归测试;数据库故障通过连接边界模拟,不访问线上库。""" + +import unittest +from datetime import date +from unittest.mock import MagicMock, patch + +import pymysql +from flask import Flask + +from api_v2 import v2_bp + + +class ReadingGoalsTest(unittest.TestCase): + def setUp(self): + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(v2_bp) + self.client = app.test_client() + self.conn = MagicMock() + self.cur = self.conn.cursor.return_value.__enter__.return_value + self.cur.fetchone.return_value = {"id": "goal-1"} + self.cur.fetchall.return_value = [] + self.cur.rowcount = 1 + self.connect = patch("api_v2.pymysql.connect", return_value=self.conn).start() + self.addCleanup(patch.stopall) + self.goal = { + "id": "goal-1", "name": " 九月阅读 ", + "start": "2026-09-01", "end": "2026-09-30", + "ids": ["unknown-book", "unknown-book", "123"], + "sources": ["科幻书单", "科幻书单"], + } + + def test_create_normalizes_and_commits_unknown_members(self): + response = self.client.post("/api/v2/goals", json=self.goal) + self.assertEqual(response.status_code, 201) + expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]) + self.assertEqual(response.json, {"goal": expected}) + self.conn.commit.assert_called_once() + self.conn.rollback.assert_not_called() + self.conn.close.assert_called_once() + + def test_rejects_invalid_fields_without_writes(self): + invalid = [ + {"id": 123}, {"id": ""}, {"id": "x" * 65}, + {"name": " "}, {"name": "字" * 61}, + {"start": "2026-02-29"}, {"start": "2026-9-01"}, + {"start": "2026-09-31"}, {"end": "2026-08-31"}, + {"ids": []}, {"ids": "123"}, {"ids": [123]}, + {"ids": [""]}, {"ids": ["x" * 65]}, + {"sources": "书单"}, {"sources": [123]}, + ] + for fields in invalid: + with self.subTest(fields=fields): + response = self.client.post("/api/v2/goals", json=dict(self.goal, **fields)) + self.assertEqual(response.status_code, 400) + self.assertTrue(response.json["error"]) + self.cur.execute.assert_not_called() + self.conn.commit.assert_not_called() + + def test_invalid_json_and_body_shape_are_chinese_errors(self): + for body in ("{", "null", "[]"): + response = self.client.post("/api/v2/goals", data=body, content_type="application/json") + self.assertEqual(response.status_code, 400) + self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"])) + + def test_list_preserves_unknown_members_and_formats_dates(self): + self.cur.fetchall.return_value = [ + {"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1), + "end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "unknown-book"}, + {"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1), + "end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"}, + ] + response = self.client.get("/api/v2/goals") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, {"goals": [dict( + self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"] + )]}) + + def test_create_duplicate_returns_conflict(self): + self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry") + response = self.client.post("/api/v2/goals", json=self.goal) + self.assertEqual(response.status_code, 409) + self.conn.commit.assert_not_called() + self.conn.rollback.assert_called_once() + + def test_update_and_delete_missing_goal_return_not_found(self): + self.cur.fetchone.return_value = None + self.cur.rowcount = 0 + for method in (self.client.put, self.client.delete): + response = method("/api/v2/goals/missing", json=dict(self.goal, id="missing")) + self.assertEqual(response.status_code, 404) + self.conn.commit.assert_not_called() + + def test_update_rejects_mismatched_id(self): + response = self.client.put("/api/v2/goals/other", json=self.goal) + self.assertEqual(response.status_code, 400) + self.cur.execute.assert_not_called() + + def test_member_write_failure_rolls_back_parent_change(self): + self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection") + for method, path in ((self.client.post, "/api/v2/goals"), + (self.client.put, "/api/v2/goals/goal-1")): + with self.subTest(path=path): + self.conn.reset_mock() + response = method(path, json=self.goal) + self.assertEqual(response.status_code, 503) + self.conn.rollback.assert_called_once() + self.conn.commit.assert_not_called() + + def test_import_validates_entire_batch_before_writes(self): + response = self.client.post("/api/v2/goals/import", json={ + "goals": [self.goal, dict(self.goal, id="bad", ids=[])] + }) + self.assertEqual(response.status_code, 400) + self.cur.execute.assert_not_called() + + def test_import_skips_existing_and_duplicate_batch_ids(self): + # 第一个 ID 已存在;第二个 ID 首次插入;批内重复项不再写入。 + counts = iter([0, 1]) + def execute(sql, params=None): + self.cur.rowcount = next(counts) + return self.cur.rowcount + self.cur.execute.side_effect = execute + response = self.client.post("/api/v2/goals/import", json={"goals": [ + self.goal, dict(self.goal, id="new"), dict(self.goal, id="new") + ]}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, {"imported": 1}) + self.cur.executemany.assert_called_once() + self.conn.commit.assert_called_once() + + def test_import_second_member_failure_rolls_back_whole_batch(self): + self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")] + response = self.client.post("/api/v2/goals/import", json={ + "goals": [self.goal, dict(self.goal, id="second")] + }) + self.assertEqual(response.status_code, 503) + self.conn.rollback.assert_called_once() + self.conn.commit.assert_not_called() + + def test_database_failures_never_return_successful_empty_data(self): + self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable") + for method, path, body in ( + (self.client.get, "/api/v2/goals", None), + (self.client.post, "/api/v2/goals", self.goal), + (self.client.put, "/api/v2/goals/goal-1", self.goal), + (self.client.delete, "/api/v2/goals/goal-1", None), + (self.client.post, "/api/v2/goals/import", {"goals": [self.goal]}), + ): + with self.subTest(path=path, method=method): + response = method(path, json=body) + self.assertEqual(response.status_code, 503) + self.assertIn("数据库", response.json["error"]) + + def test_commit_and_rollback_connection_failures_still_return_503(self): + self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection") + self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed") + response = self.client.post("/api/v2/goals", json=self.goal) + self.assertEqual(response.status_code, 503) + self.conn.close.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reading_goals_mysql.py b/tests/test_reading_goals_mysql.py new file mode 100644 index 0000000..444e544 --- /dev/null +++ b/tests/test_reading_goals_mysql.py @@ -0,0 +1,66 @@ +"""显式设置 WEREAD_TEST_MYSQL=1 后,验证已迁移数据库;仅清理本测试生成的 ID。""" + +import os +import unittest +from uuid import uuid4 +from unittest.mock import patch + +import pymysql +from flask import Flask +import api_v2 + + +@unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试') +class GoalsMySQLTest(unittest.TestCase): + def test_persistence_import_and_atomic_rollback(self): + app = Flask(__name__) + app.config['TESTING'] = True + app.register_blueprint(api_v2.v2_bp) + client = app.test_client() + ids = [f'test-{uuid4()}' for _ in range(4)] + goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01', + 'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']} + try: + response = client.post('/api/v2/goals', json=goal) + self.assertEqual(response.status_code, 201, response.json) + self.assertEqual(len(response.json['goal']['ids']), 2) + # 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。 + saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']} + self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'}) + changed = dict(goal, name='修改后的测试', ids=['missing-book']) + self.assertEqual(client.put(f'/api/v2/goals/{ids[0]}', json=changed).status_code, 200) + imported = dict(goal, id=ids[1]) + for count in (1, 0): + response = client.post('/api/v2/goals/import', json={'goals': [goal, imported, imported]}) + self.assertEqual(response.json, {'imported': count}) + saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']} + self.assertEqual(saved[ids[0]]['name'], '修改后的测试') + self.assertEqual(saved[ids[0]]['ids'], ['missing-book']) + + insert = api_v2.insert_goal + def fail_second(cur, item, skip_existing=False): + if item['id'] == ids[3]: + raise pymysql.OperationalError(2013, '模拟批次中断') + return insert(cur, item, skip_existing) + + with patch('api_v2.insert_goal', side_effect=fail_second): + response = client.post('/api/v2/goals/import', json={ + 'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]}) + self.assertEqual(response.status_code, 503) + saved = {g['id'] for g in client.get('/api/v2/goals').json['goals']} + self.assertNotIn(ids[2], saved) + self.assertNotIn(ids[3], saved) + self.assertEqual(client.delete(f'/api/v2/goals/{ids[0]}').status_code, 200) + with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],)) + self.assertEqual(cur.fetchone()['n'], 0) + finally: + with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids]) + conn.commit() + + +if __name__ == '__main__': + unittest.main() diff --git a/web-demo/app.js b/web-demo/app.js new file mode 100644 index 0000000..f720af6 --- /dev/null +++ b/web-demo/app.js @@ -0,0 +1,273 @@ +const $ = selector => document.querySelector(selector); +const esc = value => String(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-demo-goals-v2'; +let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week'; +let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready'; +let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all'; +let editingGoalId = null, draftIds = new Set(), draftSources = [], goals = initialGoals.map(goal => ({...goal, ids:[...goal.ids], sources:[...goal.sources]})); +let toastTimer, storageWarning = ''; +// 本地数据损坏时保留原存储并恢复示例;保存前仍要求用户通过有效表单提交。 +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.map(goal => ({...goal,ids:uniqueIds(goal.ids)})); + } +} 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 setTheme(theme) { + document.documentElement.dataset.theme = theme; + $('#theme').innerHTML = ``; + $('#theme').setAttribute('aria-label', `切换至${theme === 'dark' ? '亮' : '暗'}色主题`); +} +try { setTheme(localStorage.getItem('weread-demo-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); } +$('#theme').onclick = () => { + const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme); + try { localStorage.setItem('weread-demo-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); } +}; +function cover(book, mini = false) { + if (mini) return ``; + return `
${book.en}${book.short.replaceAll('\n','
')}
${book.author}READING SPACE · 示意封面
`; +} +function bookCard(book) { + return ``; +} +function bookRow(book, source = '') { + return `
${cover(book,true)}
${book.title}

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

${book.status === 'done' ? '已读完' : `${book.progress}%`}
`; +} +function welcome(title, subtitle, action = '') { + return `
A LITTLE READING, EVERY DAY

${title}

${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('')}
`; +} +// 已读状态与目标日期无关;先按书籍 ID 去重,再计算完成率。 +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)}

${goal.start} — ${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() { + const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成')); + return welcome('把想读,变成读过','几本书、一份书单,或一段想留给阅读的时间。', '')+ + `
YOUR NEXT CHAPTER

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

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

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

我的阅读目标

${goals.slice(0,3).map(goal=>{const m=goalMetrics(goal);return ``;}).join('')}
`; +} +function renderOverview() { + const values = {week:[42,65,33,57,86,71,48],month:[48,54,65,38,70,52,64,75,68,42,46],year:[8,11,14,9,18,15,12,14,27.7],overall:[75,96,128.7]}[period]; + const labels = {week:['一','二','三','四','五','六','日'],month:['1日','2日','3日','4日','5日','6日','7日','8日','9日','10日','11日'],year:['1月','2月','3月','4月','5月','6月','7月','8月','9月'],overall:['2024','2025','2026']}[period]; + const max = Math.ceil(Math.max(...values)/10)*10; + const totalMinutes = values.reduce((a,b)=>a+b,0)*(period==='year'||period==='overall'?60:1); + const duration = `${Math.floor(totalMinutes/60)}小时${Math.round(totalMinutes%60)}分钟`; + const days = {week:7,month:11,year:132,overall:412}[period]; + const naturalDays = {week:7,month:11,year:254,overall:985}[period]; + const units = period==='year'||period==='overall'?'时':'分'; + const stats = [['阅读 / 收听时长',duration,'当前所选周期','clock'],['有效阅读天数',`${days}天`,'单日满 1 分钟计一天','flag'],['自然日均阅读',`${Math.round(totalMinutes/naturalDays)}分钟`,'按周期自然日计算','chart'],['笔记总数','10条','划线 2 · 想法 2 · 书签 6','note']]; + return welcome('在字里行间,遇见自己','阅读、目标与灵感,在这里慢慢积累。','')+ + `
${tabs([['week','本周'],['month','本月'],['year','本年'],['overall','总计']],period,'period')}统计示例 · ${period==='overall'?'2024—2026':'2026 年'}
${stats.map(([label,value,hint,icon])=>`
${label}
${value}
${hint}
`).join('')}
+

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

${period==='year'?'按月':period==='overall'?'按年':'按日'}
${duration}阅读与收听合计
${[max,Math.round(max*2/3),Math.round(max/3),0].map(n=>``).join('')}
${values.map((v,i)=>``).join('')}

继续阅读

${books.filter(b=>b.status==='reading').map(bookCard).join('')}
+

读得最多

${books.slice(0,3).map((b,i)=>`
${i+1}. ${b.title}${[2,1,1][i]}小时${[12,35,6][i]}分钟
`).join('')}

偏爱的阅读方向

${[['文学',80],['心理学',60],['科幻',40]].map(([label,val])=>`
${label}相对偏好 ${val}
`).join('')}

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

留给自己的时段

${Array.from({length:24},(_,i)=>``).join('')}
06:0018:00次日 05:00

偏好夜间阅读 · 示例时段分布

+
更多阅读画像 · 作者、听书、勋章与年度回顾

偏好作者与出版方

刘慈欣3 本 · 5小时30分钟
示例出版社4 本
示例版权方3 本

阅读与收听

文字阅读80%

示例分布;正式接入只在接口返回时展示。

阅读足迹

好友排行本周第 3 · 示例
勋章初读 · 笔记 · 坚持
`; +} +function listCard(list, index) { + return ``; +} +function renderLists(lists) { return `
${lists.map(listCard).join('') || '
没有匹配的书单,换个关键词试试。
'}
`; } +function renderShelf() { + let content; + if (shelfTab==='lists') content=renderLists(booklists.filter(l=>l.source==='我的书单')); + else if (shelfTab==='audio') content=`
♫

晚间听书 · 文学选读

示例演播者 · 24 集 · 内容已完结

内容完结不代表已听完;当前不纳入按本阅读目标。

`; + else if(shelfTab==='articles') content=`
▤

文章收藏

这是书架中的收藏入口。

当前书架接口不提供入口内文章列表;可在发现页搜索文章。

`; + else content=`
${books.filter(b=>shelfTab==='all'||b.status===shelfTab).map(bookCard).join('')}
`; + return welcome('我的书架','8 本电子书 · 1 个有声专辑 · 1 个文章收藏入口,共 10 个书架条目。')+tabs([['all','全部电子书 8'],['reading','在读 4'],['done','已读完 2'],['wish','想读 2'],['lists','我的书单 2'],['audio','有声书 1'],['articles','文章收藏']],shelfTab,'shelf-tab')+content; +} +function openList(id) { + const list=booklists.find(l=>l.id===id); + showDetail('书单详情',`
A COLLECTION OF GOOD BOOKS

${list.name}

${list.description}

${list.source} · ${list.ids.length} 本 · 示例成员

${list.ids.map(id=>bookRow(findBook(id))).join('')}
${list.source.startsWith('公开')?'

公开书单成员为设计示意,完整成员获取方式待真实接口验证。

':''}`); +} +function showDetail(title, html) { $('#detail-title').textContent=title;$('#detail-content').innerHTML=html;if(!$('#detail').open)$('#detail').showModal(); } +function openGoalDetail(id) { + const goal=goals.find(g=>g.id===id), m=goalMetrics(goal); + showDetail('阅读目标',`
${m.status}${goal.start} — ${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(); + const list=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(query)); + $('#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(b=>b.id===id))).map(l=>``).join('')}
${list.map(b=>``).join('')||'
没有匹配的书籍
'}
`; +} +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(endg.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)}

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

`).join('')}
`); + pendingBooks=ids;pendingSources=sources; +} +let pendingBooks=[],pendingSources=[]; +function appendGoal(id) { + const next=goals.map(g=>g.id===id?{...g,ids:uniqueIds([...g.ids,...pendingBooks]),sources:[...new Set([...g.sources,...pendingSources])]}:g); + try { localStorage.setItem(storageKey,JSON.stringify(next)); } catch { toast('无法保存到浏览器,请允许本地存储后重试。');return; } + goals=next;renderPage();openGoalDetail(id);toast('已加入目标,重复书籍已自动去重。'); +} +function renderDiscover() { + const options=[['books','书籍'],['lists','书单'],['recommend','为你推荐'],['all','综合'],['author','作者'],['novel','网文'],['audio','听书'],['fulltext','全文'],['account','公众号'],['article','文章']]; + const matched=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(searchQuery.toLowerCase())); + let content=''; + if(dataState!=='ready') { + const copy={loading:['正在寻找好书','加载状态示意,点击下方按钮查看结果。'],empty:['暂时没有找到','换个关键词,或看看为你推荐。'],error:['暂时无法获取数据','连接失败示意,你的阅读目标仍保存在本地。']}[dataState]; + content=`
${dataState==='loading'?'LOADING':dataState==='error'?'TRY AGAIN':'NOT FOUND'}

${copy[0]}

${copy[1]}

`; + } else if(discoverTab==='lists') content=renderLists(booklists.filter(l=>`${l.name}${l.description}`.includes(searchQuery))); + else if(discoverTab==='books'||discoverTab==='recommend') content=`${discoverTab==='recommend'?'
PICKED FOR YOUR NEXT CHAPTER

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

根据你对文学与心理学的兴趣推荐 · 推荐理由为示例

':''}
${(discoverTab==='recommend'?matched.filter(b=>b.status==='wish'||b.id==='b0'):matched).map(bookCard).join('')||'
没有找到相关书籍,试试其他书名或作者。
'}
`; + else if(discoverTab==='author') content=`
${[...new Set(matched.map(b=>b.author))].map(name=>`
${name.slice(0,1)}
${name}

作者结果 · 示例

`).join('')||'
没有匹配的作者。
'}
`; + else if(discoverTab==='all') content=`

书籍

${matched.slice(0,4).map(bookCard).join('')||'
没有匹配的书籍。
'}

书单

${renderLists(booklists.filter(l=>l.name.includes(searchQuery)))}`; + else { + const examples={novel:['星河来信','网文小说 · 示例作品','连载中 · 128 章'],audio:['夜晚的文学课','有声专辑 · 示例演播者','24 集 · 内容已完结'],fulltext:['关于阅读的一段文字','全文搜索 · 原创示例文本','阅读让日常拥有了更宽阔的边界。'],account:['一页书房','公众号 · 示例账号','记录阅读、日常与思考'],article:['如何为自己留一点阅读时间','文章 · 原创示例','2026-09-11 · 一页书房']}[discoverTab]; + const matches=!searchQuery||examples.join('').includes(searchQuery); + content=matches?`
${discoverTab==='audio'?'♫':'▤'}

${examples[0]}

${examples[1]}

${examples[2]}

`:'
没有匹配的演示内容,可清空关键词查看该类型示意。
'; + } + return welcome('下一本,遇见什么?','寻找一本书,也发现一份恰好适合你的书单。')+ + `
${tabs(options,discoverTab,'discover-tab')}
${content}

搜索、推荐、书单均为设计样例,未请求微信读书服务。

`; +} +function renderNotes() { + const list=personalNotes.filter(note=>(noteBook==='all'||note.bookId===noteBook)&&(noteType==='all'||note.type===noteType)); + const counts=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook); + const bookmarks=noteBook==='all'?6:noteBook==='b0'?3:noteBook==='b1'?2:noteBook==='b2'?1:0; + return welcome('把读过的,留在心里','划线、想法与每一次停顿,都有自己的意义。','')+ + `
${counts.length+bookmarks}条笔记划线 ${counts.filter(n=>n.type==='highlight').length}想法 ${counts.filter(n=>n.type==='thought').length}书签 ${bookmarks}
${tabs([['all','全部内容'],['highlight','划线'],['thought','想法 / 个人点评'],['bookmark','书签数量']],noteType,'note-type')}${noteType==='bookmark'?`
${bookmarks} 个书签 · 当前接口只提供数量,不提供书签内容。
`:list.map(note=>`
${note.type==='thought'?'我的想法':'划线示意'}
${note.text}
《${findBook(note.bookId).title}》 · ${note.date}

原创示例文本,非书中原文

`).join('')||'
当前筛选没有笔记。
'}

总笔记数包含书签;导出包含划线和个人想法,不含公开点评及书签内容。

`; +} +function exportNotes() { + // 导出按当前选中书目取划线和想法全集,内容类型筛选不影响导出完整性。 + const notes=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook); + const text='# 阅间笔记 · 演示数据\n\n原创示例,非书中原文。书签仅提供数量,不导出内容。\n\n'+notes.map(n=>`## ${findBook(n.bookId).title}\n\n${n.type==='thought'?'想法':'划线示意'} · ${n.date}\n\n${n.text}`).join('\n\n'); + const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'})); + showDetail('导出笔记', `

已生成当前书目的划线与想法。可下载 Markdown,或直接选中下方内容复制;不含书签内容。

`); + $('#detail').addEventListener('close',()=>URL.revokeObjectURL(url),{once:true}); +} +function openBook(id) { detailBook=id;bookTab='info';selectedChapter='all';renderBookDetail(); } +function renderBookDetail() { + const book=findBook(detailBook); + showDetail('书籍详情',`
${cover(book)}
READING SPACE / BOOK

${book.title}

${book.author}

评分 ${Math.round(Number(book.score)*10)}/100 · 示例评分

${book.status==='done'?'已读完':`已读 ${book.progress}%`} · 累计 2小时18分钟

${[['info','简介'],['chapters','目录'],['notes','我的笔记'],['popular','热门划线'],['reviews','读者点评'],['similar','相似推荐']].map(([id,label])=>``).join('')}
${renderBookTab(book)}

全部为设计示例 · 目录、文字和读者资料非真实接口内容。

`); +} +function renderBookTab(book) { + if(bookTab==='info')return `

${book.desc}

分类:${book.id==='b1'?'科幻':'文学 / 人文'} · 示例
最近阅读:2026-09-11
出版社:示例出版社
译者 / ISBN:暂无演示数据
字数:约 20 万字 · 示例
出版日期:暂无演示数据
`; + if(bookTab==='chapters')return `

目录结构示意;选择章节可查看该章热门划线与热度。

${['序章 · 故事的开始','第一章 · 在旅途中','第二章 · 看见新的世界','第三章 · 留给自己的答案'].map((name,i)=>`
${String(i+1).padStart(2,'0')}   ${name} · 示例章节
`).join('')}`; + if(bookTab==='notes') { + const notes=personalNotes.filter(n=>n.bookId===book.id); + return notes.map(n=>`
${n.type==='thought'?'个人想法':'划线示意'}
${n.text}
${n.date} · 原创示例文本
`).join('')||'
这本书暂无个人笔记示例。
'; + } + if(bookTab==='popular')return `
${selectedChapter==='all'?'全书':`示例章节 ${Number(selectedChapter)+1}`} · 热门划线最多展示 20 条
${['阅读让我们有机会,在自己的生活之外再生活一次。','那些认真思考过的问题,会在未来的某一天回应你。'].map((text,i)=>`
${[1286,862][i]} 人划线 · 示例热度
${text}
原创示例文本,非书中原文
`).join('')}

章节热度提供人数与位置,原文来自热门划线;这里用示例文本说明展示方式。

`; + if(bookTab==='reviews')return tabs([['全部','全部'],['推荐','推荐'],['一般','一般'],['不行','不行'],['最新','最新']],reviewFilter,'review-filter')+publicReviews.filter(r=>reviewFilter==='全部'||reviewFilter==='最新'||r.type===reviewFilter).map(review=>`
${review.name} · 示例读者${'★'.repeat(review.rating)}${'☆'.repeat(5-review.rating)}

${review.text}

${review.date} · 公开点评示例
`).join(''); + return `

相似推荐示例 · 根据主题与阅读兴趣推荐

${books.filter(b=>b.id!==book.id).slice(0,4).map(bookCard).join('')}
`; +} +function openThought(text, subtitle) { showDetail('想法详情',`
示例内容
${esc(text)}
${esc(subtitle)} · 2026-09-11

这里展示单条想法的完整内容;不生成未经接口提供的评论或点赞列表。

`); } +function handleAction(button) { + const action=button.dataset.action,id=button.dataset.id; + switch(action) { + case 'page':navigate(id);break; + case 'new-goal':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);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);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;renderPage();break; + case 'period':period=id;renderPage();break; + case 'discover-tab':discoverTab=id;renderPage();break; + case 'retry':dataState='ready';renderPage();break; + case 'notebook':noteBook=id;renderPage();break; + case 'note-type':noteType=id;renderPage();break; + case 'export-notes':exportNotes();break; + case 'personal-note':{const note=personalNotes.find(n=>n.id===id);openThought(note.text,findBook(note.bookId).title+' · 个人笔记示例');break;} + case 'book-tab':bookTab=id;renderBookDetail();break; + case 'chapter':selectedChapter=id;bookTab='popular';renderBookDetail();break; + case 'review-filter':reviewFilter=id;renderBookDetail();break; + case 'review-detail':{const r=publicReviews.find(r=>r.id===id);openThought(r.text,r.name+' · 公开点评示例');break;} + case 'highlight-thoughts':showDetail('划线下的想法',`
${id==='0'?'阅读让我们有机会,在自己的生活之外再生活一次。':'那些认真思考过的问题,会在未来的某一天回应你。'}

原创划线示意

山间来信 · 示例读者2026-09-11

这句话让我想起,每一段阅读经历都有自己的时间。

仅展示一条想法示例,真实内容需分页获取。

`);break; + case 'thought-detail':openThought('这句话让我想起,每一段阅读经历都有自己的时间。','山间来信 · 公开想法示例');break; + case 'author-books':searchQuery=button.dataset.name;discoverTab='books';$('#global-search').value=searchQuery;renderPage();break; + case 'article-search':searchQuery='';discoverTab='article';navigate('discover');break; + case 'search-example':openThought('这是此类搜索结果的详情布局示意。实际内容、可跳转地址及字段以未来接口返回为准。','搜索结果 · '+id);break; + case 'year-report':showDetail('年度回顾',`
2026 / A YEAR IN BOOKS

翻过的每一页,都算数。

128小时42分钟 · 132 个阅读日 · 示例年度数据

偏爱文学,习惯在夜晚阅读。每一次阅读,都是给自己的时间。

年报布局示意,真实年度报告入口只使用服务端返回的链接。

`);break; + } +} +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);} +}); +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==='data-state'){dataState=event.target.value;renderPage();} + if(event.target.id==='chapter-filter'){selectedChapter=event.target.value;renderBookDetail();} +}); +$('#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();dataState='ready';if(page!=='discover')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();}}); +// 原生 dialog 提供焦点管理与 Escape 关闭;只有点击实际遮罩时才关闭。 +document.querySelectorAll('dialog').forEach(dialog=>dialog.addEventListener('click',event=>{ + const rect=dialog.getBoundingClientRect();if(event.target===dialog&&(event.clientXrect.right||event.clientYrect.bottom))dialog.close(); +})); +navigate('overview');if(storageWarning)toast(storageWarning); diff --git a/web-demo/demo-data.js b/web-demo/demo-data.js new file mode 100644 index 0000000..ea9740a --- /dev/null +++ b/web-demo/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/web-demo/index.html b/web-demo/index.html new file mode 100644 index 0000000..f390a29 --- /dev/null +++ b/web-demo/index.html @@ -0,0 +1,16 @@ + + + + +阅间 · 微信读书看板 + + + + + +
L
设计预览 · 全部为示例数据目标保存在当前浏览器
阅间 · 与好书相伴,与自己相遇独立设计 Demo · 未连接微信读书账号
+

书籍详情

+
MAKE ROOM FOR READING

新建阅读目标

想读些什么?

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

+ \ No newline at end of file diff --git a/web-demo/styles.css b/web-demo/styles.css new file mode 100644 index 0000000..c40ae2d --- /dev/null +++ b/web-demo/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/weread b/weread new file mode 100755 index 0000000..6fbb375 --- /dev/null +++ b/weread @@ -0,0 +1,102 @@ +#!/bin/bash +# weread - 微信读书数据看板 服务管理命令 +# 用法: +# weread start [port] 启动服务(默认端口 8080) +# weread stop [port] 停止服务(默认端口 8080) + +PROJECT_DIR="/Users/hwj/Codes/MyProjects/WeReadBoard" +PID_FILE="$PROJECT_DIR/.server.pid" +LOG_FILE="/tmp/weread_server.log" +DEFAULT_PORT=8080 + +get_port() { + echo "${1:-$DEFAULT_PORT}" +} + +get_pid() { + local port="$1" + if [ -f "$PID_FILE" ]; then + local saved_port + saved_port=$(sed -n '2p' "$PID_FILE" 2>/dev/null) + local pid + pid=$(head -1 "$PID_FILE" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "$pid" + return + fi + fi + lsof -i :"$port" -t 2>/dev/null | head -1 +} + +is_running() { + local port="$1" + if [ -n "$(get_pid "$port")" ]; then + return 0 + fi + return 1 +} + +cmd_start() { + local port + port=$(get_port "$1") + + if is_running "$port"; then + echo "❌ 服务已在运行 (PID: $(get_pid "$port"), 端口: $port)" + exit 1 + fi + + echo "启动服务(端口: $port)..." + WEREAD_PORT="$port" nohup python3 "$PROJECT_DIR/app.py" > "$LOG_FILE" 2>&1 & + local pid=$! + printf '%s\n%s\n' "$pid" "$port" > "$PID_FILE" + sleep 2 + + if kill -0 "$pid" 2>/dev/null; then + echo "✅ 服务已启动" + echo " PID: $pid" + echo " 端口: $port" + echo " 访问: http://127.0.0.1:$port" + echo " 日志: $LOG_FILE" + else + echo "❌ 启动失败,请查看日志: $LOG_FILE" + cat "$LOG_FILE" 2>/dev/null + rm -f "$PID_FILE" + exit 1 + fi +} + +cmd_stop() { + local port + port=$(get_port "$1") + + if ! is_running "$port"; then + echo "服务未运行(端口: $port)" + exit 0 + fi + + local pid + pid=$(get_pid "$port") + echo "停止服务 (PID: $pid, 端口: $port)..." + kill "$pid" 2>/dev/null + sleep 1 + + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null + fi + + rm -f "$PID_FILE" + echo "✅ 已停止" +} + +usage() { + echo "用法:" + echo " weread start [port] 启动服务(默认端口 8080)" + echo " weread stop [port] 停止服务(默认端口 8080)" + exit 1 +} + +case "${1}" in + start) cmd_start "$2" ;; + stop) cmd_stop "$2" ;; + *) usage ;; +esac