12 KiB
阅间 Demo 设计与数据流
页面分工
统计并入阅读概览;导航保留阅读概览、我的书架、阅读目标、笔记与划线、发现好书。概览展示阅读统计和目标摘要,目标页管理多份具体阅读清单。
完整功能、业务规则、接口落点与状态见 功能目标清单。该清单先于本轮实现建立,后续代码开发继续追加和更新。
该设计探索已归档,当前正式看板由根目录 Flask 服务提供。
数据流
demo-data.js 提供样例书籍、书单及初始目标。app.js 根据当前页面和筛选条件渲染内容;用户输入写入目标草稿,验证通过后保存到浏览器,再更新目标列表。现有 Flask 服务和数据采集代码不参与本轮 Demo。
书籍卡片打开详情;详情页可选目录、个人笔记、热门划线、点评、相似书。个人笔记与公开点评使用不同数据集合。热门划线中的想法继续打开单条详情。
书籍、书单详情均可加入已有目标,也可带着已选内容创建新目标。
阅读目标口径与例子
目标字段:id、name、start、end、ids、sources。ids 存放去重后的书籍 ID;sources 记录曾加入的书单名称,作为来源说明,不表示持续关联。
例:单独选择已读完的《活着》,再加入包含《活着》《人生海海》《悉达多》的书单。保存后总数为 3 本、已读完为 1 本,进度为 33%。从目标中移除《人生海海》后,进度为 1 / 2,即 50%。
完成状态取书籍当前已读状态,不受目标起止日期约束。同一本书可计入多个目标。同一目标内不重复计数。
状态判断先检查是否全部读完,再检查日期。例如目标时间为未来一周,但其中两本书都已读完,直接显示已完成;尚未全部完成时,按日期显示未开始、进行中、已到期。
书单采用加入时的书目,不自动跟随外部增删。移除目标内一本书,只影响这个目标,不改变书架或其他目标。
实现位置
| 职责 | 位置 |
|---|---|
| 去重与完成率、状态 | app.js:uniqueIds、goalMetrics |
| 新建、编辑、书单混选 | app.js:openGoalEditor、renderPicker、saveGoal |
| 加入已有目标 | app.js:chooseTarget、appendGoal |
| 本地持久化与异常恢复 | app.js:初始加载、persistGoals |
| 页面与详情导航 | app.js:navigate、renderPage、handleAction |
| 笔记导出 | app.js:exportNotes |
用户输入的目标名称和来源在 HTML 渲染前转义。日期和内容在保存前校验;原生对话框提供焦点管理和 Escape 关闭。
部署流程
GitHub Actions 的 .github/workflows/deploy.yml 只做验证,不登录服务器。master push 后由 GitHub Webhook 向 https://laohang.space/github/webhook 发送 JSON,服务器上的 deploy_webhook.py 校验 HMAC-SHA256、仓库和 master 分支,再在固定目录调用 deploy.sh。Webhook 不接受请求体中的命令,部署脚本路径由服务器环境变量固定。
deploy.sh 固定拉取 master,收到其他部署分支参数时直接退出,再检查项目 .venv。如果 .venv/bin/python 不存在,脚本使用服务器上的 python3 或 python 创建虚拟环境;随后用 .venv/bin/python -m pip install -r requirements.txt 安装依赖,按文件名顺序执行 migrations/*.sql,最后重启 weread.service。
例:用户将已验收代码合并并推送到 master,GitHub 发送签名请求;服务器确认仓库为 KingHWJ/WeReadBoard 且 ref 为 refs/heads/master 后,在 /opt/weread-board/WeReadBoard/.venv 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 .venv/bin/python app.py 启动服务。仅推送 dev 不会触发部署,错误签名也不会执行脚本。
正式看板登录认证
auth.py 注册全站请求拦截和登录路由。/ 与所有 /api/* 数据接口都需要 session["auth_user"];静态资源、/login、/auth/login 和首次初始化路由是唯一公开入口。页面请求未认证时重定向到登录页,API 返回 401,避免前端遮罩之外仍可直接读取数据。旧 /api/v2/* 路由同样由 Flask 全局拦截。
账号表 weread_auth 固定使用 id=1 保存单管理员的用户名和 Werkzeug 密码哈希。数据库无账号时,只有提交服务端 WEREAD_AUTH_SETUP_TOKEN 才能插入首个账号;例如提交用户名 reader、一次性口令正确、密码 a-long-private-password,数据库保存的是哈希而不是密码。插入成功后创建永久 Flask 会话,并清空当前进程中的初始化口令。后续登录从 MySQL 取哈希校验;用户名或密码错误统一返回“账号或密码不正确”。
会话签名密钥从 WEREAD_SESSION_SECRET 读取且必须稳定配置;缺失时服务对动态页面和 API 以 503 失败关闭。永久会话时长是固定 5 天,不因每次请求续期。Cookie 为 HttpOnly、SameSite=Lax,并默认 Secure;仅本机 HTTP 预览可以关闭 Secure。生产站点须使用 HTTPS。前端 JSON 写请求从页面 meta 标签读取 CSRF token 并放入 X-CSRF-Token 请求头,表单请求使用隐藏字段。
网站备案页脚
正式看板页脚保留数据状态,并展示可点击的 ICP 备案号,链接至工信部备案查询平台。窄屏时隐藏次要的数据快照,备案号仍可见。
例:正式域名为 laohang.space、备案号为 浙ICP备202607916号-1,首页页脚显示该备案号;点击后打开 https://beian.miit.gov.cn/。截图里的公安联网备案数据码不是公安备案号,不作为备案号码展示。
应用版本显示
根目录 VERSION 保存面向用户的版本号,例如输入 v0.1,Flask 在启动时读取并通过模板上下文注入 app_version。首页页脚和登录页显示“版本 v0.1”。版本号不包含分支名或提交号;发布新版本时修改 VERSION 后随 master 一起部署。
应用未内置登录尝试限流;公网部署应在可信反向代理上为 POST /auth/login 设置频率限制,且不要直接信任未校验的 X-Forwarded-For。
示例与真实接口的边界
概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。
书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 ISBN 等字段。
正式接口接入单独推进,必须核对实时可用接口、鉴权、分页、错误、升级提示及字段口径;本轮 Demo 的完成状态不得用于宣称这些能力已真实接入。
正式看板的紧凑布局
正式看板在 static/css/styles.css 中统一调整页面密度。顶栏高度为桌面 58 px、手机 56 px;标题区采用更短的上下间距,隐藏页面标题前的英文装饰文字。标题、说明、筛选、内容依次展示,操作按钮在桌面与标题并排,手机上自然换行。
书架按可用宽度自动排列书籍,每列约 116 px 起步;手机固定两列。真实封面维持原比例,并受所在列宽度约束。例如,书架网格可用宽度为 900 px 时显示多列紧凑排布,手机宽度为 390 px 时显示 2 列,长书名仍使用已有省略规则。
目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。
正式看板书架排序与分页
正式看板从 /api/reading/shelf 读取电子书列表。前端按当前书架状态筛选后排序,每页展示 48 本;用户切换状态或排序时回到第 1 页,点击底部页码时只替换当前书籍网格。排序框右对齐显示在页码列表上方。
排序默认按最近阅读时间倒序;还可按书名升序、书名降序或作者升序。四种方式都先将 is_top 置顶书籍排在前面。接口把微信读书的 isTop 映射为 is_top,前端排序只使用筛选结果的副本,避免改变其他页面共享的书架顺序。
例:筛选“全部电子书”共 101 本,选择“书名升序”并打开第 2 页。页面只显示排序后第 49 至 96 本,在网格下方显示第 1、2、3 页;切换到“最近阅读”后回到第 1 页,并按最近阅读日期倒序显示前 48 本,置顶书籍仍排在该页最前面。
正式看板的阅读目标持久化
正式看板使用 static/js/app.js、static/js/weapi.js 和 reading_api.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/reading/goals/import。先校验整批,再在事务中逐项插入;相同 ID 已存在时保留数据库内容,批次内重复 ID 只处理第一条。例如库中已有 G1,导入 [G1, G2, G2] 返回 {"imported":1},再次导入返回 {"imported":0}。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。