feat(reading-goals): 持久化阅读目标并收紧看板布局

This commit is contained in:
admin committed 2026-09-12 17:16:40 +08:00
1 parent 61d3951905
commit 094e1aca3d
13 files changed
+833 -38

No files matched your search

+171 -2
View File
@@ -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/<goal_id>", 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/<goal_id>", 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})
# ======================================================================
# 接口:连接状态
# ======================================================================
+24 -2
View File
@@ -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/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` |
| DELETE | `/api/v2/goals/<id>` | 删除目标及其成员,返回 `{"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」。
+26
View File
@@ -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}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。
@@ -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/<id>`、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 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
+51
View File
@@ -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 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
+20
View File
@@ -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='目标书籍成员;不关联书架表,避免移出书架时丢失目标成员';
+80 -1
View File
@@ -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);coloLine truncated
/* 阅读目标与内容页:与既有主题变量共用色彩。 */
: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:10pLine truncated
@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}
}
+118 -29
View File
@@ -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 ? `<button class="secondary" data-action="${action}">${kind === 'error' ? '重试' : '继续'}</button>` : '';
@@ -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 `<section class="card panel summary-goals"><div class="section-head"><h2>我的阅读目标</h2><button class="text-button" data-action="page" data-id="goals">全部 ↗</button></div>${goals.slice(0, 3).map(goal => { const m = goalMetrics(goal); return `<button class="goal-summary-row" data-action="goal-detail" data-id="${esc(goal.id)}"><strong>${esc(goal.name)}</strong><small>${m.done} / ${m.total} 本已读完 · ${m.status}</small><div class="progress" style="--progress:${m.percent}%"><i></i></div></button>`; }).join('') || '<p class="hint">还没有阅读目标,创建一份属于你的清单。</p>'}<button class="text-button section-space" data-action="new-goal">+ 新建阅读目标</button></section>`;
}
@@ -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 `<article class="card goal-card"><div class="section-head"><span class="pill ${result.status === '已完成' ? 'green' : result.status === '已到期' ? 'amber' : ''}">${result.status}</span><button class="text-button" data-action="edit-goal" data-id="${esc(goal.id)}" aria-label="编辑${esc(goal.name)}">编辑 ↗</button></div><h3>${esc(goal.name)}</h3><div class="goal-date">${esc(goal.start)} — ${esc(goal.end)}</div><div class="cover-stack">${goal.ids.slice(0, 4).map(id => cover(findBook(id), true)).join('')}${result.total > 4 ? `<span class="stack-more">+ ${result.total - 4} 本</span>` : ''}</div><div class="goal-progress"><span><strong>${result.done}</strong> / ${result.total} 本已读完</span><span>${result.percent}%</span></div><div class="progress" style="--progress:${result.percent}%"><i></i></div><div class="goal-card-footer"><small>${goal.sources.length ? `来自 ${goal.sources.length} 个书单 · 可混合单书` : '按本选书 · 自己定义节奏'}</small><button data-action="goal-detail" data-id="${esc(goal.id)}">查看清单 →</button></div></article>`;
return `<article class="card goal-card"><div class="section-head"><span class="pill ${result.status === '已完成' ? 'green' : result.status === '已到期' ? 'amber' : ''}">${result.status}</span><button class="text-button" data-action="edit-goal" data-id="${esc(goal.id)}" aria-label="编辑${esc(goal.name)}">编辑 ↗</button></div><h3>${esc(goal.name)}</h3><div class="goal-date">${esc(goal.start)} — ${esc(goal.end)}</div><div class="cover-stack">${goal.ids.slice(0, 4).map(id => cover(goalBook(id), true)).join('')}${result.total > 4 ? `<span class="stack-more">+ ${result.total - 4} 本</span>` : ''}</div><div class="goal-progress"><span><strong>${result.done}</strong> / ${result.total} 本已读完</span><span>${result.percent}%</span></div><div class="progress" style="--progress:${result.percent}%"><i></i></div><div class="goal-card-footer"><small>${goal.sources.length ? `来自 ${goal.sources.length} 个书单 · 可混合单书` : '按本选书 · 自己定义节奏'}</small><button data-action="goal-detail" data-id="${esc(goal.id)}">查看清单 →</button></div></article>`;
}
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('把想读,变成读过', '几本书、一份书单,或一段想留给阅读的时间。', '<button class="primary" data-action="new-goal">+ 新建阅读目标</button>') +
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(goal => goalMetrics(goal).status === '已完成').length}</strong>个已完成</span><span><strong>${uniqueIds(goals.flatMap(goal => goal.ids)).length}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>` +
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(goal => goalMetrics(goal).status === '已完成').length}</strong>个已完成</span><span><strong>${new Set(goals.flatMap(goal => goal.ids)).size}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>` +
`${storageWarning ? `<p class="hint">${esc(storageWarning)}</p>` : ''}${legacyGoals.length ? `<p class="hint">发现此浏览器的 ${legacyGoals.length} 个旧目标。<button class="text-button" data-action="import-goals">导入已有目标</button> · 数据库已有目标会保留,原备份也会保留</p>` : ''}` +
tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') +
`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
}
function openGoalDetail(id) {
const goal = goals.find(g => g.id === id), m = goalMetrics(goal);
showDetail('阅读目标', `<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${esc(goal.start)} — ${esc(goal.end)}</span></div><h2>${esc(goal.name)}</h2><div class="metrics-row"><span><strong>${m.done} / ${m.total}</strong>本已读完</span><span><strong>${m.percent}%</strong>已完成</span></div><p class="hint">已读完直接计入完成,不受目标日期影响。</p>${goal.sources.length ? `<p class="source-tag">加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目</p>` : ''}<div class="row-list">${goal.ids.map(id => bookRow(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button></div>`);
showDetail('阅读目标', `<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${esc(goal.start)} — ${esc(goal.end)}</span></div><h2>${esc(goal.name)}</h2><div class="metrics-row"><span><strong>${m.done} / ${m.total}</strong>本已读完</span><span><strong>${m.percent}%</strong>已完成</span></div><p class="hint">已读完直接计入完成,不受目标日期影响。</p>${goal.sources.length ? `<p class="source-tag">加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目</p>` : ''}<div class="row-list">${goal.ids.map(id => bookRow(goalBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button><button class="secondary" data-action="delete-goal" data-id="${esc(id)}">删除目标</button></div>`);
}
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 = '<p class="hint">正在同步书架…</p>'; 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 = `<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => `<button type="button" data-action="pick-list" data-id="${esc(l.id)}" class="${l.ids.every(id => draftIds.has(id)) ? 'active' : ''}">${l.ids.every(id => draftIds.has(id)) ? '✓' : '+'} ${esc(l.name)} · ${l.book_count || l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(book => `<label class="pick-row"><input type="checkbox" data-pick-book="${esc(book.id)}" ${draftIds.has(book.id) ? 'checked' : ''}><span><b>${esc(book.title)}</b><small>${esc(book.author)}</small></span><span class="pill ${book.status === 'done' ? 'green' : ''}">${esc(stateText(book))}</span></label>`).join('') || '<div class="empty">没有匹配的书籍</div>'}</div>${matched.length > list.length ? `<p class="hint">共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。</p>` : ''}`;
$('#selection-count').textContent = `已选 ${draftIds.size} 本 · ${[...draftIds].filter(id => goalBook(id).status === 'done').length} 本已读完`;
$('#goal-picker').innerHTML = `${!shelfData ? '<p class="hint">书架尚未同步,已选书籍仍会保留。</p>' : ''}<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l => !query || l.name.includes(query) || l.ids.some(id => list.some(book => book.id === id))).map(l => `<button type="button" data-action="pick-list" data-id="${esc(l.id)}" class="${l.ids.every(id => draftIds.has(id)) ? 'active' : ''}">${l.ids.every(id => draftIds.has(id)) ? '✓' : '+'} ${esc(l.name)} · ${l.book_count || l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(book => `<label class="pick-row"><input type="checkbox" data-pick-book="${esc(book.id)}" ${draftIds.has(book.id) ? 'checked' : ''}><span><b>${esc(book.title)}</b><small>${esc(book.author)}</small></span><span class="pill ${book.status === 'done' ? 'green' : ''}">${esc(stateText(book))}</span></label>`).join('') || '<div class="empty">没有匹配的书籍</div>'}</div>${matched.length > list.length ? `<p class="hint">共匹配 ${matched.length} 本,已显示前 ${list.length} 本,可用关键词继续筛选。</p>` : ''}`;
}
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('加入阅读目标', `<p class="detail-subtitle">选择已有目标,或为这些书新建目标。重复书籍只加入一次。</p><div class="row-list">${goals.map(g => `<div class="book-row"><div class="book-row-text"><b>${esc(g.name)}</b><p>${esc(g.start)} — ${esc(g.end)} · ${g.ids.length} 本</p></div><button class="secondary" data-action="append-goal" data-id="${esc(g.id)}">加入</button></div>`).join('') || '<div class="empty">还没有目标,先新建一个。</div>'}</div><div class="goal-actions"><button class="primary" data-action="new-with-selection">+ 新建目标</button></div>`);
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; }
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('删除阅读目标', `<p>确定删除“${esc(goal.name)}”?删除后无法恢复,书架中的书籍不受影响。</p><div class="goal-actions"><button class="secondary" data-action="goal-detail" data-id="${esc(id)}">取消</button><button class="primary" data-action="confirm-delete-goal" data-id="${esc(id)}">确认删除</button></div>`);
}
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);
+22 -2
View File
@@ -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
+2 -2
View File
@@ -10,7 +10,7 @@
<symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol>
</defs></svg>
<aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside>
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标保存在当前浏览器</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span id="footer-state">数据来自微信读书个人账号</span></footer></main>
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span id="footer-state">数据来自微信读书个人账号</span></footer></main>
<dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>仅保存在此浏览器</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
<div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html>
+71
View File
@@ -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, /正在读取阅读目标/);
});
+164
View File
@@ -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()
+66
View File
@@ -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()