mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
feat(reading-goals): 持久化阅读目标并收紧看板布局
This commit is contained in:
1 parent
61d3951905
commit
094e1aca3d
13 files changed
+834
-39
No files matched your search
@@ -21,11 +21,13 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
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
|
from functools import wraps
|
||||||
|
|
||||||
import pymysql
|
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__)
|
v2_bp = Blueprint("v2", __name__)
|
||||||
|
|
||||||
@@ -621,6 +623,173 @@ def _ts(date_text):
|
|||||||
return 0
|
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
@@ -13,9 +13,9 @@ v1 的 `/api/*` 接口保持不变,两套接口可同时使用。
|
|||||||
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
|
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
|
||||||
| 书籍详情 | 实时 `/book/info`、`/book/getprogress`、`/book/chapterinfo`、`/book/bestbookmarks`、`/review/list`、`/book/similar` | 逐项降级,单项失败不影响整页 |
|
| 书籍详情 | 实时 `/book/info`、`/book/getprogress`、`/book/chapterinfo`、`/book/bestbookmarks`、`/review/list`、`/book/similar` | 逐项降级,单项失败不影响整页 |
|
||||||
| 搜索 / 推荐 | 实时 `/store/search`(按 scope 切换类型)、`/book/recommend` | |
|
| 搜索 / 推荐 | 实时 `/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`。
|
- 评分:接口 `newRating` 为 0~1000,接口层换算成 `score = newRating / 100`(前端按设计稿公式展示为 `评分 XX/100`),详情页展示 `rating = newRating / 10`。
|
||||||
- 封面:设计稿使用 4 种示意底色(`c1`~`c4`),由 `book_id` 哈希稳定映射;真实封面 URL 保留在 `cover_url`。
|
- 封面:设计稿使用 4 种示意底色(`c1`~`c4`),由 `book_id` 哈希稳定映射;真实封面 URL 保留在 `cover_url`。
|
||||||
- 进度:统一到 0~100 整数;`status` 口径为「读完=done、有最近阅读=reading、从未读过=wish」。
|
- 进度:统一到 0~100 整数;`status` 口径为「读完=done、有最近阅读=reading、从未读过=wish」。
|
||||||
|
|||||||
@@ -48,3 +48,29 @@
|
|||||||
书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 ISBN 等字段。
|
书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 ISBN 等字段。
|
||||||
|
|
||||||
正式接口接入单独推进,必须核对实时可用接口、鉴权、分页、错误、升级提示及字段口径;本轮 Demo 的完成状态不得用于宣称这些能力已真实接入。
|
正式接口接入单独推进,必须核对实时可用接口、鉴权、分页、错误、升级提示及字段口径;本轮 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 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
|
||||||
|
|
||||||
|
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
|
||||||
@@ -55,3 +55,54 @@ node --check web-demo/demo-data.js
|
|||||||
## 与 1.0 的关系
|
## 与 1.0 的关系
|
||||||
|
|
||||||
1.0 继续由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供,运行在 8080 端口。2.0 探索 Demo 位于 `web-demo/`,两者可以独立预览。
|
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 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
|
||||||
@@ -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
@@ -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
|
*{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
|
: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)}
|
.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}}
|
@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}
|
.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)}
|
.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}
|
.mini-real-cover{width:66px;height:92px;flex-shrink:0}
|
||||||
.book-row .mini-real-cover{width:42px;height:58px}
|
.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}
|
||||||
|
}
|
||||||
+119
-30
@@ -24,8 +24,8 @@ let recommendOffset = 0;
|
|||||||
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
|
let noteBook = 'all', noteType = 'all', goalFilter = 'all';
|
||||||
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
|
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
|
||||||
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
|
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30;
|
||||||
let editingGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
|
let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
|
||||||
let toastTimer, storageWarning = '';
|
let toastTimer, storageWarning = '', goalBusy = false;
|
||||||
|
|
||||||
/* 接口数据缓存 */
|
/* 接口数据缓存 */
|
||||||
let books = [];
|
let books = [];
|
||||||
@@ -43,15 +43,15 @@ let numberData = {}; /* 书籍详情里按条件二次拉取的小接口缓存
|
|||||||
const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
|
const loadState = { shelf: 'idle', dashboard: {}, notes: {}, discover: {}, book: {} };
|
||||||
const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
|
const loadError = { shelf: '', dashboard: {}, notes: {}, discover: {}, book: {} };
|
||||||
|
|
||||||
let goals = [];
|
let goals = [], legacyGoals = [];
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(storageKey);
|
const saved = localStorage.getItem(storageKey);
|
||||||
if (saved) {
|
if (saved && !localStorage.getItem(`${storageKey}-imported`)) {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid');
|
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');
|
clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden');
|
||||||
toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500);
|
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) {
|
function statusBox(kind, title, text, action) {
|
||||||
const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO';
|
const eyebrow = { loading: 'LOADING', error: 'TRY AGAIN', empty: 'NOT FOUND' }[kind] || 'INFO';
|
||||||
const button = action ? `<button class="secondary" data-action="${action}">${kind === 'error' ? '重试' : '继续'}</button>` : '';
|
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)}%` : '暂无评分';
|
return book.score ? `评分 ${(Number(book.score) * 10).toFixed(2)}%` : '暂无评分';
|
||||||
}
|
}
|
||||||
function stateText(book) {
|
function stateText(book) {
|
||||||
|
if (book.status === 'unknown') return '状态待同步';
|
||||||
if (book.status === 'done') return '已读完';
|
if (book.status === 'done') return '已读完';
|
||||||
if (book.status === 'wish') return '想读';
|
if (book.status === 'wish') return '想读';
|
||||||
return book.progress > 0 ? `已读 ${book.progress}%` : '在读';
|
return book.progress > 0 ? `已读 ${book.progress}%` : '在读';
|
||||||
@@ -274,6 +270,7 @@ function renderTrend(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summaryGoals() {
|
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>`;
|
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()) {
|
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 ? '已到期' : '进行中';
|
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 };
|
return { total: ids.length, done, percent: ids.length ? Math.round((done / ids.length) * 100) : 0, status };
|
||||||
}
|
}
|
||||||
function goalCard(goal) {
|
function goalCard(goal) {
|
||||||
const result = goalMetrics(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() {
|
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 !== '已完成'));
|
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>') +
|
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') +
|
tabs([['all', '全部目标'], ['active', '未完成'], ['complete', '已完成']], goalFilter, 'goal-filter') +
|
||||||
`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
|
`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
|
||||||
}
|
}
|
||||||
function openGoalDetail(id) {
|
function openGoalDetail(id) {
|
||||||
const goal = goals.find(g => g.id === id), m = goalMetrics(goal);
|
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 = []) {
|
function openGoalEditor(id = null, prefill = [], sources = []) {
|
||||||
|
if (goalBusy) return;
|
||||||
|
if (!requireGoalsReady()) return;
|
||||||
$('#detail').close(); editingGoalId = id;
|
$('#detail').close(); editingGoalId = id;
|
||||||
|
// 新建重试沿用同一个 ID,防止响应丢失时重复创建目标。
|
||||||
|
draftGoalId = id || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
|
||||||
const goal = id ? goals.find(g => g.id === id) : null;
|
const goal = id ? goals.find(g => g.id === id) : null;
|
||||||
draftIds = new Set(goal ? goal.ids : prefill); draftSources = [...(goal ? goal.sources : sources)];
|
draftIds = new Set(goal ? goal.ids : prefill); draftSources = [...(goal ? goal.sources : sources)];
|
||||||
$('#editor-title').textContent = goal ? '编辑阅读目标' : '新建阅读目标'; $('#goal-name').value = goal ? goal.name : '';
|
$('#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('');
|
$('#goal-end').min = $('#goal-start').value; $('#goal-end').setCustomValidity(''); $('#goal-name').setCustomValidity('');
|
||||||
$('#picker-search').value = ''; $('#goal-error').textContent = ''; renderPicker(); $('#goal-editor').showModal();
|
$('#picker-search').value = ''; $('#goal-error').textContent = ''; renderPicker(); $('#goal-editor').showModal();
|
||||||
}
|
}
|
||||||
function selectedGoalBooks() { return uniqueIds([...draftIds]); }
|
function selectedGoalBooks() { return [...draftIds]; }
|
||||||
function renderPicker() {
|
function renderPicker() {
|
||||||
const query = $('#picker-search').value.trim().toLowerCase();
|
const query = $('#picker-search').value.trim().toLowerCase();
|
||||||
if (!shelfData) { $('#goal-picker').innerHTML = '<p class="hint">正在同步书架…</p>'; ensureShelf(); return; }
|
if (!shelfData && loadState.shelf !== 'error') ensureShelf();
|
||||||
const matched = books.filter(book => `${book.title}${book.author}${book.category}`.toLowerCase().includes(query));
|
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);
|
const list = matched.slice(0, PICKER_LIMIT);
|
||||||
$('#selection-count').textContent = `已选 ${draftIds.size} 本 · ${[...draftIds].filter(id => findBook(id).status === 'done').length} 本已读完`;
|
$('#selection-count').textContent = `已选 ${draftIds.size} 本 · ${[...draftIds].filter(id => goalBook(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>` : ''}`;
|
$('#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();
|
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();
|
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 (!name) { $('#goal-name').setCustomValidity('请输入目标名称'); $('#goal-name').reportValidity(); return; }
|
||||||
if (end < start) { $('#goal-error').textContent = '结束日期不能早于开始日期。'; return; }
|
if (end < start) { $('#goal-error').textContent = '结束日期不能早于开始日期。'; return; }
|
||||||
if (!ids.length) { $('#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 goal = { id: draftGoalId, name, start, end, ids, sources: [...new Set(draftSources)] };
|
||||||
const next = editingGoalId ? goals.map(g => (g.id === editingGoalId ? goal : g)) : [goal, ...goals];
|
goalBusy = true; $('#goal-form').inert = true; $('#goal-error').textContent = '正在保存…';
|
||||||
if (!persistGoals(next)) return;
|
try {
|
||||||
$('#goal-editor').close(); navigate('goals'); toast('阅读目标已保存,已读完的书已计入完成。');
|
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 = []) {
|
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>`);
|
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;
|
pendingBooks = ids; pendingSources = sources;
|
||||||
}
|
}
|
||||||
function appendGoal(id) {
|
async 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 (goalBusy) return;
|
||||||
if (!persistGoals(next)) { toast('无法保存到浏览器,请允许本地存储后重试。'); return; }
|
const goal = goals.find(g => g.id === id);
|
||||||
renderPage(); openGoalDetail(id); toast('已加入目标,重复书籍已自动去重。');
|
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) {
|
function handleAction(button) {
|
||||||
const action = button.dataset.action, id = button.dataset.id;
|
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) {
|
switch (action) {
|
||||||
case 'page': navigate(id); break;
|
case 'page': navigate(id); break;
|
||||||
case 'new-goal': ensureShelf(); openGoalEditor(); break;
|
case 'new-goal': ensureShelf(); openGoalEditor(); break;
|
||||||
case 'edit-goal': openGoalEditor(id); break;
|
case 'edit-goal': openGoalEditor(id); break;
|
||||||
case 'goal-detail': openGoalDetail(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 'goal-filter': goalFilter = id; renderPage(); break;
|
||||||
case 'close-detail': $('#detail').close(); break;
|
case 'close-detail': $('#detail').close(); break;
|
||||||
case 'close-editor': $('#goal-editor').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.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 => {
|
document.querySelectorAll('dialog').forEach(dialog => dialog.addEventListener('click', event => {
|
||||||
|
if (dialog.id === 'goal-editor' && goalBusy) return;
|
||||||
const rect = dialog.getBoundingClientRect();
|
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();
|
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() {
|
async function boot() {
|
||||||
|
ensureGoals();
|
||||||
renderPage();
|
renderPage();
|
||||||
await Promise.all([ensureStatus(), ensureShelf()]);
|
await Promise.all([ensureStatus(), ensureShelf()]);
|
||||||
ensureDashboard(period);
|
ensureDashboard(period);
|
||||||
|
|||||||
+22
-2
@@ -10,12 +10,16 @@
|
|||||||
|
|
||||||
const BASE = '/api/v2';
|
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);
|
const url = new URL(BASE + path, global.location.origin);
|
||||||
Object.entries(params || {}).forEach(([key, value]) => {
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null && value !== '') url.searchParams.set(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;
|
let payload = null;
|
||||||
try {
|
try {
|
||||||
payload = await response.json();
|
payload = await response.json();
|
||||||
@@ -56,6 +60,22 @@
|
|||||||
return request('/status');
|
return request('/status');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goals() {
|
||||||
|
return request('/goals');
|
||||||
|
},
|
||||||
|
|
||||||
|
saveGoal(goal, editing = false) {
|
||||||
|
return request(editing ? `/goals/${encodeURIComponent(goal.id)}` : '/goals', null, editing ? 'PUT' : 'POST', goal);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteGoal(id) {
|
||||||
|
return request(`/goals/${encodeURIComponent(id)}`, null, 'DELETE');
|
||||||
|
},
|
||||||
|
|
||||||
|
importGoals(goals) {
|
||||||
|
return request('/goals/import', null, 'POST', { goals });
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 书架数据
|
* 书架数据
|
||||||
* @param {string} tab all|reading|done|wish
|
* @param {string} tab all|reading|done|wish
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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>
|
<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>/ <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>/ <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="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>
|
<div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html>
|
||||||
@@ -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, /正在读取阅读目标/);
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user