diff --git a/api_v2.py b/api_v2.py index 2c728a0..72cc365 100644 --- a/api_v2.py +++ b/api_v2.py @@ -21,11 +21,13 @@ import time import urllib.error import urllib.request from concurrent.futures import ThreadPoolExecutor -from datetime import datetime, timedelta, timezone +from contextlib import suppress +from datetime import date, datetime, timedelta, timezone from functools import wraps import pymysql -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, make_response, request +from werkzeug.exceptions import BadRequest v2_bp = Blueprint("v2", __name__) @@ -621,6 +623,173 @@ def _ts(date_text): 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}) + + # ====================================================================== # 接口:连接状态 # ====================================================================== diff --git a/docs/V2_API.md b/docs/V2_API.md index 6be952d..07d34b6 100644 --- a/docs/V2_API.md +++ b/docs/V2_API.md @@ -13,9 +13,9 @@ v1 的 `/api/*` 接口保持不变,两套接口可同时使用。 | 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 | | 书籍详情 | 实时 `/book/info`、`/book/getprogress`、`/book/chapterinfo`、`/book/bestbookmarks`、`/review/list`、`/book/similar` | 逐项降级,单项失败不影响整页 | | 搜索 / 推荐 | 实时 `/store/search`(按 scope 切换类型)、`/book/recommend` | | -| 阅读目标 | 浏览器 localStorage(key `weread-goals-v2`) | 与设计稿一致,不落库 | +| 阅读目标 | MySQL `reading_goals` / `reading_goal_books` | 数据库持久化;旧浏览器目标可手动导入 | -MySQL 不可用时接口仍可用(返回 `degraded: true`),依赖本地缓存的字段会退化为实时接口结果。 +MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。 ## 接口清单 @@ -38,6 +38,28 @@ MySQL 不可用时接口仍可用(返回 `degraded: true`),依赖本地缓 ## 与微信读书接口的字段口径 +### 阅读目标接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| 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」。 diff --git a/docs/code-design.md b/docs/code-design.md index 0223520..e5917e2 100644 --- a/docs/code-design.md +++ b/docs/code-design.md @@ -48,3 +48,29 @@ 书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 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/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 index 36b457f..e183ea6 100644 --- a/docs/technical-guide.md +++ b/docs/technical-guide.md @@ -55,3 +55,54 @@ node --check web-demo/demo-data.js ## 与 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` 的采集覆盖。 + +浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 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 宽,概览、书架和目标页均未出现横向溢出。 + +本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 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/static/css/styles.css b/static/css/styles.css index ba07797..8e7063c 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -4,7 +4,7 @@ *{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}} +@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} @@ -12,3 +12,82 @@ .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 ef54aab..5fe3fa2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -24,8 +24,8 @@ 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, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = []; -let toastTimer, storageWarning = ''; +let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = []; +let toastTimer, storageWarning = '', goalBusy = false; /* 接口数据缓存 */ let books = []; @@ -43,15 +43,15 @@ let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存 const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} }; const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} }; -let goals = []; +let goals = [], legacyGoals = []; try { const saved = localStorage.getItem(storageKey); - if (saved) { + 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'); - goals = parsed; + legacyGoals = parsed; } -} catch { storageWarning = '本地目标暂时无法读取,已重置为空白;原存储未修改。'; } +} catch { storageWarning = '浏览器旧目标无法读取,原备份已保留。数据库目标不受影响。'; } /* ------------------------------- 通用组件 ------------------------------- */ @@ -59,11 +59,6 @@ function toast(message) { clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden'); toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500); } -function persistGoals(next) { - try { localStorage.setItem(storageKey, JSON.stringify(next)); } - catch { $('#goal-error').textContent = '浏览器无法保存数据,请允许本地存储后重试。'; return false; } - goals = next; return true; -} function statusBox(kind, title, text, action) { const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO'; const button = action ? `` : ''; @@ -97,6 +92,7 @@ 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}%` : '在读'; @@ -274,6 +270,7 @@ function renderTrend(data) { } function summaryGoals() { + if (loadState.goals !== 'ready') return goalLoadMessage(); return `

我的阅读目标

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

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

'}
`; } @@ -365,29 +362,64 @@ function openList(id) { /* ------------------------------- 阅读目标 ------------------------------- */ +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 = uniqueIds(goal.ids), done = ids.filter(id => findBook(id).status === 'done').length; + 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(findBook(id), true)).join('')}${result.total > 4 ? `+ ${result.total - 4} 本` : ''}
${result.done} / ${result.total} 本已读完${result.percent}%
`; + 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 (!shelfData) ensureShelf(); + 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}个已完成${uniqueIds(goals.flatMap(goal => goal.ids)).length}本不同的书
❧
` + + `
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(findBook(id))).join('')}
`); + 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 : ''; @@ -395,34 +427,83 @@ function openGoalEditor(id = null, prefill = [], sources = []) { $('#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 selectedGoalBooks() { return [...draftIds]; } function renderPicker() { const query = $('#picker-search').value.trim().toLowerCase(); - if (!shelfData) { $('#goal-picker').innerHTML = '

正在同步书架…

'; ensureShelf(); return; } - const matched = books.filter(book => `${book.title}${book.author}${book.category}`.toLowerCase().includes(query)); + 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 => findBook(id).status === 'done').length} 本已读完`; - $('#goal-picker').innerHTML = `

快捷加入书单当前书目

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

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

` : ''}`; + $('#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} 本,可用关键词继续筛选。

` : ''}`; } -function saveGoal(event) { +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: editingGoalId || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())), name, start, end, ids, sources: [...new Set(draftSources)] }; - const next = editingGoalId ? goals.map(g => (g.id === editingGoalId ? goal : g)) : [goal, ...goals]; - if (!persistGoals(next)) return; - $('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存,已读完的书已计入完成。'); + 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; } -function appendGoal(id) { - const next = goals.map(g => (g.id === id ? { ...g, ids: uniqueIds([...g.ids, ...pendingBooks]), sources: [...new Set([...g.sources, ...pendingSources])] } : g)); - if (!persistGoals(next)) { toast('无法保存到浏览器,请允许本地存储后重试。'); return; } - renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。'); +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(); } } /* ------------------------------- 笔记与划线 ------------------------------- */ @@ -600,11 +681,16 @@ async function openHighlightThoughts(bookId, chapterUid, range) { 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; @@ -709,13 +795,16 @@ document.addEventListener('compositionend', event => { }); 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 boot() { + ensureGoals(); renderPage(); await Promise.all([ensureStatus(), ensureShelf()]); ensureDashboard(period); diff --git a/static/js/weapi.js b/static/js/weapi.js index 80c00e4..e08fa54 100644 --- a/static/js/weapi.js +++ b/static/js/weapi.js @@ -10,12 +10,16 @@ const BASE = '/api/v2'; - async function request(path, params) { + 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(), { headers: { Accept: 'application/json' } }); + 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(); @@ -56,6 +60,22 @@ 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 diff --git a/templates/index.html b/templates/index.html index b7e4462..4d1a578 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,7 +10,7 @@ -
L
正在连接微信读书…阅读目标保存在当前浏览器
阅间 · 与好书相伴,与自己相遇数据来自微信读书个人账号
+
L
正在连接微信读书…阅读目标跨浏览器保存
阅间 · 与好书相伴,与自己相遇数据来自微信读书个人账号

书籍详情

-
MAKE ROOM FOR READING

新建阅读目标

想读些什么?

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

+
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()