mirror of
https://github.com/KingHWJ/WeReadBoard.git
synced 2026-11-03 18:24:58 +08:00
Merge branch 'codex/weread-dashboard-design' into dev
This commit is contained in:
commit
8d2f8f521f
7 files changed
+559
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
# 阅间 Demo 设计与数据流
|
||||||
|
|
||||||
|
## 页面分工
|
||||||
|
|
||||||
|
统计并入阅读概览;导航保留阅读概览、我的书架、阅读目标、笔记与划线、发现好书。概览展示阅读统计和目标摘要,目标页管理多份具体阅读清单。
|
||||||
|
|
||||||
|
完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。
|
||||||
|
|
||||||
|
此版本属于 2.0 设计探索。正式版本需要接入真实接口、完成验收并合并到正式 Web 项目的 `master`,随后再创建 `v2.0` 标签。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
`demo-data.js` 提供样例书籍、书单及初始目标。`app.js` 根据当前页面和筛选条件渲染内容;用户输入写入目标草稿,验证通过后保存到浏览器,再更新目标列表。现有 Flask 服务和数据采集代码不参与本轮 Demo。
|
||||||
|
|
||||||
|
书籍卡片打开详情;详情页可选目录、个人笔记、热门划线、点评、相似书。个人笔记与公开点评使用不同数据集合。热门划线中的想法继续打开单条详情。
|
||||||
|
|
||||||
|
书籍、书单详情均可加入已有目标,也可带着已选内容创建新目标。
|
||||||
|
|
||||||
|
## 阅读目标口径与例子
|
||||||
|
|
||||||
|
目标字段:`id`、`name`、`start`、`end`、`ids`、`sources`。`ids` 存放去重后的书籍 ID;`sources` 记录曾加入的书单名称,作为来源说明,不表示持续关联。
|
||||||
|
|
||||||
|
例:单独选择已读完的《活着》,再加入包含《活着》《人生海海》《悉达多》的书单。保存后总数为 3 本、已读完为 1 本,进度为 33%。从目标中移除《人生海海》后,进度为 1 / 2,即 50%。
|
||||||
|
|
||||||
|
完成状态取书籍当前已读状态,不受目标起止日期约束。同一本书可计入多个目标。同一目标内不重复计数。
|
||||||
|
|
||||||
|
状态判断先检查是否全部读完,再检查日期。例如目标时间为未来一周,但其中两本书都已读完,直接显示已完成;尚未全部完成时,按日期显示未开始、进行中、已到期。
|
||||||
|
|
||||||
|
书单采用加入时的书目,不自动跟随外部增删。移除目标内一本书,只影响这个目标,不改变书架或其他目标。
|
||||||
|
|
||||||
|
## 实现位置
|
||||||
|
|
||||||
|
| 职责 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| 去重与完成率、状态 | `app.js`:`uniqueIds`、`goalMetrics` |
|
||||||
|
| 新建、编辑、书单混选 | `app.js`:`openGoalEditor`、`renderPicker`、`saveGoal` |
|
||||||
|
| 加入已有目标 | `app.js`:`chooseTarget`、`appendGoal` |
|
||||||
|
| 本地持久化与异常恢复 | `app.js`:初始加载、`persistGoals` |
|
||||||
|
| 页面与详情导航 | `app.js`:`navigate`、`renderPage`、`handleAction` |
|
||||||
|
| 笔记导出 | `app.js`:`exportNotes` |
|
||||||
|
|
||||||
|
用户输入的目标名称和来源在 HTML 渲染前转义。日期和内容在保存前校验;原生对话框提供焦点管理和 Escape 关闭。
|
||||||
|
|
||||||
|
## 示例与真实接口的边界
|
||||||
|
|
||||||
|
概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。
|
||||||
|
|
||||||
|
书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 ISBN 等字段。
|
||||||
|
|
||||||
|
正式接口接入单独推进,必须核对实时可用接口、鉴权、分页、错误、升级提示及字段口径;本轮 Demo 的完成状态不得用于宣称这些能力已真实接入。
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 阅间功能目标清单
|
||||||
|
|
||||||
|
> 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。
|
||||||
|
|
||||||
|
当前版本定位:本目录中的静态 Demo 是 2.0 设计探索,不是正式 2.0 发布。只有后续真实功能合并到正式 Web 项目的 `master` 并完成验收后,才创建 `v2.0` 标签。
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
- 功能编号固定,后续需求追加新编号,不复用或删除旧编号。
|
||||||
|
- 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。
|
||||||
|
- 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。
|
||||||
|
- 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。
|
||||||
|
- 当前范围为优化本地假数据 Demo,不接入账号,不修改原 Python 业务逻辑,不推送。
|
||||||
|
|
||||||
|
## 已确认业务口径
|
||||||
|
|
||||||
|
| 编号 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| R01 | 合并阅读概览与阅读统计,统计在概览内展示,不设置重复导航。 |
|
||||||
|
| R02 | 支持多个阅读目标,每个目标有名称、开始日期、结束日期。结束日期不得早于开始日期。 |
|
||||||
|
| R03 | 目标可添加具体书籍、一个或多个书单,允许混合选择。 |
|
||||||
|
| R04 | 同一目标内按书籍 ID 去重;同一本书可属于多个目标。 |
|
||||||
|
| R05 | 已读完的书直接计为完成,不要求在目标时间范围内读完,不做重读。 |
|
||||||
|
| R06 | 完成率 = 已读完书籍数 / 去重后目标书籍数。创建目标至少选一本;空目标展示未开始,不除零。 |
|
||||||
|
| R07 | 时间范围仅安排计划和提示状态,不改变书籍是否读完。全部读完优先显示已完成;否则按未开始、进行中、已到期展示。 |
|
||||||
|
| R08 | 本版把书单当前成员加入目标并展示来源,保存时提示「加入当前书目」。书单后续变动不自动增删目标书籍;自动跟随属于后续待讨论项。 |
|
||||||
|
| R09 | Demo 目标保存在当前浏览器,刷新保留;不写回微信读书。现有文档没有目标写回接口。 |
|
||||||
|
| R10 | 接入时按接口已读完状态判断,不把进度 1 当作完成:书架 finishReading=1;独立进度需要 progress=100 且存在 finishTime。冲突、缺失数据需处理,不伪造已读状态。 |
|
||||||
|
| R11 | 书架条目数 = books.length + albums.length + (mp 非空 ? 1 : 0)。有声专辑「已完结」不是用户「已读完」。 |
|
||||||
|
| R12 | 笔记总数 = 划线数 + 想法/点评数 + 书签数。公开点评不计入个人笔记;书签目前仅有数量,无内容。 |
|
||||||
|
| R13 | 时间戳显示 YYYY-MM-DD;时长按秒转换为小时/分钟;评分按对应接口说明转换,不能混用百分制、五分制。 |
|
||||||
|
| R14 | 演示数据与真实数据明确区分。无 deepLink 不拼接阅读链接。可选字段缺失不当作 0,也不补造内容。 |
|
||||||
|
|
||||||
|
## 功能目标与追踪
|
||||||
|
|
||||||
|
以下位置已按本轮实现回填。函数名作为稳定定位依据,行号仅对应本次版本。
|
||||||
|
|
||||||
|
| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| F01 | 五个入口:概览、书架、目标、笔记、发现;暗亮切换及移动适配 | 已验证(示例) | 不涉及接口 | `web-demo/app.js:54` `navigate`; `web-demo/app.js:30` `setTheme` |
|
||||||
|
| F02 | 概览内切换周/月/年/总计,展示时长、天数、日均、排行、偏好 | 已验证(示例) | 待实现 | `web-demo/app.js:85` `renderOverview` |
|
||||||
|
| F03 | 多目标列表、名称、时间范围、封面组合、完成率、到期状态 | 已验证(示例) | 本地功能,待正式持久化 | `web-demo/app.js:76` `renderGoals`; `web-demo/app.js:67` `goalMetrics` |
|
||||||
|
| F04 | 创建与编辑目标,校验日期和非空内容;刷新保留 | 已验证(示例) | 本地功能,待正式持久化 | `web-demo/app.js:122` `openGoalEditor`; `web-demo/app.js:138` `saveGoal` |
|
||||||
|
| F05 | 目标混合选择单书与书单,去重,已读直接计完成 | 已验证(示例) | 待接真实书籍状态 | `web-demo/app.js:131` `selectedGoalBooks`; `web-demo/app.js:67` `goalMetrics` |
|
||||||
|
| F06 | 目标详情查看成员、来源、单本进度;编辑移除成员 | 已验证(示例) | 待接真实书籍状态 | `web-demo/app.js:118` `openGoalDetail`; `web-demo/app.js:122` `openGoalEditor` |
|
||||||
|
| F07 | 我的书架:在读/已读/想读、书单、有声书和文章收藏入口 | 已验证(示例) | 待实现 | `web-demo/app.js:105` `renderShelf` |
|
||||||
|
| F08 | 我的书单列表与详情;从书单添加目标 | 已验证(示例) | 待实现 | `web-demo/app.js:104` `renderLists`; `web-demo/app.js:113` `openList` |
|
||||||
|
| F09 | 发现页:书籍/书单搜索、公开书单详情示意、空结果 | 已验证(示例) | 搜索待实现;公开书单成员待接口验证 | `web-demo/app.js:158` `renderDiscover`; `web-demo/app.js:113` `openList` |
|
||||||
|
| F10 | 书籍详情:信息、阅读进度、目录;加入目标 | 已验证(示例) | 待实现 | `web-demo/app.js:192` `openBook`; `web-demo/app.js:197` `renderBookTab` |
|
||||||
|
| F11 | 笔记本、划线与个人想法、书签数量、按书过滤、内容导出 | 内容/导出预览已验证;文件下载待验证 | 待实现;书签内容暂不支持 | `web-demo/app.js:177` `renderNotes`; `web-demo/app.js:184` `exportNotes` |
|
||||||
|
| F12 | 热门划线、章节热度、划线下想法及想法详情 | 已验证(示例) | 待实现 | `web-demo/app.js:197` `renderBookTab`; `web-demo/app.js:208` `openThought` |
|
||||||
|
| F13 | 公开点评筛选、点评详情,与个人笔记分离 | 已验证(示例) | 待实现 | `web-demo/app.js:197` `renderBookTab`; `web-demo/app.js:208` `openThought` |
|
||||||
|
| F14 | 个性化推荐和相似书推荐,可查详情和加入目标 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover`; `web-demo/app.js:197` `renderBookTab` |
|
||||||
|
| F15 | 搜索其余类型:作者、网文、听书、全文、公众号、文章、综合 | 已验证(示例) | 待实现;非书籍回包结构需实测 | `web-demo/app.js:158` `renderDiscover` |
|
||||||
|
| F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` |
|
||||||
|
| F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 |
|
||||||
|
| F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` |
|
||||||
|
|
||||||
|
## 全部文档接口的落点
|
||||||
|
|
||||||
|
依据本地 weread-skills 1.0.4 能力文档。未调用实时 `/_list`,因此此表是文档已知能力,不宣称已发现网关所有实际接口。
|
||||||
|
|
||||||
|
| 接口 | 使用场景 | 约束与后续接入要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| /_list | 接入前核对网关可用接口 | 获取后补齐本清单;不可将未验证能力标完成 |
|
||||||
|
| /shelf/sync | 书架、我的书单、专辑、文章收藏 | archive 包含名称与 bookIds;mp 仅入口,不是文章内容 |
|
||||||
|
| /store/search | 综合、电子书、网文、听书、作者、全文、书单、公众号、文章 | 显式 scope;分页看 hasMore/searchIdx;书单成员结构待验证 |
|
||||||
|
| /book/info | 详情资料与评分 | 按返回字段展示,不补造 ISBN 等信息 |
|
||||||
|
| /book/chapterinfo | 目录与章节选择 | 使用 chapterUid,保留层级和付费状态 |
|
||||||
|
| /book/getprogress | 书籍进度、已读状态 | 进度 0–100;时长秒;最近阅读优先加载,避免全书架逐本请求 |
|
||||||
|
| /readdata/detail | 概览全部阅读统计 | 自然周期;总量用 totalReadTime;日均按自然日;preferTime 从 6 点开始;可选数据缺失时隐藏 |
|
||||||
|
| /user/notebooks | 笔记本与数量 | count + lastSort 分页;总数含书签 |
|
||||||
|
| /book/bookmarklist | 划线内容 | 不含书签内容 |
|
||||||
|
| /review/list/mine | 个人想法与点评 | 与划线合并导出;使用 bookid 参数及 synckey 分页 |
|
||||||
|
| /book/underlines | 章节划线热度 | 只有人数/位置等统计,不含文本 |
|
||||||
|
| /book/bestbookmarks | 热门划线 | 固定前 20 条,不支持分页 |
|
||||||
|
| /book/readreviews | 划线下想法 | 使用返回 range、chapterUid;每个 range 独立分页 |
|
||||||
|
| /review/single | 想法、点评详情 | 富文本正式接入时需净化后渲染 |
|
||||||
|
| /review/list | 公开点评及筛选 | 双层 review 包装;20/40/60/80/100 对应 1–5 星 |
|
||||||
|
| /book/recommend | 为你推荐 | 使用 searchIdx 分页 |
|
||||||
|
| /book/similar | 相似推荐 | 显式 count/maxIdx;后续携带 sessionId |
|
||||||
|
|
||||||
|
## 验收记录
|
||||||
|
|
||||||
|
2026-09-11 在本地浏览器实测,结果如下。均为假数据 Demo 验证,不代表真实接口验收。
|
||||||
|
|
||||||
|
| 场景 | 输入或操作 | 实际结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 导航合并 | 查看侧边栏 | 共 5 个入口,无独立阅读统计 |
|
||||||
|
| 日期校验 | 开始 2026-09-12,结束 2026-09-01 | 原生表单阻止保存,提示结束日期过早 |
|
||||||
|
| 非空校验 | 只填写名称与日期,未选择书籍 | 提示至少选择一本书或一个书单 |
|
||||||
|
| 混合去重 | 单独选《活着》,再加入含它的三本书单 | 已选 3 本,1 本已读完,保存显示 33% |
|
||||||
|
| 保存刷新 | 保存“周末慢读清单”后刷新 | 名称、日期、3 本书与进度保留 |
|
||||||
|
| 编辑移除 | 从上述目标移除《人生海海》 | 剩余 2 本,1 本完成,显示 50% |
|
||||||
|
| 已有目标去重 | 重复加入“心理学,向内走一程”书单 | 原目标仍为 3 本,1 本完成 |
|
||||||
|
| 书单查询 | 发现页选择书单,输入“心理学” | 仅显示匹配书单,详情可加入已有目标 |
|
||||||
|
| 书籍内容 | 三体 → 目录 → 示例章节 2 → 划线想法 → 详情 | 展示对应入口及示例内容 |
|
||||||
|
| 点评筛选 | 读者点评选择“一般” | 仅显示三星的一般点评示例 |
|
||||||
|
| 相似推荐 | 在书籍详情选择相似推荐 | 展示其他 4 本书,可进入详情 |
|
||||||
|
| 笔记边界 | 全部笔记 → 书签数量 | 总数 10,划线 2、想法 2、书签 6;不显示书签内容 |
|
||||||
|
| 导出预览 | 导出全部笔记 | 生成 4 条内容:2 条划线、2 条想法,包含演示声明 |
|
||||||
|
| 文件下载 | 等待浏览器下载事件 | 事件超时,未确认落盘;保留可复制预览和下载链接,后续实测下载 |
|
||||||
|
| 搜索类型 | 推荐、作者、网文、听书、全文、公众号、文章、综合 | 均切换为对应类型示意 |
|
||||||
|
| 失败重试 | 预览状态选失败,再点击重试 | 显示失败状态,重试恢复结果 |
|
||||||
|
| 统计周期 | 切换月、年、总计、周 | 图表与总量切换;年度最终核对为 128小时42分钟 |
|
||||||
|
| 书架边界 | 我的书单、有声书、文章收藏 | 展示书单、有声信息和收藏入口边界说明 |
|
||||||
|
| 主题与响应式 | 1440px 桌面、390px 手机,暗亮切换 | 桌面双列目标;手机单列且编辑弹窗可滚动,无横向溢出 |
|
||||||
|
| 静态检查 | Node 语法检查 app.js 和 demo-data.js | 均通过 |
|
||||||
|
|
||||||
|
可重复手工验收:新建空内容目标检查提示;选择《活着》和文学书单验证 1/3;刷新验证保存;移除一本验证 1/2。不要把验收用目标写入未来真实账号。
|
||||||
|
|
||||||
|
## 变更记录
|
||||||
|
|
||||||
|
| 日期 | 变更 | 依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-09-11 | 建立清单;确认多目标、日期范围、单书与书单混合、已读直接完成 | 本任务用户需求 |
|
||||||
|
| 2026-09-11 | 完成第二版 Demo,回填代码位置与验收;真实接口接入保留待实现 | 本轮浏览器实测 |
|
||||||
|
|
||||||
|
## 后续追加模板
|
||||||
|
|
||||||
|
复制下面一行到功能目标表,分配下一个编号,并在完成时同步验收记录。
|
||||||
|
|
||||||
|
| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 |
|
||||||
|
|
||||||
|
后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 阅间设计 Demo 使用说明
|
||||||
|
|
||||||
|
## 范围与启动
|
||||||
|
|
||||||
|
这是独立的静态设计 Demo,不调用微信读书接口,不需要 API Key,也不修改现有 Flask 服务、数据采集脚本和 8080 页面。功能与接口的后续实现进度统一维护在 [功能目标清单](feature-checklist.md)。
|
||||||
|
|
||||||
|
该 Demo 定位为 2.0 设计探索。当前提交用于保存探索成果,不代表 2.0 正式发布,也不创建 `v2.0` 标签。
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m http.server 8765 --bind 127.0.0.1 --directory web-demo
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://127.0.0.1:8765`。也可以直接打开 `web-demo/index.html`;推荐通过本地服务器使用,以便浏览器稳定保存目标数据。
|
||||||
|
|
||||||
|
无需安装前端依赖。页面由以下文件构成:
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `web-demo/index.html` | 页面外壳、导航、详情与目标编辑弹窗 |
|
||||||
|
| `web-demo/styles.css` | 暗亮主题、书封示意、响应式排版 |
|
||||||
|
| `web-demo/demo-data.js` | 书籍、书单、初始目标、笔记与点评样例 |
|
||||||
|
| `web-demo/app.js` | 页面渲染、目标操作、搜索筛选、导出 |
|
||||||
|
|
||||||
|
## 保存与数据边界
|
||||||
|
|
||||||
|
目标使用当前来源的 `localStorage` 键 `weread-demo-goals-v2`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。
|
||||||
|
|
||||||
|
书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。
|
||||||
|
|
||||||
|
本地存储读取失败时显示示例目标并提示,不自动覆盖原存储;保存失败时保留编辑表单并提示重试。
|
||||||
|
|
||||||
|
所有阅读数据、书单成员、封面、目录、笔记和点评都是示例。用户编辑的目标只保存在本机,不会写回微信读书。公开书单完整成员获取方式仍需接口验证。
|
||||||
|
|
||||||
|
## 使用入口
|
||||||
|
|
||||||
|
- 阅读目标:新建目标,填写日期,勾选书籍或点击书单快捷加入,保存后可再编辑。
|
||||||
|
- 我的书架:切换我的书单、有声书、文章收藏与阅读状态。
|
||||||
|
- 发现好书:选择搜索类型;右侧预览状态可查看加载、空结果、失败及重试效果。
|
||||||
|
- 书籍详情:从卡片进入,切换目录、个人笔记、热门划线、点评、相似推荐。
|
||||||
|
- 笔记:按书目和类型筛选,导出当前书目的划线与想法,书签仅显示数量。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
语法检查:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --check web-demo/app.js
|
||||||
|
node --check web-demo/demo-data.js
|
||||||
|
```
|
||||||
|
|
||||||
|
交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。
|
||||||
|
|
||||||
|
## 与 1.0 的关系
|
||||||
|
|
||||||
|
1.0 继续由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供,运行在 8080 端口。2.0 探索 Demo 位于 `web-demo/`,两者可以独立预览。
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||||||
|
const findBook = id => books.find(book => book.id === id);
|
||||||
|
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
|
||||||
|
const today = () => new Date().toLocaleDateString('sv-SE');
|
||||||
|
const storageKey = 'weread-demo-goals-v2';
|
||||||
|
let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week';
|
||||||
|
let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready';
|
||||||
|
let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all';
|
||||||
|
let editingGoalId = null, draftIds = new Set(), draftSources = [], goals = initialGoals.map(goal => ({...goal, ids:[...goal.ids], sources:[...goal.sources]}));
|
||||||
|
let toastTimer, storageWarning = '';
|
||||||
|
// 本地数据损坏时保留原存储并恢复示例;保存前仍要求用户通过有效表单提交。
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid');
|
||||||
|
goals = parsed.map(goal => ({...goal,ids:uniqueIds(goal.ids)}));
|
||||||
|
}
|
||||||
|
} catch { storageWarning = '本地目标暂时无法读取,已显示示例目标;原存储未修改。'; }
|
||||||
|
function toast(message) {
|
||||||
|
clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden');
|
||||||
|
toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500);
|
||||||
|
}
|
||||||
|
function persistGoals(next) {
|
||||||
|
try { localStorage.setItem(storageKey, JSON.stringify(next)); }
|
||||||
|
catch { $('#goal-error').textContent = '浏览器无法保存数据,请允许本地存储后重试。'; return false; }
|
||||||
|
goals = next; return true;
|
||||||
|
}
|
||||||
|
function setTheme(theme) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
$('#theme').innerHTML = `<svg><use href="#${theme === 'dark' ? 'sun' : 'moon'}"/></svg>`;
|
||||||
|
$('#theme').setAttribute('aria-label', `切换至${theme === 'dark' ? '亮' : '暗'}色主题`);
|
||||||
|
}
|
||||||
|
try { setTheme(localStorage.getItem('weread-demo-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); }
|
||||||
|
$('#theme').onclick = () => {
|
||||||
|
const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme);
|
||||||
|
try { localStorage.setItem('weread-demo-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); }
|
||||||
|
};
|
||||||
|
function cover(book, mini = false) {
|
||||||
|
if (mini) return `<div class="mini-cover ${book.cover}" aria-hidden="true"><b>${esc(book.title)}</b><small>${esc(book.author)}</small></div>`;
|
||||||
|
return `<div class="cover ${book.cover}"><span class="tiny">${book.en}</span><span class="title">${book.short.replaceAll('\n','<br>')}</span><span class="author">${book.author}</span><span class="publisher">READING SPACE · 示意封面</span></div>`;
|
||||||
|
}
|
||||||
|
function bookCard(book) {
|
||||||
|
return `<button class="book" data-action="book" data-id="${book.id}" aria-label="查看${book.title}详情">${cover(book)}<h3>${book.title}</h3><p>${book.author}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div><div class="book-meta"><span>${book.status === 'done' ? '已读完' : book.status === 'wish' ? '想读' : `已读 ${book.progress}%`}</span><span>评分 ${Math.round(Number(book.score)*10)}/100</span></div></button>`;
|
||||||
|
}
|
||||||
|
function bookRow(book, source = '') {
|
||||||
|
return `<div class="book-row">${cover(book,true)}<div class="book-row-text"><b>${book.title}</b><p>${book.author}${source ? ' · '+esc(source) : ''}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div></div><span class="book-row-status">${book.status === 'done' ? '已读完' : `${book.progress}%`}</span><button class="text-button" data-action="book" data-id="${book.id}" aria-label="查看${book.title}详情">详情 ↗</button></div>`;
|
||||||
|
}
|
||||||
|
function welcome(title, subtitle, action = '') {
|
||||||
|
return `<section class="welcome"><div><div class="eyebrow">A LITTLE READING, EVERY DAY</div><h1>${title}</h1><p>${subtitle}</p></div><div class="page-actions">${action}</div></section>`;
|
||||||
|
}
|
||||||
|
const pageNames = {overview:'阅读概览',shelf:'我的书架',goals:'阅读目标',notes:'笔记与划线',discover:'发现好书'};
|
||||||
|
function navigate(next) {
|
||||||
|
page = next; $('#crumb').textContent = pageNames[page];
|
||||||
|
document.querySelectorAll('[data-page]').forEach(button => {button.classList.toggle('active',button.dataset.page === page);button.setAttribute('aria-current',button.dataset.page === page ? 'page' : 'false');});
|
||||||
|
renderPage();
|
||||||
|
}
|
||||||
|
function renderPage() {
|
||||||
|
const views = {overview:renderOverview,shelf:renderShelf,goals:renderGoals,notes:renderNotes,discover:renderDiscover};
|
||||||
|
$('#page-content').innerHTML = views[page]();
|
||||||
|
}
|
||||||
|
function tabs(items, selected, action) {
|
||||||
|
return `<div class="type-chips">${items.map(([id,label]) => `<button class="${selected === id ? 'active':''}" aria-pressed="${selected === id}" data-action="${action}" data-id="${id}">${label}</button>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
// 已读状态与目标日期无关;先按书籍 ID 去重,再计算完成率。
|
||||||
|
function goalMetrics(goal, date = today()) {
|
||||||
|
const ids = uniqueIds(goal.ids), done = ids.filter(id => findBook(id).status === 'done').length;
|
||||||
|
const status = ids.length && done === ids.length ? '已完成' : date < goal.start ? '未开始' : date > goal.end ? '已到期' : '进行中';
|
||||||
|
return {total:ids.length,done,percent:ids.length ? Math.round(done/ids.length*100):0,status};
|
||||||
|
}
|
||||||
|
function goalCard(goal) {
|
||||||
|
const result = goalMetrics(goal);
|
||||||
|
return `<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">${goal.start} — ${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>`;
|
||||||
|
}
|
||||||
|
function renderGoals() {
|
||||||
|
const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成'));
|
||||||
|
return welcome('把想读,变成读过','几本书、一份书单,或一段想留给阅读的时间。', '<button class="primary" data-action="new-goal">+ 新建阅读目标</button>')+
|
||||||
|
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(g=>goalMetrics(g).status==='已完成').length}</strong>个已完成</span><span><strong>${uniqueIds(goals.flatMap(g=>g.ids)).length}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>`+
|
||||||
|
tabs([['all','全部目标'],['active','未完成'],['complete','已完成']],goalFilter,'goal-filter')+`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
|
||||||
|
}
|
||||||
|
function summaryGoals() {
|
||||||
|
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('')}<button class="text-button section-space" data-action="new-goal">+ 新建阅读目标</button></section>`;
|
||||||
|
}
|
||||||
|
function renderOverview() {
|
||||||
|
const values = {week:[42,65,33,57,86,71,48],month:[48,54,65,38,70,52,64,75,68,42,46],year:[8,11,14,9,18,15,12,14,27.7],overall:[75,96,128.7]}[period];
|
||||||
|
const labels = {week:['一','二','三','四','五','六','日'],month:['1日','2日','3日','4日','5日','6日','7日','8日','9日','10日','11日'],year:['1月','2月','3月','4月','5月','6月','7月','8月','9月'],overall:['2024','2025','2026']}[period];
|
||||||
|
const max = Math.ceil(Math.max(...values)/10)*10;
|
||||||
|
const totalMinutes = values.reduce((a,b)=>a+b,0)*(period==='year'||period==='overall'?60:1);
|
||||||
|
const duration = `${Math.floor(totalMinutes/60)}小时${Math.round(totalMinutes%60)}分钟`;
|
||||||
|
const days = {week:7,month:11,year:132,overall:412}[period];
|
||||||
|
const naturalDays = {week:7,month:11,year:254,overall:985}[period];
|
||||||
|
const units = period==='year'||period==='overall'?'时':'分';
|
||||||
|
const stats = [['阅读 / 收听时长',duration,'当前所选周期','clock'],['有效阅读天数',`${days}<small>天</small>`,'单日满 1 分钟计一天','flag'],['自然日均阅读',`${Math.round(totalMinutes/naturalDays)}<small>分钟</small>`,'按周期自然日计算','chart'],['笔记总数','10<small>条</small>','划线 2 · 想法 2 · 书签 6','note']];
|
||||||
|
return welcome('在字里行间,遇见自己','阅读、目标与灵感,在这里慢慢积累。','<button class="primary" data-action="new-goal">+ 新建目标</button>')+
|
||||||
|
`<div class="toolbar">${tabs([['week','本周'],['month','本月'],['year','本年'],['overall','总计']],period,'period')}<span class="hint">统计示例 · ${period==='overall'?'2024—2026':'2026 年'}</span></div><section class="stats">${stats.map(([label,value,hint,icon])=>`<article class="card stat"><div class="stat-label">${label}<svg><use href="#${icon}"/></svg></div><div class="stat-value compact-value">${value}</div><div class="stat-foot">${hint}</div></article>`).join('')}</section>
|
||||||
|
<div class="columns"><div><section class="card trend"><div class="section-head"><h2>阅读的日常 <small>每一天,都算数</small></h2><span class="hint">${period==='year'?'按月':period==='overall'?'按年':'按日'}</span></div><div class="trend-total">${duration}<small>阅读与收听合计</small></div><div class="chart"><div class="gridlines">${[max,Math.round(max*2/3),Math.round(max/3),0].map(n=>`<i data-label="${n}${n?units:''}"></i>`).join('')}</div><div class="bars">${values.map((v,i)=>`<div class="bar" style="--h:${v/max*100}%" data-tip="${v}${units}" role="img" aria-label="${labels[i]} ${v}${units}"><span>${labels[i]}</span></div>`).join('')}</div></div></section><section class="card shelf"><div class="section-head"><h2>继续阅读</h2><button class="text-button" data-action="page" data-id="shelf">全部书架 ↗</button></div><div class="books">${books.filter(b=>b.status==='reading').map(bookCard).join('')}</div></section></div><aside class="rightcol">${summaryGoals()}<section class="quote"><div class="eyebrow">✦ 留给今天的一句话</div><p>不必追赶每一页,<br>只需为自己翻开一本书。</p><small>— 阅间 · 原创阅读手记</small></section></aside></div>
|
||||||
|
<div class="insight-grid"><section class="card panel insight"><h3>读得最多</h3>${books.slice(0,3).map((b,i)=>`<div class="insight-line"><b>${i+1}. ${b.title}</b><span>${[2,1,1][i]}小时${[12,35,6][i]}分钟</span></div>`).join('')}</section><section class="card panel insight"><h3>偏爱的阅读方向</h3>${[['文学',80],['心理学',60],['科幻',40]].map(([label,val])=>`<div class="insight-line"><b>${label}</b><span>相对偏好 ${val}</span></div><div class="progress" style="--progress:${val}%"><i></i></div>`).join('')}<p class="hint section-space">相对偏好权重,不是阅读占比</p></section><section class="card panel insight"><h3>留给自己的时段</h3><div class="hour-bars">${Array.from({length:24},(_,i)=>`<i style="--height:${[10,20,15,12,20,35,28,24,12,10,16,20,30,35,40,65,95,85,40,15,6,4,3,5][i]}%"></i>`).join('')}</div><div class="insight-line"><span>06:00</span><span>18:00</span><span>次日 05:00</span></div><p class="hint">偏好夜间阅读 · 示例时段分布</p></section></div>
|
||||||
|
<details class="insight-detail card panel"><summary>更多阅读画像 · 作者、听书、勋章与年度回顾</summary><div class="insight-grid"><div><h3>偏好作者与出版方</h3><div class="insight-line"><b>刘慈欣</b><span>3 本 · 5小时30分钟</span></div><div class="insight-line"><b>示例出版社</b><span>4 本</span></div><div class="insight-line"><b>示例版权方</b><span>3 本</span></div></div><div><h3>阅读与收听</h3><div class="insight-line"><b>文字阅读</b><span>80%</span></div><div class="progress" style="--progress:80%"><i></i></div><p class="hint section-space">示例分布;正式接入只在接口返回时展示。</p></div><div><h3>阅读足迹</h3><div class="insight-line"><b>好友排行</b><span>本周第 3 · 示例</span></div><div class="insight-line"><b>勋章</b><span>初读 · 笔记 · 坚持</span></div><button class="text-button" data-action="year-report">年度回顾示意 →</button></div></div></details>`;
|
||||||
|
}
|
||||||
|
function listCard(list, index) {
|
||||||
|
return `<button class="card list-card" data-action="list" data-id="${list.id}"><div class="list-art">${list.ids.slice(0,3).map(id=>cover(findBook(id),true)).join('')}</div><h3><span class="list-index">${String(index+1).padStart(2,'0')}</span>${list.name}</h3><p>${list.description}</p><small>${list.ids.length} 本书 · ${list.source}</small></button>`;
|
||||||
|
}
|
||||||
|
function renderLists(lists) { return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单,换个关键词试试。</div>'}</div>`; }
|
||||||
|
function renderShelf() {
|
||||||
|
let content;
|
||||||
|
if (shelfTab==='lists') content=renderLists(booklists.filter(l=>l.source==='我的书单'));
|
||||||
|
else if (shelfTab==='audio') content=`<article class="card media-card"><div class="media-icon">♫</div><div><h3>晚间听书 · 文学选读</h3><p>示例演播者 · 24 集 · 内容已完结</p><p class="hint">内容完结不代表已听完;当前不纳入按本阅读目标。</p></div></article>`;
|
||||||
|
else if(shelfTab==='articles') content=`<article class="card media-card"><div class="media-icon">▤</div><div><h3>文章收藏</h3><p>这是书架中的收藏入口。</p><p class="hint">当前书架接口不提供入口内文章列表;可在发现页搜索文章。</p><button class="text-button" data-action="article-search">去搜索文章 →</button></div></article>`;
|
||||||
|
else content=`<div class="card panel"><div class="books full-books">${books.filter(b=>shelfTab==='all'||b.status===shelfTab).map(bookCard).join('')}</div></div>`;
|
||||||
|
return welcome('我的书架','8 本电子书 · 1 个有声专辑 · 1 个文章收藏入口,共 10 个书架条目。')+tabs([['all','全部电子书 8'],['reading','在读 4'],['done','已读完 2'],['wish','想读 2'],['lists','我的书单 2'],['audio','有声书 1'],['articles','文章收藏']],shelfTab,'shelf-tab')+content;
|
||||||
|
}
|
||||||
|
function openList(id) {
|
||||||
|
const list=booklists.find(l=>l.id===id);
|
||||||
|
showDetail('书单详情',`<div class="discovery-hero"><div class="eyebrow">A COLLECTION OF GOOD BOOKS</div><h2>${list.name}</h2><p>${list.description}</p><p class="source-tag">${list.source} · ${list.ids.length} 本 · 示例成员</p></div><div class="row-list">${list.ids.map(id=>bookRow(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="add-list" data-id="${id}">加入阅读目标</button></div>${list.source.startsWith('公开')?'<p class="hint section-space">公开书单成员为设计示意,完整成员获取方式待真实接口验证。</p>':''}`);
|
||||||
|
}
|
||||||
|
function showDetail(title, html) { $('#detail-title').textContent=title;$('#detail-content').innerHTML=html;if(!$('#detail').open)$('#detail').showModal(); }
|
||||||
|
function openGoalDetail(id) {
|
||||||
|
const goal=goals.find(g=>g.id===id), m=goalMetrics(goal);
|
||||||
|
showDetail('阅读目标',`<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${goal.start} — ${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>`);
|
||||||
|
}
|
||||||
|
function openGoalEditor(id=null, prefill=[], sources=[]) {
|
||||||
|
$('#detail').close(); editingGoalId=id;
|
||||||
|
const goal=id?goals.find(g=>g.id===id):null;
|
||||||
|
draftIds=new Set(goal?goal.ids:prefill);draftSources=[...(goal?goal.sources:sources)];
|
||||||
|
$('#editor-title').textContent=goal?'编辑阅读目标':'新建阅读目标';$('#goal-name').value=goal?goal.name:'';
|
||||||
|
$('#goal-start').value=goal?goal.start:today();$('#goal-end').value=goal?goal.end:`${today().slice(0,4)}-12-31`;
|
||||||
|
$('#goal-end').min=$('#goal-start').value;$('#goal-end').setCustomValidity('');$('#goal-name').setCustomValidity('');
|
||||||
|
$('#picker-search').value='';$('#goal-error').textContent='';renderPicker();$('#goal-editor').showModal();
|
||||||
|
}
|
||||||
|
function selectedGoalBooks() { return uniqueIds([...draftIds]); }
|
||||||
|
function renderPicker() {
|
||||||
|
const query=$('#picker-search').value.trim().toLowerCase();
|
||||||
|
const list=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(query));
|
||||||
|
$('#selection-count').textContent=`已选 ${draftIds.size} 本 · ${[...draftIds].filter(id=>findBook(id).status==='done').length} 本已读完`;
|
||||||
|
$('#goal-picker').innerHTML=`<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l=>!query||l.name.includes(query)||l.ids.some(id=>list.some(b=>b.id===id))).map(l=>`<button type="button" data-action="pick-list" data-id="${l.id}" class="${l.ids.every(id=>draftIds.has(id))?'active':''}">${l.ids.every(id=>draftIds.has(id))?'✓':'+'} ${l.name} · ${l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(b=>`<label class="pick-row"><input type="checkbox" data-pick-book="${b.id}" ${draftIds.has(b.id)?'checked':''}><span><b>${b.title}</b><small>${b.author}</small></span><span class="pill ${b.status==='done'?'green':''}">${b.status==='done'?'已读完':b.status==='wish'?'想读':b.progress+'%'}</span></label>`).join('')||'<div class="empty">没有匹配的书籍</div>'}</div>`;
|
||||||
|
}
|
||||||
|
function saveGoal(event) {
|
||||||
|
event.preventDefault();const name=$('#goal-name').value.trim(),start=$('#goal-start').value,end=$('#goal-end').value,ids=selectedGoalBooks();
|
||||||
|
if(!name){$('#goal-name').setCustomValidity('请输入目标名称');$('#goal-name').reportValidity();return;}
|
||||||
|
if(end<start){$('#goal-error').textContent='结束日期不能早于开始日期。';return;}
|
||||||
|
if(!ids.length){$('#goal-error').textContent='请至少选择一本书,或加入一个书单。';return;}
|
||||||
|
const goal={id:editingGoalId||crypto.randomUUID(),name,start,end,ids,sources:[...new Set(draftSources)]};
|
||||||
|
const next=editingGoalId?goals.map(g=>g.id===editingGoalId?goal:g):[goal,...goals];
|
||||||
|
if(!persistGoals(next))return;
|
||||||
|
$('#goal-editor').close();navigate('goals');toast('阅读目标已保存,已读完的书已计入完成。');
|
||||||
|
}
|
||||||
|
function chooseTarget(ids,sources=[]) {
|
||||||
|
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>${g.start} — ${g.end} · ${g.ids.length} 本</p></div><button class="secondary" data-action="append-goal" data-id="${esc(g.id)}">加入</button></div>`).join('')}</div><div class="goal-actions"><button class="primary" data-action="new-with-selection">+ 新建目标</button></div>`);
|
||||||
|
pendingBooks=ids;pendingSources=sources;
|
||||||
|
}
|
||||||
|
let pendingBooks=[],pendingSources=[];
|
||||||
|
function appendGoal(id) {
|
||||||
|
const next=goals.map(g=>g.id===id?{...g,ids:uniqueIds([...g.ids,...pendingBooks]),sources:[...new Set([...g.sources,...pendingSources])]}:g);
|
||||||
|
try { localStorage.setItem(storageKey,JSON.stringify(next)); } catch { toast('无法保存到浏览器,请允许本地存储后重试。');return; }
|
||||||
|
goals=next;renderPage();openGoalDetail(id);toast('已加入目标,重复书籍已自动去重。');
|
||||||
|
}
|
||||||
|
function renderDiscover() {
|
||||||
|
const options=[['books','书籍'],['lists','书单'],['recommend','为你推荐'],['all','综合'],['author','作者'],['novel','网文'],['audio','听书'],['fulltext','全文'],['account','公众号'],['article','文章']];
|
||||||
|
const matched=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
let content='';
|
||||||
|
if(dataState!=='ready') {
|
||||||
|
const copy={loading:['正在寻找好书','加载状态示意,点击下方按钮查看结果。'],empty:['暂时没有找到','换个关键词,或看看为你推荐。'],error:['暂时无法获取数据','连接失败示意,你的阅读目标仍保存在本地。']}[dataState];
|
||||||
|
content=`<div class="card status-box"><div class="eyebrow">${dataState==='loading'?'LOADING':dataState==='error'?'TRY AGAIN':'NOT FOUND'}</div><h3>${copy[0]}</h3><p>${copy[1]}</p><button class="secondary" data-action="retry">${dataState==='error'?'重试':'查看演示结果'}</button></div>`;
|
||||||
|
} else if(discoverTab==='lists') content=renderLists(booklists.filter(l=>`${l.name}${l.description}`.includes(searchQuery)));
|
||||||
|
else if(discoverTab==='books'||discoverTab==='recommend') content=`${discoverTab==='recommend'?'<div class="discovery-hero"><div class="eyebrow">PICKED FOR YOUR NEXT CHAPTER</div><h2>读过的书,会带你遇见下一本。</h2><p>根据你对文学与心理学的兴趣推荐 · 推荐理由为示例</p></div>':''}<div class="card panel"><div class="books full-books">${(discoverTab==='recommend'?matched.filter(b=>b.status==='wish'||b.id==='b0'):matched).map(bookCard).join('')||'<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
|
||||||
|
else if(discoverTab==='author') content=`<div class="card panel">${[...new Set(matched.map(b=>b.author))].map(name=>`<div class="book-row"><div class="avatar">${name.slice(0,1)}</div><div class="book-row-text"><b>${name}</b><p>作者结果 · 示例</p></div><button class="text-button" data-action="author-books" data-name="${esc(name)}">查看书籍 →</button></div>`).join('')||'<div class="empty">没有匹配的作者。</div>'}</div>`;
|
||||||
|
else if(discoverTab==='all') content=`<div class="section-head"><h2>书籍</h2></div><div class="card panel"><div class="books">${matched.slice(0,4).map(bookCard).join('')||'<div class="empty">没有匹配的书籍。</div>'}</div></div><div class="section-head section-space"><h2>书单</h2></div>${renderLists(booklists.filter(l=>l.name.includes(searchQuery)))}`;
|
||||||
|
else {
|
||||||
|
const examples={novel:['星河来信','网文小说 · 示例作品','连载中 · 128 章'],audio:['夜晚的文学课','有声专辑 · 示例演播者','24 集 · 内容已完结'],fulltext:['关于阅读的一段文字','全文搜索 · 原创示例文本','阅读让日常拥有了更宽阔的边界。'],account:['一页书房','公众号 · 示例账号','记录阅读、日常与思考'],article:['如何为自己留一点阅读时间','文章 · 原创示例','2026-09-11 · 一页书房']}[discoverTab];
|
||||||
|
const matches=!searchQuery||examples.join('').includes(searchQuery);
|
||||||
|
content=matches?`<div class="card media-card"><div class="media-icon">${discoverTab==='audio'?'♫':'▤'}</div><div><h3>${examples[0]}</h3><p>${examples[1]}</p><p>${examples[2]}</p><button class="text-button" data-action="search-example" data-id="${discoverTab}">查看结果示意 →</button></div></div>`:'<div class="card empty">没有匹配的演示内容,可清空关键词查看该类型示意。</div>';
|
||||||
|
}
|
||||||
|
return welcome('下一本,遇见什么?','寻找一本书,也发现一份恰好适合你的书单。')+
|
||||||
|
`<div class="toolbar"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="discover-search" aria-label="搜索发现内容" placeholder="书名、书单或关键词…" value="${esc(searchQuery)}"></label><label class="hint">预览状态 <select id="data-state" aria-label="预览数据状态">${[['ready','正常'],['loading','加载中'],['empty','空结果'],['error','失败']].map(([id,label])=>`<option value="${id}" ${dataState===id?'selected':''}>${label}</option>`).join('')}</select></label></div>${tabs(options,discoverTab,'discover-tab')}<div id="discover-results">${content}</div><p class="hint section-space">搜索、推荐、书单均为设计样例,未请求微信读书服务。</p>`;
|
||||||
|
}
|
||||||
|
function renderNotes() {
|
||||||
|
const list=personalNotes.filter(note=>(noteBook==='all'||note.bookId===noteBook)&&(noteType==='all'||note.type===noteType));
|
||||||
|
const counts=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook);
|
||||||
|
const bookmarks=noteBook==='all'?6:noteBook==='b0'?3:noteBook==='b1'?2:noteBook==='b2'?1:0;
|
||||||
|
return welcome('把读过的,留在心里','划线、想法与每一次停顿,都有自己的意义。','<button class="secondary" data-action="export-notes">↓ 导出当前书目笔记</button>')+
|
||||||
|
`<div class="notebook-grid"><aside class="card panel notebook-menu"><button data-action="notebook" data-id="all" class="${noteBook==='all'?'active':''}"><b>全部笔记本</b><small>4 本书 · 10 条笔记</small></button>${books.slice(0,4).map((b,i)=>`<button data-action="notebook" data-id="${b.id}" class="${noteBook===b.id?'active':''}"><b>${b.title}</b><small>${[4,3,2,1][i]} 条 · 含书签数量</small></button>`).join('')}</aside><div><div class="metrics-row"><span><strong>${counts.length+bookmarks}</strong>条笔记</span><span>划线 ${counts.filter(n=>n.type==='highlight').length}</span><span>想法 ${counts.filter(n=>n.type==='thought').length}</span><span>书签 ${bookmarks}</span></div>${tabs([['all','全部内容'],['highlight','划线'],['thought','想法 / 个人点评'],['bookmark','书签数量']],noteType,'note-type')}${noteType==='bookmark'?`<div class="card empty">${bookmarks} 个书签 · 当前接口只提供数量,不提供书签内容。</div>`:list.map(note=>`<article class="note-content"><span class="pill ${note.type==='thought'?'green':''}">${note.type==='thought'?'我的想法':'划线示意'}</span><blockquote>${note.text}</blockquote><small>《${findBook(note.bookId).title}》 · ${note.date}</small><p class="hint">原创示例文本,非书中原文</p><button class="text-button section-space" data-action="personal-note" data-id="${note.id}">查看笔记详情 →</button></article>`).join('')||'<div class="card empty">当前筛选没有笔记。</div>'}<p class="hint">总笔记数包含书签;导出包含划线和个人想法,不含公开点评及书签内容。</p></div></div>`;
|
||||||
|
}
|
||||||
|
function exportNotes() {
|
||||||
|
// 导出按当前选中书目取划线和想法全集,内容类型筛选不影响导出完整性。
|
||||||
|
const notes=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook);
|
||||||
|
const text='# 阅间笔记 · 演示数据\n\n原创示例,非书中原文。书签仅提供数量,不导出内容。\n\n'+notes.map(n=>`## ${findBook(n.bookId).title}\n\n${n.type==='thought'?'想法':'划线示意'} · ${n.date}\n\n${n.text}`).join('\n\n');
|
||||||
|
const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'}));
|
||||||
|
showDetail('导出笔记', `<p class="hint">已生成当前书目的划线与想法。可下载 Markdown,或直接选中下方内容复制;不含书签内容。</p><textarea readonly class="export-preview" aria-label="笔记导出内容">${esc(text)}</textarea><div class="goal-actions"><a class="primary" style="text-decoration:none" href="${url}" download="阅间-演示笔记.md">下载 Markdown</a></div>`);
|
||||||
|
$('#detail').addEventListener('close',()=>URL.revokeObjectURL(url),{once:true});
|
||||||
|
}
|
||||||
|
function openBook(id) { detailBook=id;bookTab='info';selectedChapter='all';renderBookDetail(); }
|
||||||
|
function renderBookDetail() {
|
||||||
|
const book=findBook(detailBook);
|
||||||
|
showDetail('书籍详情',`<div class="detail-body">${cover(book)}<div class="detail-info"><div class="eyebrow">READING SPACE / BOOK</div><h3>${book.title}</h3><p>${book.author}</p><p style="color:var(--green)">评分 ${Math.round(Number(book.score)*10)}/100 · 示例评分</p><p>${book.status==='done'?'已读完':`已读 ${book.progress}%`} · 累计 2小时18分钟</p><button class="primary" data-action="add-book" data-id="${book.id}">+ 加入阅读目标</button></div></div><div class="detail-tabs">${[['info','简介'],['chapters','目录'],['notes','我的笔记'],['popular','热门划线'],['reviews','读者点评'],['similar','相似推荐']].map(([id,label])=>`<button data-action="book-tab" data-id="${id}" class="${bookTab===id?'active':''}">${label}</button>`).join('')}</div><div id="book-tab-content">${renderBookTab(book)}</div><p class="hint section-space">全部为设计示例 · 目录、文字和读者资料非真实接口内容。</p>`);
|
||||||
|
}
|
||||||
|
function renderBookTab(book) {
|
||||||
|
if(bookTab==='info')return `<p class="detail-desc">${book.desc}</p><div class="book-details-grid section-space"><div><span>分类:</span>${book.id==='b1'?'科幻':'文学 / 人文'} · 示例</div><div><span>最近阅读:</span>2026-09-11</div><div><span>出版社:</span>示例出版社</div><div><span>译者 / ISBN:</span>暂无演示数据</div><div><span>字数:</span>约 20 万字 · 示例</div><div><span>出版日期:</span>暂无演示数据</div></div>`;
|
||||||
|
if(bookTab==='chapters')return `<p class="hint">目录结构示意;选择章节可查看该章热门划线与热度。</p>${['序章 · 故事的开始','第一章 · 在旅途中','第二章 · 看见新的世界','第三章 · 留给自己的答案'].map((name,i)=>`<div class="chapter-row"><span>${String(i+1).padStart(2,'0')} ${name}<small style="color:var(--muted)"> · 示例章节</small></span><button class="text-button" data-action="chapter" data-id="${i}">划线热度 →</button></div>`).join('')}`;
|
||||||
|
if(bookTab==='notes') {
|
||||||
|
const notes=personalNotes.filter(n=>n.bookId===book.id);
|
||||||
|
return notes.map(n=>`<div class="note-content"><span class="pill">${n.type==='thought'?'个人想法':'划线示意'}</span><blockquote>${n.text}</blockquote><small>${n.date} · 原创示例文本</small></div>`).join('')||'<div class="detail-empty">这本书暂无个人笔记示例。</div>';
|
||||||
|
}
|
||||||
|
if(bookTab==='popular')return `<div class="toolbar"><span class="hint">${selectedChapter==='all'?'全书':`示例章节 ${Number(selectedChapter)+1}`} · 热门划线最多展示 20 条</span><select id="chapter-filter" aria-label="选择热门划线章节"><option value="all">全部章节</option>${[0,1,2,3].map(i=>`<option value="${i}" ${String(i)===selectedChapter?'selected':''}>示例章节 ${i+1}</option>`).join('')}</select></div>${['阅读让我们有机会,在自己的生活之外再生活一次。','那些认真思考过的问题,会在未来的某一天回应你。'].map((text,i)=>`<article class="note-content"><span class="pill">${[1286,862][i]} 人划线 · 示例热度</span><blockquote>${text}</blockquote><small>原创示例文本,非书中原文</small><div class="section-space"><button class="text-button" data-action="highlight-thoughts" data-id="${i}">查看 ${[12,8][i]} 条想法 →</button></div></article>`).join('')}<p class="hint">章节热度提供人数与位置,原文来自热门划线;这里用示例文本说明展示方式。</p>`;
|
||||||
|
if(bookTab==='reviews')return tabs([['全部','全部'],['推荐','推荐'],['一般','一般'],['不行','不行'],['最新','最新']],reviewFilter,'review-filter')+publicReviews.filter(r=>reviewFilter==='全部'||reviewFilter==='最新'||r.type===reviewFilter).map(review=>`<article class="review-row"><div class="meta"><b>${review.name} · 示例读者</b><span>${'★'.repeat(review.rating)}${'☆'.repeat(5-review.rating)}</span></div><p>${review.text}</p><div class="section-head section-space"><small class="hint">${review.date} · 公开点评示例</small><button class="text-button" data-action="review-detail" data-id="${review.id}">查看详情 →</button></div></article>`).join('');
|
||||||
|
return `<p class="hint">相似推荐示例 · 根据主题与阅读兴趣推荐</p><div class="books section-space">${books.filter(b=>b.id!==book.id).slice(0,4).map(bookCard).join('')}</div>`;
|
||||||
|
}
|
||||||
|
function openThought(text, subtitle) { showDetail('想法详情',`<article class="note-content"><span class="pill">示例内容</span><blockquote>${esc(text)}</blockquote><small>${esc(subtitle)} · 2026-09-11</small></article><p class="hint">这里展示单条想法的完整内容;不生成未经接口提供的评论或点赞列表。</p>`); }
|
||||||
|
function handleAction(button) {
|
||||||
|
const action=button.dataset.action,id=button.dataset.id;
|
||||||
|
switch(action) {
|
||||||
|
case 'page':navigate(id);break;
|
||||||
|
case 'new-goal':openGoalEditor();break;
|
||||||
|
case 'edit-goal':openGoalEditor(id);break;
|
||||||
|
case 'goal-detail':openGoalDetail(id);break;
|
||||||
|
case 'goal-filter':goalFilter=id;renderPage();break;
|
||||||
|
case 'close-detail':$('#detail').close();break;
|
||||||
|
case 'close-editor':$('#goal-editor').close();break;
|
||||||
|
case 'pick-list':{
|
||||||
|
const list=booklists.find(l=>l.id===id);list.ids.forEach(bookId=>draftIds.add(bookId));
|
||||||
|
if(!draftSources.includes(list.name))draftSources.push(list.name);renderPicker();break;
|
||||||
|
}
|
||||||
|
case 'book':openBook(id);break;
|
||||||
|
case 'list':openList(id);break;
|
||||||
|
case 'add-book':chooseTarget([id]);break;
|
||||||
|
case 'add-list':{const list=booklists.find(l=>l.id===id);chooseTarget(list.ids,[list.name]);break;}
|
||||||
|
case 'append-goal':appendGoal(id);break;
|
||||||
|
case 'new-with-selection':openGoalEditor(null,pendingBooks,pendingSources);break;
|
||||||
|
case 'shelf-tab':shelfTab=id;renderPage();break;
|
||||||
|
case 'period':period=id;renderPage();break;
|
||||||
|
case 'discover-tab':discoverTab=id;renderPage();break;
|
||||||
|
case 'retry':dataState='ready';renderPage();break;
|
||||||
|
case 'notebook':noteBook=id;renderPage();break;
|
||||||
|
case 'note-type':noteType=id;renderPage();break;
|
||||||
|
case 'export-notes':exportNotes();break;
|
||||||
|
case 'personal-note':{const note=personalNotes.find(n=>n.id===id);openThought(note.text,findBook(note.bookId).title+' · 个人笔记示例');break;}
|
||||||
|
case 'book-tab':bookTab=id;renderBookDetail();break;
|
||||||
|
case 'chapter':selectedChapter=id;bookTab='popular';renderBookDetail();break;
|
||||||
|
case 'review-filter':reviewFilter=id;renderBookDetail();break;
|
||||||
|
case 'review-detail':{const r=publicReviews.find(r=>r.id===id);openThought(r.text,r.name+' · 公开点评示例');break;}
|
||||||
|
case 'highlight-thoughts':showDetail('划线下的想法',`<div class="note-original">${id==='0'?'阅读让我们有机会,在自己的生活之外再生活一次。':'那些认真思考过的问题,会在未来的某一天回应你。'}<p class="hint">原创划线示意</p></div><article class="review-row"><div class="meta"><b>山间来信 · 示例读者</b><span>2026-09-11</span></div><p>这句话让我想起,每一段阅读经历都有自己的时间。</p><button class="text-button section-space" data-action="thought-detail">查看完整想法 →</button></article><p class="hint">仅展示一条想法示例,真实内容需分页获取。</p>`);break;
|
||||||
|
case 'thought-detail':openThought('这句话让我想起,每一段阅读经历都有自己的时间。','山间来信 · 公开想法示例');break;
|
||||||
|
case 'author-books':searchQuery=button.dataset.name;discoverTab='books';$('#global-search').value=searchQuery;renderPage();break;
|
||||||
|
case 'article-search':searchQuery='';discoverTab='article';navigate('discover');break;
|
||||||
|
case 'search-example':openThought('这是此类搜索结果的详情布局示意。实际内容、可跳转地址及字段以未来接口返回为准。','搜索结果 · '+id);break;
|
||||||
|
case 'year-report':showDetail('年度回顾',`<div class="discovery-hero"><div class="eyebrow">2026 / A YEAR IN BOOKS</div><h2>翻过的每一页,都算数。</h2><p>128小时42分钟 · 132 个阅读日 · 示例年度数据</p></div><p class="detail-desc">偏爱文学,习惯在夜晚阅读。每一次阅读,都是给自己的时间。</p><p class="hint section-space">年报布局示意,真实年度报告入口只使用服务端返回的链接。</p>`);break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click',event=>{
|
||||||
|
const action=event.target.closest('[data-action]');if(action){handleAction(action);return;}
|
||||||
|
const nav=event.target.closest('[data-page]');if(nav){searchQuery='';$('#global-search').value='';navigate(nav.dataset.page);}
|
||||||
|
});
|
||||||
|
document.addEventListener('change',event=>{
|
||||||
|
if(event.target.matches('[data-pick-book]')){const id=event.target.dataset.pickBook;event.target.checked?draftIds.add(id):draftIds.delete(id);renderPicker();}
|
||||||
|
if(event.target.id==='data-state'){dataState=event.target.value;renderPage();}
|
||||||
|
if(event.target.id==='chapter-filter'){selectedChapter=event.target.value;renderBookDetail();}
|
||||||
|
});
|
||||||
|
$('#goal-start').onchange=()=>{$('#goal-end').min=$('#goal-start').value;};
|
||||||
|
$('#goal-name').oninput=()=>$('#goal-name').setCustomValidity('');
|
||||||
|
$('#goal-form').onsubmit=saveGoal;$('#picker-search').oninput=renderPicker;
|
||||||
|
$('#global-search').oninput=event=>{searchQuery=event.target.value.trim();dataState='ready';if(page!=='discover')discoverTab='books';navigate('discover');};
|
||||||
|
document.addEventListener('input',event=>{
|
||||||
|
if(event.target.id==='discover-search'){
|
||||||
|
const position=event.target.selectionStart;searchQuery=event.target.value;$('#global-search').value=searchQuery;renderPage();
|
||||||
|
$('#discover-search').focus();$('#discover-search').setSelectionRange(position,position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown',event=>{if((event.metaKey||event.ctrlKey)&&event.key==='k'){event.preventDefault();$('#global-search').focus();}});
|
||||||
|
// 原生 dialog 提供焦点管理与 Escape 关闭;只有点击实际遮罩时才关闭。
|
||||||
|
document.querySelectorAll('dialog').forEach(dialog=>dialog.addEventListener('click',event=>{
|
||||||
|
const rect=dialog.getBoundingClientRect();if(event.target===dialog&&(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom))dialog.close();
|
||||||
|
}));
|
||||||
|
navigate('overview');if(storageWarning)toast(storageWarning);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// 所有数据均为设计示例,不来自真实账号。
|
||||||
|
const books = [{title:'也许你该找个人聊聊',short:'也许你该\n找个人\n聊聊',author:'洛莉·戈特利布',en:'MAYBE YOU SHOULD TALK TO SOMEONE',cover:'c1',progress:68,status:'reading',score:'9.1',desc:'一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。'},{title:'三体',short:'三体',author:'刘慈欣',en:'THE THREE-BODY PROBLEM',cover:'c2',progress:42,status:'reading',score:'9.4',desc:'从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。'},{title:'蛤蟆先生去看心理医生',short:'蛤蟆先生\n去看\n心理医生',author:'罗伯特·戴博德',en:'COUNSELLING FOR TOADS',cover:'c3',progress:85,status:'reading',score:'8.8',desc:'借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。'},{title:'人生海海',short:'人生\n海海',author:'麦家',en:'LIFE IS A VAST SEA',cover:'c4',progress:26,status:'reading',score:'9.0',desc:'在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。'},{title:'活着',short:'活着',author:'余华',en:'TO LIVE',cover:'c3',progress:100,status:'done',score:'9.5',desc:'一个人与时代相遇的故事,关于生命的韧性与日常的重量。'},{title:'被讨厌的勇气',short:'被讨厌\n的勇气',author:'岸见一郎 / 古贺史健',en:'THE COURAGE TO BE DISLIKED',cover:'c1',progress:100,status:'done',score:'8.6',desc:'以对话的方式讨论人际关系、自我接纳与选择的自由。'},{title:'悉达多',short:'悉达多',author:'赫尔曼·黑塞',en:'SIDDHARTHA',cover:'c4',progress:0,status:'wish',score:'9.2',desc:'一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。'},{title:'置身事内',short:'置身\n事内',author:'兰小欢',en:'INSIDE THE SYSTEM',cover:'c2',progress:0,status:'wish',score:'9.3',desc:'从具体的经济生活出发,理解政府与经济发展的关系。'}];
|
||||||
|
books.forEach((book, index) => { book.id = `b${index}`; });
|
||||||
|
const booklists = [
|
||||||
|
{id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']},
|
||||||
|
{id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']},
|
||||||
|
{id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']},
|
||||||
|
{id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']}
|
||||||
|
];
|
||||||
|
const initialGoals = [
|
||||||
|
{id:'g1',name:'秋季文学计划',start:'2026-09-01',end:'2026-11-30',ids:['b3','b4','b6','b1'],sources:['在故事里,读懂人生']},
|
||||||
|
{id:'g2',name:'给内心一点生长的空间',start:'2026-09-01',end:'2026-10-31',ids:['b0','b2','b5'],sources:['心理学,向内走一程']},
|
||||||
|
{id:'g3',name:'读完的书,成为自己的底气',start:'2026-08-01',end:'2026-08-31',ids:['b4','b5'],sources:[]}
|
||||||
|
];
|
||||||
|
const personalNotes = [
|
||||||
|
{id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'},
|
||||||
|
{id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'},
|
||||||
|
{id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'},
|
||||||
|
{id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'}
|
||||||
|
];
|
||||||
|
const publicReviews = [
|
||||||
|
{id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'},
|
||||||
|
{id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'},
|
||||||
|
{id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'}
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>阅间 · 微信读书看板</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg style="display:none" aria-hidden="true"><defs>
|
||||||
|
<symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol>
|
||||||
|
</defs></svg>
|
||||||
|
<aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 演示空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">L</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><span>目标保存在当前浏览器</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span>独立设计 Demo · 未连接微信读书账号</span></footer></main>
|
||||||
|
<dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog>
|
||||||
|
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>仅保存在此浏览器</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
|
||||||
|
<div id="toast" class="hidden" role="status"></div><script src="demo-data.js"></script><script src="app.js"></script></body></html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
:root{--bg:#f7f8fa;--panel:#fff;--text:#242a34;--muted:#9298a4;--line:#edf0f3;--accent:#356ce5;--soft:#edf3ff;--sidebar:#fff;--shadow:0 8px 32px #24355005;--quote:#f8f6f1;--green:#449682}
|
||||||
|
[data-theme=dark]{--bg:#14171e;--panel:#1d222c;--text:#e7eaf0;--muted:#8892a4;--line:#2c323f;--accent:#80a5ff;--soft:#26344e;--sidebar:#191d26;--shadow:0 8px 32px #0001;--quote:#292820;--green:#75c6ac;color-scheme:dark}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;transition:background .25s,color .25s}button,input{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:4px}button{border:0;background:none}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}.sidebar{position:fixed;inset:0 auto 0 0;width:218px;padding:39px 23px 24px;background:var(--sidebar);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:5}.logo{display:flex;align-items:center;gap:11px;font-size:24px;font-weight:650;letter-spacing:2px}.logo-mark{background:#356ce5;color:white;width:37px;height:37px;display:grid;place-items:center;border-radius:12px}.logo-mark svg{width:25px;height:25px}.logo small{display:block;letter-spacing:2px;font-size:9px;color:var(--muted);margin-top:3px;font-weight:500}.navlabel{font-size:10px;color:var(--muted);letter-spacing:1.7px;margin:53px 14px 15px}.nav{display:flex;flex-direction:column;gap:9px}.nav button{text-align:left;padding:14px 17px;border-radius:9px;display:flex;align-items:center;gap:13px;color:#818997}.nav button.active{color:var(--accent);background:var(--soft);font-weight:600}.nav button:hover{background:var(--soft)}.nav em{margin-left:auto;font-size:10px;font-style:normal;background:var(--line);padding:2px 6px;border-radius:4px}.sidebar-bottom{margin-top:auto}.connection{padding:16px 12px;border:1px solid var(--line);border-radius:10px;font-size:12px;line-height:1.9}.dot{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:7px}.connection small{display:block;font-size:10px;color:var(--muted)}.profile{display:flex;align-items:center;gap:10px;margin-top:24px;padding:0 8px}.avatar{width:33px;height:33px;border-radius:50%;display:grid;place-items:center;background:#e4eadf;color:#5e7252;font-family:Georgia;font-size:18px}.profile small{display:block;font-size:10px;color:var(--muted);margin-top:4px}.main{margin-left:218px;padding:0 40px 32px;max-width:1800px}.topbar{height:91px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.breadcrumb{font-size:12px;color:var(--muted)}.breadcrumb span{color:var(--text);margin-left:12px}.top-actions{display:flex;align-items:center;gap:20px}.searchbox{display:flex;align-items:center;gap:10px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:9px 12px}.searchbox input{background:none;border:0;outline:none;width:180px;color:var(--text);font-size:11px}.key{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:2px 4px}.iconbtn{padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.welcome{display:flex;justify-content:space-between;align-items:center;margin:33px 0 27px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);margin-bottom:10px}h1{font-size:27px;letter-spacing:.5px;margin:0 0 10px;font-weight:600}h1 span{font-family:Georgia;font-size:25px;color:#c3a977}p{margin:0;color:var(--muted);font-size:12px;line-height:1.8}.welcome-right{display:flex;gap:12px;align-items:center}.demo-tag{font-size:10px;padding:6px 9px;background:var(--quote);border:1px solid var(--line);border-radius:5px;color:var(--muted)}.primary{background:#356ce5;color:white;padding:11px 16px;border-radius:7px;font-size:12px}.primary svg{width:15px;height:15px;margin-right:6px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:23px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stat{padding:21px 23px}.stat-label{display:flex;justify-content:space-between;align-items:center;color:var(--muted);font-size:12px}.stat-label svg{width:17px;height:17px;color:#9aa8bf}.stat-value{font-family:Georgia,serif;font-size:34px;letter-spacing:-1px;margin:14px 0 12px}.stat-value small{font-family:"PingFang SC",sans-serif;font-size:11px;color:var(--muted);letter-spacing:0;margin-left:5px}.stat-foot{font-size:10px;color:var(--muted)}.stat-foot b{color:var(--green);font-weight:500;margin-right:7px}.columns{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:23px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.section-head h2{font-size:15px;font-weight:600;margin:0}.section-head h2 small{font-weight:400;color:var(--muted);font-size:10px;margin-left:10px}.text-button{font-size:11px;color:var(--muted);padding:3px}.text-button:hover{color:var(--accent)}.trend{padding:24px 25px;margin-bottom:23px}.segmented{background:var(--bg);padding:3px;display:flex;border-radius:6px}.segmented button{font-size:10px;padding:5px 12px;color:var(--muted);border-radius:4px}.segmented button.active{background:var(--panel);coloLine truncated
|
||||||
|
/* 阅读目标与内容页:与既有主题变量共用色彩。 */
|
||||||
|
:root{--muted:#717b8b}[data-theme=dark]{--muted:#a2adbf}h3{font-size:14px;margin:0}button:disabled{opacity:.45;cursor:not-allowed}.demo-banner{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:18px}.welcome{margin-top:26px}.page-actions{display:flex;align-items:center;gap:10px}.secondary{padding:10px 15px;border:1px solid var(--line);background:var(--panel);border-radius:7px;font-size:12px}.pill{display:inline-block;padding:5px 8px;font-size:10px;border-radius:5px;background:var(--soft);color:var(--accent);white-space:nowrap}.pill.green{background:#6a9a8219;color:var(--green)}.pill.amber{background:#c3923919;color:#b78a3f}.hint{font-size:11px;color:var(--muted);line-height:1.8}.panel{padding:24px}.panel+.panel{margin-top:20px}.toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:0 0 22px}.toolbar .tabs{margin:0;flex-wrap:wrap}.toolbar input,.toolbar select,.field input,select{border:1px solid var(--line);border-radius:7px;padding:10px 12px;background:var(--panel);color:var(--text);font:inherit;font-size:12px}.toolbar select{max-width:100%}.field{display:flex;flex-direction:column;gap:8px;font-size:12px;margin-bottom:18px}.field input{width:100%;background:var(--bg)}.date-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px}.metrics-row{display:flex;gap:24px;padding:18px 0;margin-bottom:10px;color:var(--muted);font-size:11px}.metrics-row strong{font-family:Georgia;font-size:28px;color:var(--text);margin-right:6px;font-weight:400}.goals-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.goal-card{padding:25px;position:relative;overflow:hidden}.goal-card:before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--green)}.goal-card h3{font-size:19px;margin:17px 0 8px;word-break:break-word}.goal-card .section-head{margin-bottom:0}.goal-date{font-size:11px;color:var(--muted)}.cover-stack{display:flex;gap:0;margin:24px 0 20px;height:92px;padding-left:4px}.mini-cover{width:66px;height:92px;padding:10px 8px;border-radius:2px 4px 4px 2px;box-shadow:4px 5px 10px #0002;display:flex;flex-direction:column;flex-shrink:0;position:relative;overflow:hidden;border-left:3px solid #0002}.cover-stack .mini-cover{margin-left:-4px;transform:rotate(-3deg);transform-origin:bottom}.cover-stack .mini-cover:nth-child(2n){transform:rotate(4deg)}.mini-cover b{font-family:'Songti SC',serif;font-size:12px;position:relative;z-index:1;line-height:1.5}.mini-cover small{font-size:6px;position:relative;margin-top:auto}.stack-more{align-self:center;margin-left:18px;color:var(--muted);font-size:12px}.goal-progress{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--muted);margin:12px 0 9px}.goal-progress strong{font-size:22px;color:var(--text);font-family:Georgia;font-weight:400}.goal-card .progress{height:5px;margin:0 0 19px}.goal-card .progress i{background:var(--green)}.goal-card-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding-top:16px}.goal-card-footer small{font-size:10px;color:var(--muted)}.goal-card-footer button{font-size:11px;color:var(--accent)}.goal-intro{display:grid;grid-template-columns:1fr auto;gap:28px;background:var(--quote);padding:26px 30px;margin-bottom:24px;border:1px solid var(--line);border-radius:12px}.goal-intro h2{font-family:'Songti SC',serif;font-size:23px;font-weight:500;margin:0 0 10px}.goal-intro p{max-width:500px}.goal-intro .symbol{font-family:Georgia;font-size:65px;align-self:center;color:#819583}.goal-summary-row{display:block;text-align:left;width:100%;padding:16px 0;border-top:1px solid var(--line)}.goal-summary-row strong{font-size:12px;font-weight:500}.goal-summary-row small{display:block;margin-top:7px;color:var(--muted);font-size:10px}.goal-summary-row .progress{margin-top:10px}.list-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.list-card{padding:21px;display:block;text-align:left;transition:transform .2s}.list-card:hover{transform:translateY(-3px)}.list-art{height:117px;display:flex;align-items:center;justify-content:center;background:var(--quote);border-radius:7px;margin-bottom:18px}.list-art .mini-cover:nth-child(2){transform:translateY(-5px);margin:0 -4px;z-index:1}.list-card h3{font-size:15px;margin-bottom:8px}.list-card p{font-size:11px}.list-card>small{display:block;color:var(--muted);font-size:10px;margin-top:15px}.full-books{grid-template-columns:repeat(5,minmax(0,1fr));gap:26px}.full-books .cover{height:168px;width:118px}.row-list{display:flex;flex-direction:column}.book-row{display:flex;align-items:center;gap:15px;padding:14px 0;border-bottom:1px solid var(--line)}.book-row:last-child{border:0}.book-row .mini-cover{width:42px;height:58px;padding:5px}.book-row .mini-cover b{font-size:8px}.book-row .mini-cover small{display:none}.book-row-text{flex:1;min-width:0}.book-row-text b{font-size:12px;font-weight:500}.book-row-text p{font-size:10pLine truncated
|
||||||
|
@media(max-width:1200px){.full-books{grid-template-columns:repeat(4,minmax(0,1fr))}.list-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.goals-grid{grid-template-columns:1fr}.columns>.rightcol{display:flex}.notebook-grid{grid-template-columns:1fr}.notebook-menu{display:flex;overflow:auto}.notebook-menu button{min-width:160px}.full-books{grid-template-columns:repeat(3,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sidebar .nav{gap:9px}.welcome{align-items:flex-start;gap:15px}.welcome h1{line-height:1.4}.welcome .primary{white-space:nowrap}.goal-intro{padding:22px}.demo-banner span:last-child{display:none}}@media(max-width:600px){.full-books{grid-template-columns:repeat(2,minmax(0,1fr))}.list-grid,.insight-grid{grid-template-columns:1fr}.goal-intro{grid-template-columns:1fr}.goal-intro .symbol{display:none}.goal-card{padding:20px}.welcome{flex-direction:column}.welcome .page-actions{align-self:flex-end}.panel{padding:18px}.date-fields{grid-template-columns:1fr;gap:0}.detail-body>.cover{width:95px;flex-shrink:0}.detail-body{gap:15px}.detail-info h3{font-size:18px}.book-details-grid{grid-template-columns:1fr}.toolbar input{width:100%}.metrics-row{gap:16px;flex-wrap:wrap}.metrics-row strong{font-size:23px}.goal-intro h2{font-size:20px}.cover-stack .mini-cover{width:57px}.stack-more{font-size:10px;margin-left:10px}.demo-banner{font-size:9px}.goal-card h3{font-size:17px}dialog{padding:20px}.editor-footer{gap:10px}.book-row{gap:9px}.book-row-text b{font-size:11px}}
|
||||||
|
.compact-value{font-family:Georgia,'PingFang SC',sans-serif;font-size:25px;line-height:1.5;letter-spacing:-.5px}.stat-foot{line-height:1.7}.insight-detail .insight-grid{margin-top:0}.sidebar .nav button[aria-current=page]{color:var(--accent)}
|
||||||
|
@media(max-width:600px){.compact-value{font-size:20px}.list-art .mini-cover{width:56px}.full-books .cover{width:105px;height:159px}.goal-card .section-head{gap:10px}.book-row .mini-cover{display:none}.detail-tabs{gap:16px}.date-fields input{min-height:40px}.detail-info .primary{font-size:11px;padding:9px}}
|
||||||
|
.mini-cover.c2,.mini-cover.c3{color:#fff5df}
|
||||||
|
.export-preview{width:100%;height:320px;margin-top:20px;border:1px solid var(--line);border-radius:8px;padding:18px;background:var(--bg);color:var(--text);font:12px/1.9 'PingFang SC',sans-serif;resize:vertical}
|
||||||
Reference in new issue
Block a user