书架页新增「最近在读」:构建时抓微信读书,顺带移除漫画/论文
数据链路 - scripts/fetch-reading.mjs:调腾讯官方 Agent Gateway (POST https://i.weread.qq.com/api/agent/gateway,Bearer wrk-...), /shelf/sync 拿书架,再对前 N 本调 /book/getprogress 拿进度, 写出 src/data/reading.json - src/lib/reading.ts:读 JSON + 按 YYYY-MM-DD 格式化(官方硬性要求), 文件缺失返回 null 让页面走空态而不是构建报错 - src/components/bookshelf-content.astro:只渲染,页面本身不发请求 接口实测得到的几个结论(都写进脚本注释和 README 了) - skill_version 必填,业务参数平铺顶层 - /shelf/sync 一次给全封面/书名/作者/分类/最近阅读时间/deepLink, 只有进度要额外请求,所以请求数 = 1 + N - progress 是 0-100 整数,只有 100 算读完 - recordReadingTime 恒为 0,所以卡片不做「累计时长」 - deepLink 实测是普通 https,浏览器能直接打开 失败兜底(这次最关键的一条) - pnpm reading:失败 exit 1,明确报错 - pnpm build:串的是 `fetch-reading.mjs --soft`,Key 失效/断网/接口挂了 都保留上一次的 reading.json 并 exit 0,不会阻塞部署 - 实测:错误 Key 时 pnpm reading exit 1;pnpm build 完整跑通 exit 0 且 reading.json 未被改动;不设 Key 也 exit 0 - process.loadEnvFile 不覆盖已存在的环境变量,所以 CI 设的 Key 优先于 .env Key 安全 - 只写进 .env(gitignore,权限 600),已确认 git status 里没有它 - deploy.sh 是本地构建再 rsync,凭据不会上传服务器 - .env.example 补了占位和申请地址 书架页改版 - 移除漫画/论文两个分类:删掉 tab 切换、两个视图、对应筛选和 JS, 956 行 -> 620 行;sidebar 的 tab 按钮改成 #reading / #books 页内锚点 - 新增「最近在读」区块置顶(Collection · 01),书籍顺延为 02 - 卡片:封面(hot-link 腾讯 CDN,referrerpolicy=no-referrer)+ 书名 + 作者 · 分类 + 进度条 + 最近阅读日期,整卡跳 weread.qq.com - 读完标「已读完」,0% 标「还没开始」 - 过滤私密阅读(secret=1)和从没打开过的书;有声书不处理 静态验证(dist/bookshelf/index.html) - 8 张在读卡片,链接均指向 weread.qq.com/book-detail - 8 条进度条宽度 30/0/77/100/28/13/97/0,与脚本输出一致 - 8 张封面全部 hot-link,无本地副本 - 漫画/论文/comics/papers/data-type-btn/switchType 均已从产物消失 - 其他页面(首页/时间线/关于/文章页)不含该区块,未受影响
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
# 评论系统:twikoo 后端地址。留空则整个评论区不渲染。
|
# 评论系统:twikoo 后端地址。留空则整个评论区不渲染。
|
||||||
# 默认值写在 src/data/comments.ts,这里设置会覆盖它。
|
# 默认值写在 src/data/comments.ts,这里设置会覆盖它。
|
||||||
PUBLIC_TWIKOO_ENV_ID=https://twikoo.tianxuetao.top
|
PUBLIC_TWIKOO_ENV_ID=https://twikoo.tianxuetao.top
|
||||||
|
|
||||||
|
# 微信读书「最近在读」:Agent API Key,scripts/fetch-reading.mjs 用。
|
||||||
|
# 申请:https://weread.qq.com/r/weread-skills 扫码后拿到 wrk- 开头的串。
|
||||||
|
# 不填的话 `pnpm reading` 会报错;`pnpm build` 会用上一次的数据继续构建。
|
||||||
|
WEREAD_API_KEY=
|
||||||
|
|||||||
@@ -273,15 +273,38 @@ prose-p:leading-[1.78] md:prose-p:leading-[1.85]
|
|||||||
> 移动端页眉目前**故意关掉**了常驻磨砂(`site-header.astro` 的 `@media (max-width: 767px)` 那段):固定元素持续模糊会让移动端合成开销变大。要开的话建议用「滚动后才开」的方式,别让它一直生效。
|
> 移动端页眉目前**故意关掉**了常驻磨砂(`site-header.astro` 的 `@media (max-width: 767px)` 那段):固定元素持续模糊会让移动端合成开销变大。要开的话建议用「滚动后才开」的方式,别让它一直生效。
|
||||||
|
|
||||||
|
|
||||||
## 书架数据
|
## 书架与「最近在读」
|
||||||
|
|
||||||
书架页的数据来自这三个文件,由 `src/components/bookshelf-content.astro` 在**构建时 import**(所以文件必须存在,删掉会导致构建失败):
|
书架页(`/bookshelf/`)有两块内容:
|
||||||
|
|
||||||
- `public/data/books.json`
|
**1. 最近在读** —— 来自微信读书,构建时抓取,页面本身不发任何请求。
|
||||||
- `public/data/papers.json`
|
|
||||||
- `public/data/comics.json`
|
|
||||||
|
|
||||||
目前都是空的 `{"data":{"items":[],"total":0}}`,所以书架页显示为空。要填充就按同样的结构往 `items` 里加记录。
|
```
|
||||||
|
微信读书 Agent Gateway
|
||||||
|
↓ scripts/fetch-reading.mjs(读 .env 的 WEREAD_API_KEY)
|
||||||
|
src/data/reading.json
|
||||||
|
↓ src/lib/reading.ts
|
||||||
|
src/components/bookshelf-content.astro
|
||||||
|
```
|
||||||
|
|
||||||
|
- 刷新数据:`pnpm reading`(失败会明确报错并 exit 1);`pnpm reading --top 12` 可改抓取条数(默认 8)
|
||||||
|
- **`pnpm build` 会自动先跑一次抓取,但用 `--soft` 模式**:微信读书挂了 / Key 过期 / 断网时,保留上一次的 `reading.json` 继续构建,**不会阻塞部署**
|
||||||
|
- `src/data/reading.json` 是数据快照,**要提交进 git**,这样 CI 或新克隆的仓库没 Key 也能构建
|
||||||
|
- Key 申请:<https://weread.qq.com/r/weread-skills> 扫码,拿到 `wrk-` 开头的串,写进 `.env` 的 `WEREAD_API_KEY`(`.env` 已 gitignore)。**只在本地构建**时用得上 —— `deploy.sh` 是本地构建再 rsync,凭据不会上传服务器;如果另外接了 Vercel / GitHub Actions 构建,要在那边单独配 secret
|
||||||
|
|
||||||
|
接口是腾讯官方的 Agent Gateway(`POST https://i.weread.qq.com/api/agent/gateway`)。几个实测得到的结论,改脚本前值得知道:
|
||||||
|
|
||||||
|
- 请求体里 `skill_version` **必填**,业务参数**平铺在顶层**,不能包进 `params`
|
||||||
|
- `/shelf/sync` 一次就给全封面/书名/作者/分类/最近阅读时间/跳转链接,**只有阅读进度**要额外调 `/book/getprogress`,所以请求数 = 1 + N
|
||||||
|
- `book.progress` 是 0–100 的整数,**只有 100 才算读完**
|
||||||
|
- `book.recordReadingTime` **恒为 0**,所以卡片不做「累计时长」
|
||||||
|
- `deepLink` 实测是普通 https(`https://weread.qq.com/book-detail?...`),浏览器能直接打开
|
||||||
|
- `category` 是 `文学-外国文学` 这种「大类-子类」复合串
|
||||||
|
- 时间戳统一按 `YYYY-MM-DD` 展示(官方硬性要求),顺带避开「N 天前」在静态站上会随构建时间变旧
|
||||||
|
|
||||||
|
展示逻辑:过滤掉私密阅读(`secret === 1`)和从没打开过的书;读完的照常显示,标「已读完」;0% 标「还没开始」;有声书不管。
|
||||||
|
|
||||||
|
**2. 书籍** —— 来自 `public/data/books.json`,由 `bookshelf-content.astro` 在构建时 import(文件必须存在)。另外两个文件 `papers.json` / `comics.json` 对应的漫画、论文分类已经移除,文件保留但不再被引用。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -193,6 +193,35 @@ pnpm preview # 本地预览构建产物(搜索功能只有这里才生效
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7.5 刷新「最近在读」
|
||||||
|
|
||||||
|
书架页(`/bookshelf/`)顶部的「最近在读」是从微信读书抓来的,数据存在 `src/data/reading.json`。
|
||||||
|
|
||||||
|
**只想更新书单**(不动别的):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm reading
|
||||||
|
```
|
||||||
|
|
||||||
|
会打印抓到的每本书和进度。想看更多本就 `pnpm reading --top 12`(默认 8 本)。
|
||||||
|
|
||||||
|
**平时不用管**:`pnpm build` 和 `./deploy.sh` 都会自动先抓一次。抓不到就沿用上一次的数据,**不会让部署失败**。
|
||||||
|
|
||||||
|
**首次配置**(只做一次):
|
||||||
|
|
||||||
|
1. 打开 <https://weread.qq.com/r/weread-skills> 扫码,拿到 `wrk-` 开头的 Key
|
||||||
|
2. 在项目根目录的 `.env` 里加一行:
|
||||||
|
|
||||||
|
```
|
||||||
|
WEREAD_API_KEY=wrk-你的key
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` 已被 gitignore,不会提交,也不会随 `deploy.sh` 上传到服务器。
|
||||||
|
|
||||||
|
不想出现在页面上就改 `scripts/fetch-reading.mjs` 里的过滤条件(默认跳过私密阅读和从没打开过的书)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 8. 常见问题
|
## 8. 常见问题
|
||||||
|
|
||||||
**改了文件但浏览器没变**
|
**改了文件但浏览器没变**
|
||||||
@@ -247,8 +276,21 @@ sudo sysctl fs.inotify.max_user_watches=524288
|
|||||||
`twikoo-custom.css` 之前)。如果又出现这个症状,先确认这两行 `<link>` 还在,
|
`twikoo-custom.css` 之前)。如果又出现这个症状,先确认这两行 `<link>` 还在,
|
||||||
并且 `public/assets/css/twikoo.css` 没被误删。
|
并且 `public/assets/css/twikoo.css` 没被误删。
|
||||||
|
|
||||||
**想回退到之前的状态**
|
**「最近在读」空了 / 数据不更新**
|
||||||
|
|
||||||
|
先看构建日志里有没有 `⚠️ 抓取失败,继续用上一次的数据构建`,后面会跟具体原因:
|
||||||
|
|
||||||
|
| 日志里的话 | 怎么办 |
|
||||||
|
|---|---|
|
||||||
|
| `WEREAD_API_KEY 无效或已过期` | Key 失效了,重新扫码拿一个,更新 `.env` |
|
||||||
|
| `缺少 WEREAD_API_KEY` | `.env` 里没配,见第 7.5 节 |
|
||||||
|
| `fetch failed` / 超时 | 网络问题,重跑 `pnpm reading` 即可 |
|
||||||
|
|
||||||
|
**重要**:以上任何一种都**不会**让 `pnpm build` 失败 —— 页面会继续用 `src/data/reading.json` 里上一次的数据。所以「站点发布成功但书单是旧的」是正常现象,不是 bug。
|
||||||
|
|
||||||
|
如果连空态提示都看不到,检查 `src/data/reading.json` 是否存在(这个文件要提交进 git)。
|
||||||
|
|
||||||
|
**想回退到之前的状态**
|
||||||
```bash
|
```bash
|
||||||
git log --oneline # 看提交
|
git log --oneline # 看提交
|
||||||
git reset --hard <提交号>
|
git reset --hard <提交号>
|
||||||
|
|||||||
+3
-2
@@ -5,11 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro build && pagefind --site dist",
|
"build": "node scripts/fetch-reading.mjs --soft && astro build && pagefind --site dist",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"new-post": "node scripts/new-post.mjs",
|
"new-post": "node scripts/new-post.mjs",
|
||||||
"new-thought": "node scripts/new-thought.mjs"
|
"new-thought": "node scripts/new-thought.mjs",
|
||||||
|
"reading": "node scripts/fetch-reading.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^5.0.6",
|
"@astrojs/mdx": "^5.0.6",
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/* 抓取微信读书「最近在读」,写出 src/data/reading.json
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* pnpm reading 手动刷新(失败会 exit 1,明确报错)
|
||||||
|
* pnpm reading --top 12 抓前 12 本,默认 8
|
||||||
|
* node scripts/fetch-reading.mjs --soft 失败时保留旧数据并 exit 0(构建流程用)
|
||||||
|
*
|
||||||
|
* 接口是腾讯官方的微信读书 Agent Gateway:
|
||||||
|
* POST https://i.weread.qq.com/api/agent/gateway
|
||||||
|
* Authorization: Bearer wrk-... ← 从 .env 的 WEREAD_API_KEY 读
|
||||||
|
* 体里必须有 api_name + skill_version,业务参数平铺在顶层(不能包进 params)
|
||||||
|
*
|
||||||
|
* Key 申请:https://weread.qq.com/r/weread-skills 扫码
|
||||||
|
*
|
||||||
|
* 两处实测得到的结论,别被直觉带偏:
|
||||||
|
* 1. /shelf/sync 一次就给全了封面/书名/作者/分类/最近阅读时间/跳转链接,
|
||||||
|
* 只有「阅读进度」要额外调 /book/getprogress,所以请求数 = 1 + N
|
||||||
|
* 2. book.recordReadingTime 恒为 0,拿不到累计时长,所以卡片不做这一项
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const ENDPOINT = "https://i.weread.qq.com/api/agent/gateway"
|
||||||
|
/** 与官方 SKILL.md 顶部的 version 保持一致;服务端会用它对版本 */
|
||||||
|
const SKILL_VERSION = "1.0.4"
|
||||||
|
const OUT_FILE = "src/data/reading.json"
|
||||||
|
const DEFAULT_TOP = 8
|
||||||
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
|
const DELAY_BETWEEN_CALLS_MS = 150
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const SOFT = args.includes("--soft")
|
||||||
|
const topIndex = args.indexOf("--top")
|
||||||
|
const TOP_N = topIndex >= 0 ? Number(args[topIndex + 1]) || DEFAULT_TOP : DEFAULT_TOP
|
||||||
|
|
||||||
|
// .env 已 gitignore;Node 22 自带 loadEnvFile,不需要 dotenv
|
||||||
|
try {
|
||||||
|
process.loadEnvFile(".env")
|
||||||
|
} catch {
|
||||||
|
// 没有 .env 就只认真正的环境变量
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.WEREAD_API_KEY
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
async function call(apiName, params = {}) {
|
||||||
|
const res = await fetch(ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ api_name: apiName, skill_version: SKILL_VERSION, ...params }),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (json?.upgrade_info) {
|
||||||
|
console.warn(`⚠️ 服务端要求升级 Skill:${json.upgrade_info.message ?? "(无说明)"}`)
|
||||||
|
}
|
||||||
|
if (res.status === 401 || json?.errcode === -2010) {
|
||||||
|
throw new Error(
|
||||||
|
`WEREAD_API_KEY 无效或已过期(HTTP ${res.status}${json?.errmsg ? `:${json.errmsg}` : ""})。` +
|
||||||
|
`去 https://weread.qq.com/r/weread-skills 重新拿一个。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!res.ok || (json?.errcode !== undefined && json.errcode !== 0)) {
|
||||||
|
throw new Error(
|
||||||
|
`${apiName} 失败:HTTP ${res.status} errcode=${json?.errcode} ${json?.errmsg ?? ""}`.trim(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return json ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReading() {
|
||||||
|
const shelf = await call("/shelf/sync")
|
||||||
|
|
||||||
|
const candidates = (shelf.books ?? [])
|
||||||
|
// 私密阅读(secret=1)不要发到公开网站上
|
||||||
|
.filter((book) => book.secret !== 1)
|
||||||
|
// readUpdateTime 为 0 表示从没打开过,不算「在读」
|
||||||
|
.filter((book) => Number(book.readUpdateTime) > 0)
|
||||||
|
.sort((a, b) => Number(b.readUpdateTime) - Number(a.readUpdateTime))
|
||||||
|
.slice(0, TOP_N)
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { books: [], note: "书架里没有可展示的阅读记录" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const books = []
|
||||||
|
for (const book of candidates) {
|
||||||
|
const entry = {
|
||||||
|
bookId: book.bookId,
|
||||||
|
title: book.title ?? "",
|
||||||
|
author: book.author ?? "",
|
||||||
|
category: book.category ?? "",
|
||||||
|
cover: book.cover ?? "",
|
||||||
|
progress: null,
|
||||||
|
finished: false,
|
||||||
|
lastReadAt: new Date(Number(book.readUpdateTime) * 1000).toISOString(),
|
||||||
|
deepLink: book.deepLink ?? "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await call("/book/getprogress", { bookId: book.bookId })
|
||||||
|
const reading = detail.book ?? {}
|
||||||
|
if (typeof reading.progress === "number") {
|
||||||
|
entry.progress = reading.progress
|
||||||
|
// progress 是 0-100 的整数,只有正好 100 才算读完
|
||||||
|
entry.finished = reading.progress === 100
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 单本节失败不该让整次抓取挂掉,卡片退化成「不显示进度条」
|
||||||
|
console.warn(` ⚠️ ${book.title}:进度获取失败 — ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
books.push(entry)
|
||||||
|
await sleep(DELAY_BETWEEN_CALLS_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { books }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"缺少 WEREAD_API_KEY。在项目根目录的 .env 里加一行 WEREAD_API_KEY=wrk-... ," +
|
||||||
|
"或直接 export 到环境变量。",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { books, note } = await fetchReading()
|
||||||
|
if (note) console.warn(`⚠️ ${note}`)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
source: "weread",
|
||||||
|
books,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true })
|
||||||
|
fs.writeFileSync(OUT_FILE, `${JSON.stringify(payload, null, 2)}\n`)
|
||||||
|
|
||||||
|
console.log(`✅ 写入 ${books.length} 本到 ${OUT_FILE}`)
|
||||||
|
for (const book of books) {
|
||||||
|
const progress = book.progress === null ? " ?" : `${String(book.progress).padStart(3)}%`
|
||||||
|
console.log(` ${progress} ${book.title}${book.finished ? "(已读完)" : ""}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
if (SOFT && fs.existsSync(OUT_FILE)) {
|
||||||
|
// 构建流程用这条路径:微信读书挂了 / key 过期 / 断网,都不该阻塞部署
|
||||||
|
console.warn(`⚠️ 抓取失败,继续用上一次的数据构建:${error.message}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
console.error(`❌ ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -3,8 +3,12 @@ import SiteHeader from "@/components/site-header.astro"
|
|||||||
import Footer from "@/components/footer.astro"
|
import Footer from "@/components/footer.astro"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import booksData from "../../public/data/books.json"
|
import booksData from "../../public/data/books.json"
|
||||||
import comicsData from "../../public/data/comics.json"
|
import { formatReadingDate, getReadingData } from "@/lib/reading"
|
||||||
import papersData from "../../public/data/papers.json"
|
|
||||||
|
// 微信读书「最近在读」。数据缺失时 reading 为 null,页面退化成空态,不报错。
|
||||||
|
const reading = getReadingData()
|
||||||
|
const readingBooks = reading?.books ?? []
|
||||||
|
const readingCount = readingBooks.length
|
||||||
|
|
||||||
// ---- Types
|
// ---- Types
|
||||||
|
|
||||||
@@ -25,42 +29,6 @@ interface BookRecord {
|
|||||||
record_id: string
|
record_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComicFields {
|
|
||||||
作品名?: string
|
|
||||||
作者?: string
|
|
||||||
封面?: { local_path: string }[]
|
|
||||||
短评?: string
|
|
||||||
推荐状态?: "推荐" | "中庸" | "不行"
|
|
||||||
连载状态?: "连载中" | "完结" | "腰斩"
|
|
||||||
追读状态?: "追更中" | "已读完" | "弃坑" | "想读"
|
|
||||||
最新话数?: string
|
|
||||||
最新卷数?: string
|
|
||||||
分类?: string[]
|
|
||||||
年份?: number
|
|
||||||
komiic_url?: string
|
|
||||||
}
|
|
||||||
interface ComicRecord {
|
|
||||||
fields: ComicFields
|
|
||||||
id: string
|
|
||||||
record_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaperFields {
|
|
||||||
标题?: string
|
|
||||||
作者?: string
|
|
||||||
年份?: string | number
|
|
||||||
PDF链接?: { link: string; text?: string }
|
|
||||||
领域?: string
|
|
||||||
核心观点?: string
|
|
||||||
我的笔记?: string
|
|
||||||
推荐状态?: "推荐" | "中庸" | "不行"
|
|
||||||
}
|
|
||||||
interface PaperRecord {
|
|
||||||
fields: PaperFields
|
|
||||||
id: string
|
|
||||||
record_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Books
|
// ---- Books
|
||||||
|
|
||||||
const allBooks = (booksData.data.items as BookRecord[]).filter(
|
const allBooks = (booksData.data.items as BookRecord[]).filter(
|
||||||
@@ -84,46 +52,6 @@ const bookStats = {
|
|||||||
|
|
||||||
const getBookCover = (b: BookRecord): string | null => b.fields.封面?.[0]?.local_path ?? null
|
const getBookCover = (b: BookRecord): string | null => b.fields.封面?.[0]?.local_path ?? null
|
||||||
|
|
||||||
// ---- Comics
|
|
||||||
|
|
||||||
const allComics = (comicsData.data.items as ComicRecord[]).filter((c) => c.fields.作品名)
|
|
||||||
const comicSerialStatuses = ["全部", "连载中", "完结", "腰斩"]
|
|
||||||
|
|
||||||
const comicStats = {
|
|
||||||
total: allComics.length,
|
|
||||||
recommended: allComics.filter((c) => c.fields.推荐状态 === "推荐").length,
|
|
||||||
ongoing: allComics.filter((c) => c.fields.连载状态 === "连载中").length,
|
|
||||||
}
|
|
||||||
|
|
||||||
const getComicCover = (c: ComicRecord): string | null => c.fields.封面?.[0]?.local_path ?? null
|
|
||||||
|
|
||||||
// ---- Papers
|
|
||||||
|
|
||||||
const allPapers = (papersData.data.items as PaperRecord[]).filter((p) => p.fields.标题)
|
|
||||||
|
|
||||||
const papersByYear = allPapers.reduce<Record<string, PaperRecord[]>>((acc, p) => {
|
|
||||||
const y = String(p.fields.年份 ?? "未知")
|
|
||||||
;(acc[y] = acc[y] || []).push(p)
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
const paperYears = Object.keys(papersByYear).sort((a, b) => {
|
|
||||||
const na = Number(a), nb = Number(b)
|
|
||||||
if (Number.isNaN(na) && Number.isNaN(nb)) return a.localeCompare(b)
|
|
||||||
if (Number.isNaN(na)) return 1
|
|
||||||
if (Number.isNaN(nb)) return -1
|
|
||||||
return nb - na
|
|
||||||
})
|
|
||||||
const paperFields = Array.from(new Set(allPapers.map((p) => p.fields.领域).filter(Boolean))) as string[]
|
|
||||||
|
|
||||||
const paperYearFilters = ["全部", ...paperYears]
|
|
||||||
const paperFieldFilters = ["全部", ...paperFields]
|
|
||||||
|
|
||||||
const paperStats = {
|
|
||||||
total: allPapers.length,
|
|
||||||
recommended: allPapers.filter((p) => p.fields.推荐状态 === "推荐").length,
|
|
||||||
fields: paperFields.length,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Data for client scripts (modal, filters)
|
// ---- Data for client scripts (modal, filters)
|
||||||
|
|
||||||
const booksForClient = allBooks.map((b) => ({
|
const booksForClient = allBooks.map((b) => ({
|
||||||
@@ -138,32 +66,6 @@ const booksForClient = allBooks.map((b) => ({
|
|||||||
finished: b.fields.完成阅读时期 ?? "",
|
finished: b.fields.完成阅读时期 ?? "",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const comicsForClient = allComics.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
title: c.fields.作品名 ?? "",
|
|
||||||
author: c.fields.作者 ?? "",
|
|
||||||
cover: getComicCover(c) ?? "",
|
|
||||||
review: c.fields.短评 ?? "",
|
|
||||||
serial: c.fields.连载状态 ?? "",
|
|
||||||
tracking: c.fields.追读状态 ?? "",
|
|
||||||
status: c.fields.推荐状态 ?? "",
|
|
||||||
chapters: c.fields.最新话数 ?? "",
|
|
||||||
categories: c.fields.分类 ?? [],
|
|
||||||
url: c.fields.komiic_url ?? "",
|
|
||||||
}))
|
|
||||||
|
|
||||||
const papersForClient = allPapers.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.fields.标题 ?? "",
|
|
||||||
authors: p.fields.作者 ?? "",
|
|
||||||
year: String(p.fields.年份 ?? ""),
|
|
||||||
link: p.fields.PDF链接?.link ?? "",
|
|
||||||
field: p.fields.领域 ?? "",
|
|
||||||
insight: p.fields.核心观点 ?? "",
|
|
||||||
note: p.fields.我的笔记 ?? "",
|
|
||||||
status: p.fields.推荐状态 ?? "",
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Status badge color helpers
|
// Status badge color helpers
|
||||||
const recommendBadge = (status?: string) => {
|
const recommendBadge = (status?: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -197,44 +99,33 @@ const recommendBadge = (status?: string) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-[10px] text-muted-foreground/80 uppercase tracking-[0.18em] mb-2 pl-2">馆藏</div>
|
<div class="text-[10px] text-muted-foreground/80 uppercase tracking-[0.18em] mb-2 pl-2">馆藏</div>
|
||||||
|
{/* 漫画和论文两个分类已移除,只剩书籍;这里改成页内锚点,不再是 tab 切换 */}
|
||||||
<nav class="flex flex-row md:flex-col gap-1 mb-6 md:mb-8 overflow-x-auto md:overflow-visible -mx-1 px-1">
|
<nav class="flex flex-row md:flex-col gap-1 mb-6 md:mb-8 overflow-x-auto md:overflow-visible -mx-1 px-1">
|
||||||
{[
|
{[
|
||||||
{ id: "books", label: "书籍", count: bookStats.total, svg: "books" },
|
{ href: "#reading", label: "在读", count: readingCount, svg: "reading" },
|
||||||
{ id: "comics", label: "漫画", count: comicStats.total, svg: "comics" },
|
{ href: "#books", label: "书籍", count: bookStats.total, svg: "books" },
|
||||||
{ id: "papers", label: "论文", count: paperStats.total, svg: "papers" },
|
|
||||||
].map((it) => (
|
].map((it) => (
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={it.href}
|
||||||
data-type-btn={it.id}
|
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors whitespace-nowrap text-foreground/80 hover:bg-muted/60 hover:text-foreground"
|
||||||
class={cn(
|
|
||||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors whitespace-nowrap",
|
|
||||||
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
|
|
||||||
"data-[active=false]:text-foreground/80 data-[active=false]:hover:bg-muted/60",
|
|
||||||
)}
|
|
||||||
data-active={it.id === "books" ? "true" : "false"}
|
|
||||||
>
|
>
|
||||||
<span class="inline-flex h-4 w-4 items-center justify-center shrink-0">
|
<span class="inline-flex h-4 w-4 items-center justify-center shrink-0">
|
||||||
|
{it.svg === "reading" && (
|
||||||
|
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 7v5l3 2" />
|
||||||
|
<path d="M3 5.5A2.5 2.5 0 0 1 5.5 3H10a2 2 0 0 1 2 2v14a1.5 1.5 0 0 0-1.5-1.5h-5A2.5 2.5 0 0 1 3 15z" />
|
||||||
|
<path d="M21 5.5A2.5 2.5 0 0 0 18.5 3H14a2 2 0 0 0-2 2v14a1.5 1.5 0 0 1 1.5-1.5h5A2.5 2.5 0 0 0 21 15z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{it.svg === "books" && (
|
{it.svg === "books" && (
|
||||||
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor">
|
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor">
|
||||||
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v16.5a1.5 1.5 0 0 1-1.5 1.5H6.5A2.5 2.5 0 0 1 4 18.5zM6.5 4A1.5 1.5 0 0 0 5 5.5v13A1.5 1.5 0 0 0 6.5 20H18a.5.5 0 0 0 .5-.5V4z" />
|
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v16.5a1.5 1.5 0 0 1-1.5 1.5H6.5A2.5 2.5 0 0 1 4 18.5zM6.5 4A1.5 1.5 0 0 0 5 5.5v13A1.5 1.5 0 0 0 6.5 20H18a.5.5 0 0 0 .5-.5V4z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{it.svg === "comics" && (
|
|
||||||
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
|
||||||
<path d="M7 9h4M7 13h8M16 9l2 2-2 2" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{it.svg === "papers" && (
|
|
||||||
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<path d="M14 2v6h6M8 13h8M8 17h5" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-1 font-medium text-left">{it.label}</span>
|
<span class="flex-1 font-medium text-left">{it.label}</span>
|
||||||
<span class="text-[11px] opacity-60 tabular-nums">{it.count}</span>
|
<span class="text-[11px] opacity-60 tabular-nums">{it.count}</span>
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -243,39 +134,102 @@ const recommendBadge = (status?: string) => {
|
|||||||
{/* ========================= Main ========================= */}
|
{/* ========================= Main ========================= */}
|
||||||
<section class="flex-1 min-w-0" data-bookshelf-main>
|
<section class="flex-1 min-w-0" data-bookshelf-main>
|
||||||
|
|
||||||
{/* ---------- Header (per-type) ---------- */}
|
{/* ========================= 最近在读(微信读书) =========================
|
||||||
<div data-type-header="books" class="mb-6">
|
数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json,
|
||||||
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 01</div>
|
这里只负责渲染,页面本身不发任何请求。 */}
|
||||||
|
<div id="reading" class="mb-16 scroll-mt-24">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 01</div>
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">最近在读</h2>
|
||||||
|
<div class="text-sm italic font-serif text-muted-foreground">翻开的书,都还热着</div>
|
||||||
|
</div>
|
||||||
|
{reading ? (
|
||||||
|
<p class="text-xs text-muted-foreground tabular-nums">
|
||||||
|
来自微信读书 · 更新于 {formatReadingDate(reading.updatedAt)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readingBooks.length > 0 ? (
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
{readingBooks.map((book) => (
|
||||||
|
<a
|
||||||
|
href={book.deepLink || undefined}
|
||||||
|
target={book.deepLink ? "_blank" : undefined}
|
||||||
|
rel={book.deepLink ? "noopener noreferrer" : undefined}
|
||||||
|
class="group flex gap-4 rounded-xl border border-border/50 bg-card p-4 transition-shadow duration-300 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="shrink-0 w-20 aspect-[3/4] rounded-lg overflow-hidden bg-surface-subtle">
|
||||||
|
{book.cover ? (
|
||||||
|
<img
|
||||||
|
src={book.cover}
|
||||||
|
alt={book.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div class="flex h-full w-full items-center justify-center p-2 bg-gradient-to-br from-primary/30 via-primary/20 to-primary/5">
|
||||||
|
<p class="font-semibold text-[10px] text-foreground text-center line-clamp-4">{book.title}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<h3 class="font-medium text-sm text-foreground line-clamp-2 leading-snug mb-1 group-hover:text-primary transition-colors">
|
||||||
|
{book.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground line-clamp-1 mb-3">
|
||||||
|
{[book.author, book.category].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-auto space-y-1.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 h-1.5 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div class="h-full rounded-full bg-primary" style={`width: ${book.progress ?? 0}%`} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"text-[11px] tabular-nums shrink-0",
|
||||||
|
book.finished ? "text-status-positive-fg font-medium" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{book.finished ? "已读完" : !book.progress ? "还没开始" : `${book.progress}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-muted-foreground/80 tabular-nums">
|
||||||
|
最近阅读 {formatReadingDate(book.lastReadAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="py-12 text-center border border-dashed border-border rounded-xl">
|
||||||
|
<p class="text-muted-foreground mb-2">暂时读不到微信读书的数据。</p>
|
||||||
|
<p class="text-xs text-muted-foreground/70">
|
||||||
|
在项目根目录跑一次 <code class="px-1 py-0.5 rounded bg-muted font-mono">pnpm reading</code> 刷新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- Header ---------- */}
|
||||||
|
<div id="books" class="mb-6 scroll-mt-24">
|
||||||
|
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 02</div>
|
||||||
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">书籍</h2>
|
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">书籍</h2>
|
||||||
<div class="text-sm italic font-serif text-muted-foreground">博学而笃志,切问而近思</div>
|
<div class="text-sm italic font-serif text-muted-foreground">博学而笃志,切问而近思</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-type-header="comics" class="mb-6 hidden">
|
|
||||||
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 02</div>
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">漫画</h2>
|
|
||||||
<div class="text-sm italic font-serif text-muted-foreground">画格间的万千气象</div>
|
|
||||||
</div>
|
|
||||||
<div data-type-header="papers" class="mb-6 hidden">
|
|
||||||
<div class="text-[11px] text-primary tracking-[0.22em] font-semibold uppercase mb-1.5">Collection · 03</div>
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold font-serif tracking-tight text-foreground mb-1.5">论文</h2>
|
|
||||||
<div class="text-sm italic font-serif text-muted-foreground">追随前沿,记录思考</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- Stats (per-type) ---------- */}
|
{/* ---------- Stats ---------- */}
|
||||||
<div data-type-stats="books" class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7">
|
<div class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7">
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.total}</span><span>本已读</span></span>
|
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.total}</span><span>本已读</span></span>
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.recommended}</span><span>本推荐</span></span>
|
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.recommended}</span><span>本推荐</span></span>
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.categories}</span><span>个领域</span></span>
|
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{bookStats.categories}</span><span>个领域</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div data-type-stats="comics" class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7 hidden">
|
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{comicStats.total}</span><span>部作品</span></span>
|
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{comicStats.recommended}</span><span>部推荐</span></span>
|
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{comicStats.ongoing}</span><span>部连载中</span></span>
|
|
||||||
</div>
|
|
||||||
<div data-type-stats="papers" class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7 hidden">
|
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{paperStats.total}</span><span>篇收录</span></span>
|
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{paperStats.recommended}</span><span>篇精读</span></span>
|
|
||||||
<span class="flex items-baseline gap-2"><span class="text-primary font-semibold text-base font-serif tabular-nums">{paperStats.fields}</span><span>个主题</span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------- Search + filters ---------- */}
|
{/* ---------- Search + filters ---------- */}
|
||||||
<div class="space-y-3 mb-8">
|
<div class="space-y-3 mb-8">
|
||||||
@@ -286,16 +240,13 @@ const recommendBadge = (status?: string) => {
|
|||||||
<input
|
<input
|
||||||
data-bookshelf-search
|
data-bookshelf-search
|
||||||
type="text"
|
type="text"
|
||||||
data-placeholder-books="搜索书名或作者..."
|
|
||||||
data-placeholder-comics="搜索漫画..."
|
|
||||||
data-placeholder-papers="搜索论文..."
|
|
||||||
placeholder="搜索书名或作者..."
|
placeholder="搜索书名或作者..."
|
||||||
class="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all"
|
class="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Books filters */}
|
{/* Books filters */}
|
||||||
<div data-type-filters="books" class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">领域</span>
|
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">领域</span>
|
||||||
{bookCategories.map((cat) => (
|
{bookCategories.map((cat) => (
|
||||||
@@ -329,69 +280,12 @@ const recommendBadge = (status?: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comics filters */}
|
|
||||||
<div data-type-filters="comics" class="hidden flex-wrap items-center gap-x-6 gap-y-3">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">状态</span>
|
|
||||||
{comicSerialStatuses.map((s) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-filter-comics-serial={s}
|
|
||||||
data-active-class="bg-primary text-primary-foreground"
|
|
||||||
data-inactive-class="text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
|
||||||
class={cn(
|
|
||||||
"px-3 py-1.5 rounded-md text-xs transition-all",
|
|
||||||
s === "全部" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
>{s}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Papers filters */}
|
|
||||||
<div data-type-filters="papers" class="hidden flex-wrap items-center gap-x-6 gap-y-3">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">年份</span>
|
|
||||||
{paperYearFilters.map((y) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-filter-papers-year={y}
|
|
||||||
data-active-class="bg-primary text-primary-foreground"
|
|
||||||
data-inactive-class="text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
|
||||||
class={cn(
|
|
||||||
"px-3 py-1.5 rounded-md text-xs transition-all",
|
|
||||||
y === "全部" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
>{y}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{paperFieldFilters.length > 1 && (
|
|
||||||
<>
|
|
||||||
<div class="h-4 w-px bg-border hidden sm:block" />
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="text-[10px] text-muted-foreground uppercase tracking-[0.18em]">领域</span>
|
|
||||||
{paperFieldFilters.map((f) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-filter-papers-field={f}
|
|
||||||
data-active-class="bg-primary text-primary-foreground"
|
|
||||||
data-inactive-class="text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
|
||||||
class={cn(
|
|
||||||
"px-3 py-1.5 rounded-md text-xs transition-all",
|
|
||||||
f === "全部" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/50",
|
|
||||||
)}
|
|
||||||
>{f}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========================= Views ========================= */}
|
{/* ========================= Views ========================= */}
|
||||||
|
|
||||||
{/* ---- Books ---- */}
|
{/* ---- Books ---- */}
|
||||||
<div data-view="books" class="space-y-12">
|
<div data-bookshelf-fade class="space-y-12">
|
||||||
{Object.entries(booksByCategory).map(([category, books]) => (
|
{Object.entries(booksByCategory).map(([category, books]) => (
|
||||||
<section data-books-section={category}>
|
<section data-books-section={category}>
|
||||||
<div class="flex items-center gap-3 mb-5">
|
<div class="flex items-center gap-3 mb-5">
|
||||||
@@ -460,137 +354,6 @@ const recommendBadge = (status?: string) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- Comics ---- */}
|
|
||||||
<div data-view="comics" class="hidden">
|
|
||||||
{allComics.length === 0 ? (
|
|
||||||
<div class="py-16 text-center border border-dashed border-border rounded-xl">
|
|
||||||
<p class="text-muted-foreground mb-2">书架上还没有漫画。</p>
|
|
||||||
<p class="text-xs text-muted-foreground/70">去飞书添加几部作品,下次同步后就会出现在这里。</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-5">
|
|
||||||
{allComics.map((c) => {
|
|
||||||
const cover = getComicCover(c)
|
|
||||||
const serialClass = c.fields.连载状态 === "连载中"
|
|
||||||
? "bg-primary/90 text-white"
|
|
||||||
: c.fields.连载状态 === "腰斩"
|
|
||||||
? "bg-muted text-muted-foreground line-through"
|
|
||||||
: "bg-black/60 text-white"
|
|
||||||
const trackingClass = c.fields.追读状态 === "追更中"
|
|
||||||
? "bg-primary/90 text-white"
|
|
||||||
: c.fields.追读状态 === "已读完"
|
|
||||||
? "bg-green-600/90 text-white"
|
|
||||||
: c.fields.追读状态 === "弃坑"
|
|
||||||
? "bg-muted/90 text-muted-foreground line-through"
|
|
||||||
: "bg-black/55 text-white/90"
|
|
||||||
const href = c.fields.komiic_url || undefined
|
|
||||||
const Tag = href ? "a" : "div"
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
{...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
||||||
data-comic-card
|
|
||||||
data-comic-id={c.id}
|
|
||||||
data-serial={c.fields.连载状态 || ""}
|
|
||||||
data-status={c.fields.推荐状态 || ""}
|
|
||||||
data-title={(c.fields.作品名 || "").toLowerCase()}
|
|
||||||
data-author={(c.fields.作者 || "").toLowerCase()}
|
|
||||||
class="group block cursor-pointer focus:outline-none"
|
|
||||||
>
|
|
||||||
<div class="relative aspect-[3/4] rounded-lg overflow-hidden mb-3 bg-surface-subtle shadow-sm group-hover:shadow-xl transition-shadow duration-300">
|
|
||||||
{cover ? (
|
|
||||||
<img src={cover} alt={c.fields.作品名} loading="lazy" decoding="async" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
|
||||||
) : (
|
|
||||||
<div class="w-full h-full flex items-end p-3 bg-gradient-to-br from-primary/20 via-primary/10 to-primary/5">
|
|
||||||
<h4 class="text-sm font-semibold text-foreground font-display-sans leading-tight line-clamp-3">{c.fields.作品名}</h4>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{c.fields.连载状态 && (
|
|
||||||
<span class={cn("absolute top-2 right-2 text-[10px] px-2 py-0.5 rounded-full backdrop-blur-sm font-medium", serialClass)}>
|
|
||||||
{c.fields.连载状态}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{c.fields.追读状态 && (
|
|
||||||
<span class={cn("absolute bottom-2 left-2 text-[10px] px-2 py-0.5 rounded-full backdrop-blur-sm font-medium", trackingClass)}>
|
|
||||||
{c.fields.追读状态}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="min-h-[3.5rem] space-y-1">
|
|
||||||
<h4 class="font-medium text-sm text-foreground line-clamp-2 group-hover:text-primary transition-colors leading-snug">
|
|
||||||
{c.fields.作品名}
|
|
||||||
</h4>
|
|
||||||
<div class="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
||||||
<span class="line-clamp-1 flex-1">{c.fields.作者}</span>
|
|
||||||
{c.fields.最新话数 && (
|
|
||||||
<span class="tabular-nums shrink-0">{c.fields.最新话数}话</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---- Papers ---- */}
|
|
||||||
<div data-view="papers" class="hidden">
|
|
||||||
{allPapers.length === 0 ? (
|
|
||||||
<div class="py-16 text-center border border-dashed border-border rounded-xl">
|
|
||||||
<p class="text-muted-foreground mb-2">还没有收录论文。</p>
|
|
||||||
<p class="text-xs text-muted-foreground/70">去飞书记录你读过的第一篇 paper 吧。</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div class="space-y-8">
|
|
||||||
{paperYears.map((year) => (
|
|
||||||
<section data-papers-section={year}>
|
|
||||||
<div class="flex items-center gap-3 mb-3">
|
|
||||||
<h3 class="text-sm font-semibold text-primary font-serif tabular-nums">{year}</h3>
|
|
||||||
<span class="text-[11px] text-muted-foreground" data-papers-count>({papersByYear[year].length})</span>
|
|
||||||
<div class="flex-1 h-px bg-border/60" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
{papersByYear[year].map((p, i) => (
|
|
||||||
<a
|
|
||||||
href={p.fields.PDF链接?.link || "#"}
|
|
||||||
target={p.fields.PDF链接?.link ? "_blank" : "_self"}
|
|
||||||
rel={p.fields.PDF链接?.link ? "noopener noreferrer" : undefined}
|
|
||||||
data-paper-card
|
|
||||||
data-paper-id={p.id}
|
|
||||||
data-year={year}
|
|
||||||
data-field={p.fields.领域 || ""}
|
|
||||||
data-status={p.fields.推荐状态 || ""}
|
|
||||||
data-title={(p.fields.标题 || "").toLowerCase()}
|
|
||||||
data-authors={(p.fields.作者 || "").toLowerCase()}
|
|
||||||
class="group grid grid-cols-[auto_1fr_auto] gap-3 sm:gap-4 items-baseline py-3 px-1 border-b border-border/40 hover:bg-muted/40 rounded-sm transition-colors"
|
|
||||||
>
|
|
||||||
<span class="font-mono text-[10px] text-muted-foreground/70 min-w-[32px] tabular-nums">[{String(i + 1).padStart(2, "0")}]</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1 mb-1">
|
|
||||||
<h4 class="font-semibold text-sm font-display-sans text-foreground group-hover:text-primary transition-colors">{p.fields.标题}</h4>
|
|
||||||
{p.fields.领域 && (
|
|
||||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{p.fields.领域}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
|
||||||
{p.fields.作者 && <span>{p.fields.作者}</span>}
|
|
||||||
</div>
|
|
||||||
{(p.fields.我的笔记 || p.fields.核心观点) && (
|
|
||||||
<p class="mt-1 text-xs italic text-muted-foreground/80 leading-relaxed line-clamp-2">
|
|
||||||
“{p.fields.我的笔记 || p.fields.核心观点}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span class="text-primary text-sm shrink-0 opacity-60 group-hover:opacity-100 transition-opacity">↗</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Empty state (shared, shown when filters return nothing) */}
|
{/* Empty state (shared, shown when filters return nothing) */}
|
||||||
<div data-empty-state class="hidden text-center py-16">
|
<div data-empty-state class="hidden text-center py-16">
|
||||||
<span class="inline-flex h-12 w-12 items-center justify-center text-muted-foreground/30 mx-auto mb-4">
|
<span class="inline-flex h-12 w-12 items-center justify-center text-muted-foreground/30 mx-auto mb-4">
|
||||||
@@ -655,32 +418,22 @@ const recommendBadge = (status?: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ====== Styles: switch animation ====== */}
|
{/* ====== Styles ====== */}
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@keyframes bookshelfFadeIn {
|
@keyframes bookshelfFadeIn {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
to { opacity: 1; transform: none; }
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
[data-view]:not(.hidden),
|
[data-bookshelf-fade] {
|
||||||
[data-type-header]:not(.hidden),
|
|
||||||
[data-type-stats]:not(.hidden) {
|
|
||||||
animation: bookshelfFadeIn 200ms ease-out both;
|
animation: bookshelfFadeIn 200ms ease-out both;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
[data-view]:not(.hidden),
|
[data-bookshelf-fade] { animation: none; }
|
||||||
[data-type-header]:not(.hidden),
|
|
||||||
[data-type-stats]:not(.hidden) { animation: none; }
|
|
||||||
}
|
}
|
||||||
/* Hidden views shouldn't contribute to scroll height / paint work */
|
|
||||||
[data-view].hidden,
|
|
||||||
[data-type-header].hidden,
|
|
||||||
[data-type-stats].hidden { content-visibility: hidden; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{/* ====== Client script ====== */}
|
{/* ====== Client script ====== */}
|
||||||
<script is:inline type="application/json" id="books-data" set:html={JSON.stringify(booksForClient).replace(/</g, "\\u003c")} />
|
<script is:inline type="application/json" id="books-data" set:html={JSON.stringify(booksForClient).replace(/</g, "\\u003c")} />
|
||||||
<script is:inline type="application/json" id="comics-data" set:html={JSON.stringify(comicsForClient).replace(/</g, "\\u003c")} />
|
|
||||||
<script is:inline type="application/json" id="papers-data" set:html={JSON.stringify(papersForClient).replace(/</g, "\\u003c")} />
|
|
||||||
|
|
||||||
<script is:inline data-astro-rerun>
|
<script is:inline data-astro-rerun>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -691,21 +444,14 @@ const recommendBadge = (status?: string) => {
|
|||||||
try { return JSON.parse(el?.textContent || "[]"); } catch { return []; }
|
try { return JSON.parse(el?.textContent || "[]"); } catch { return []; }
|
||||||
};
|
};
|
||||||
const books = getJSON("books-data");
|
const books = getJSON("books-data");
|
||||||
const comics = getJSON("comics-data");
|
|
||||||
const papers = getJSON("papers-data");
|
|
||||||
const bookMap = new Map(books.map((b) => [b.id, b]));
|
const bookMap = new Map(books.map((b) => [b.id, b]));
|
||||||
|
|
||||||
const searchInput = document.querySelector("[data-bookshelf-search]");
|
const searchInput = document.querySelector("[data-bookshelf-search]");
|
||||||
const typeBtns = Array.from(document.querySelectorAll("[data-type-btn]"));
|
|
||||||
const emptyState = document.querySelector("[data-empty-state]");
|
const emptyState = document.querySelector("[data-empty-state]");
|
||||||
|
|
||||||
// Filter state per type
|
|
||||||
const state = {
|
const state = {
|
||||||
type: "books",
|
|
||||||
query: "",
|
query: "",
|
||||||
books: { category: "全部", status: "全部" },
|
books: { category: "全部", status: "全部" },
|
||||||
comics: { serial: "全部" },
|
|
||||||
papers: { year: "全部", field: "全部" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanups = [];
|
const cleanups = [];
|
||||||
@@ -743,90 +489,11 @@ const recommendBadge = (status?: string) => {
|
|||||||
return totalVisible;
|
return totalVisible;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyComics = () => {
|
|
||||||
const q = state.query.trim().toLowerCase();
|
|
||||||
const cards = document.querySelectorAll("[data-comic-card]");
|
|
||||||
let totalVisible = 0;
|
|
||||||
cards.forEach((card) => {
|
|
||||||
const serial = card.getAttribute("data-serial") || "";
|
|
||||||
const title = card.getAttribute("data-title") || "";
|
|
||||||
const author = card.getAttribute("data-author") || "";
|
|
||||||
const mserial = state.comics.serial === "全部" || serial === state.comics.serial;
|
|
||||||
const mq = !q || title.includes(q) || author.includes(q);
|
|
||||||
const show = mserial && mq;
|
|
||||||
card.classList.toggle("hidden", !show);
|
|
||||||
if (show) totalVisible++;
|
|
||||||
});
|
|
||||||
return totalVisible;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyPapers = () => {
|
|
||||||
const q = state.query.trim().toLowerCase();
|
|
||||||
const cards = document.querySelectorAll("[data-paper-card]");
|
|
||||||
let totalVisible = 0;
|
|
||||||
cards.forEach((card) => {
|
|
||||||
const year = card.getAttribute("data-year") || "";
|
|
||||||
const field = card.getAttribute("data-field") || "";
|
|
||||||
const title = card.getAttribute("data-title") || "";
|
|
||||||
const authors = card.getAttribute("data-authors") || "";
|
|
||||||
const my = state.papers.year === "全部" || year === state.papers.year;
|
|
||||||
const mf = state.papers.field === "全部" || field === state.papers.field;
|
|
||||||
const mq = !q || title.includes(q) || authors.includes(q);
|
|
||||||
const show = my && mf && mq;
|
|
||||||
card.classList.toggle("hidden", !show);
|
|
||||||
if (show) totalVisible++;
|
|
||||||
});
|
|
||||||
document.querySelectorAll("[data-papers-section]").forEach((section) => {
|
|
||||||
const visible = Array.from(section.querySelectorAll("[data-paper-card]")).filter((c) => !c.classList.contains("hidden")).length;
|
|
||||||
const countEl = section.querySelector("[data-papers-count]");
|
|
||||||
if (countEl) countEl.textContent = `(${visible})`;
|
|
||||||
section.classList.toggle("hidden", visible === 0);
|
|
||||||
});
|
|
||||||
return totalVisible;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyAll = () => {
|
const applyAll = () => {
|
||||||
let visible = 0;
|
const visible = applyBooks();
|
||||||
if (state.type === "books") visible = applyBooks();
|
|
||||||
else if (state.type === "comics") visible = applyComics();
|
|
||||||
else if (state.type === "papers") visible = applyPapers();
|
|
||||||
if (emptyState) emptyState.classList.toggle("hidden", visible !== 0);
|
if (emptyState) emptyState.classList.toggle("hidden", visible !== 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchType = (type) => {
|
|
||||||
state.type = type;
|
|
||||||
typeBtns.forEach((b) => b.setAttribute("data-active", b.getAttribute("data-type-btn") === type ? "true" : "false"));
|
|
||||||
|
|
||||||
["books", "comics", "papers"].forEach((t) => {
|
|
||||||
const isActive = t === type;
|
|
||||||
document.querySelectorAll(`[data-type-header="${t}"]`).forEach((el) => el.classList.toggle("hidden", !isActive));
|
|
||||||
document.querySelectorAll(`[data-type-stats="${t}"]`).forEach((el) => el.classList.toggle("hidden", !isActive));
|
|
||||||
document.querySelectorAll(`[data-type-filters="${t}"]`).forEach((el) => {
|
|
||||||
el.classList.toggle("hidden", !isActive);
|
|
||||||
el.classList.toggle("flex", isActive);
|
|
||||||
});
|
|
||||||
document.querySelectorAll(`[data-view="${t}"]`).forEach((el) => el.classList.toggle("hidden", !isActive));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchInput) {
|
|
||||||
const ph = searchInput.getAttribute(`data-placeholder-${type}`);
|
|
||||||
if (ph) searchInput.setAttribute("placeholder", ph);
|
|
||||||
}
|
|
||||||
applyAll();
|
|
||||||
|
|
||||||
// Bring the main region into view on tab switch — only when user has
|
|
||||||
// scrolled past the fold, to avoid a jarring jump when already at top.
|
|
||||||
const main = document.querySelector("[data-bookshelf-main]");
|
|
||||||
if (main) {
|
|
||||||
const top = main.getBoundingClientRect().top + window.scrollY - 72;
|
|
||||||
if (window.scrollY > top + 40) {
|
|
||||||
window.scrollTo({ top, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
typeBtns.forEach((btn) => on(btn, "click", () => switchType(btn.getAttribute("data-type-btn"))));
|
|
||||||
|
|
||||||
// Filter buttons wiring
|
// Filter buttons wiring
|
||||||
const wireFilterGroup = (selector, onPick) => {
|
const wireFilterGroup = (selector, onPick) => {
|
||||||
const btns = Array.from(document.querySelectorAll(selector));
|
const btns = Array.from(document.querySelectorAll(selector));
|
||||||
@@ -838,9 +505,6 @@ const recommendBadge = (status?: string) => {
|
|||||||
};
|
};
|
||||||
wireFilterGroup("[data-filter-books-category]", (b) => { state.books.category = b.getAttribute("data-filter-books-category") || "全部"; });
|
wireFilterGroup("[data-filter-books-category]", (b) => { state.books.category = b.getAttribute("data-filter-books-category") || "全部"; });
|
||||||
wireFilterGroup("[data-filter-books-status]", (b) => { state.books.status = b.getAttribute("data-filter-books-status") || "全部"; });
|
wireFilterGroup("[data-filter-books-status]", (b) => { state.books.status = b.getAttribute("data-filter-books-status") || "全部"; });
|
||||||
wireFilterGroup("[data-filter-comics-serial]", (b) => { state.comics.serial = b.getAttribute("data-filter-comics-serial") || "全部"; });
|
|
||||||
wireFilterGroup("[data-filter-papers-year]", (b) => { state.papers.year = b.getAttribute("data-filter-papers-year") || "全部"; });
|
|
||||||
wireFilterGroup("[data-filter-papers-field]", (b) => { state.papers.field = b.getAttribute("data-filter-papers-field") || "全部"; });
|
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
let searchTimer = 0;
|
let searchTimer = 0;
|
||||||
@@ -941,7 +605,7 @@ const recommendBadge = (status?: string) => {
|
|||||||
on(document, "keydown", (e) => { if (e.key === "Escape") closeModal(); });
|
on(document, "keydown", (e) => { if (e.key === "Escape") closeModal(); });
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
switchType("books");
|
applyAll();
|
||||||
|
|
||||||
window.__bookshelfCleanup = () => {
|
window.__bookshelfCleanup = () => {
|
||||||
cleanups.forEach((c) => c());
|
cleanups.forEach((c) => c());
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"updatedAt": "2026-09-15T02:39:58.551Z",
|
||||||
|
"source": "weread",
|
||||||
|
"books": [
|
||||||
|
{
|
||||||
|
"bookId": "26678299",
|
||||||
|
"title": "大师和玛格丽特(果麦经典)",
|
||||||
|
"author": "布尔加科夫",
|
||||||
|
"category": "文学-外国文学",
|
||||||
|
"cover": "https://wfqqreader-1252317822.image.myqcloud.com/cover/299/26678299/t6_26678299.jpg",
|
||||||
|
"progress": 30,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-09-13T15:08:26.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=34c329807197141b34cf1db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "3300048195",
|
||||||
|
"title": "正面管教(修订版)",
|
||||||
|
"author": "简•尼尔森",
|
||||||
|
"category": "教育学习-育儿",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/33/cpplatform_nxkyuocaxczycncqyfugye/t6_cpplatform_nxkyuocaxczycncqyfugye1675999568.jpg",
|
||||||
|
"progress": 0,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-09-09T05:05:04.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=bbc32a50813ab7953g01522a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "3300176703",
|
||||||
|
"title": "关于建国以来党的若干历史问题的决议",
|
||||||
|
"author": "中国共产党第十一届中央委员会",
|
||||||
|
"category": "政治军事-政治",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/6/cpplatform_bxvnmtl7wfftjdyxqgmpxt/t6_cpplatform_bxvnmtl7wfftjdyxqgmpxt1762827216.jpg",
|
||||||
|
"progress": 77,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-09-07T09:27:28.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=de6321e0813abab86g01378d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "3300020417",
|
||||||
|
"title": "西线无战事(译文经典)",
|
||||||
|
"author": "雷马克",
|
||||||
|
"category": "文学-外国文学",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/17/3300020417/t6_3300020417.jpg",
|
||||||
|
"progress": 100,
|
||||||
|
"finished": true,
|
||||||
|
"lastReadAt": "2026-09-06T09:45:42.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=a1932750813ab6e79g017e78"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "23796324",
|
||||||
|
"title": "昨日的世界",
|
||||||
|
"author": "茨威格",
|
||||||
|
"category": "文学-外国文学",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/58/YueWen_23796324/t6_YueWen_23796324.jpg",
|
||||||
|
"progress": 28,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-09-06T05:26:38.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=80a324f0716b1a6480af682"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "3300189057",
|
||||||
|
"title": "我弥留之际(果麦经典)",
|
||||||
|
"author": "[美]威廉·福克纳",
|
||||||
|
"category": "精品小说-社会小说",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/35/cpplatform_bypxixudgppfdrnvcvfuyq/t6_cpplatform_bypxixudgppfdrnvcvfuyq1766719527.jpg",
|
||||||
|
"progress": 13,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-06-15T16:24:59.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=b4432330813abb059g01728a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "23914527",
|
||||||
|
"title": "树莓派开始,玩转Linux",
|
||||||
|
"author": "Vamei 周昕梓",
|
||||||
|
"category": "计算机-计算机综合",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/55/YueWen_23914527/t6_YueWen_23914527.jpg",
|
||||||
|
"progress": 97,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-06-10T16:14:02.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=140329d0716ce81f140468e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bookId": "3300151878",
|
||||||
|
"title": "从Python开始学编程",
|
||||||
|
"author": "Vamei",
|
||||||
|
"category": "计算机-计算机综合",
|
||||||
|
"cover": "https://cdn.weread.qq.com/weread/cover/12/cpplatform_cmcqasqymymeqejem95ssq/t6_cpplatform_cmcqasqymymeqejem95ssq1751968250.jpg",
|
||||||
|
"progress": 0,
|
||||||
|
"finished": false,
|
||||||
|
"lastReadAt": "2026-06-10T16:13:58.000Z",
|
||||||
|
"deepLink": "https://weread.qq.com/book-detail?type=1&v=32f329e0813aba1d3g018fee"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信读书「最近在读」的数据读取与展示格式化。
|
||||||
|
*
|
||||||
|
* 数据由 `scripts/fetch-reading.mjs` 在构建前抓到 `src/data/reading.json`,
|
||||||
|
* 这个模块只读文件,不发任何网络请求 —— 页面是纯静态的。
|
||||||
|
*
|
||||||
|
* 只允许在 `.astro` 的 frontmatter 里用(依赖 node:fs,不能在客户端组件里 import)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReadingBook = {
|
||||||
|
bookId: string
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
|
/** 微信读书的分类是「大类-子类」复合串,如 `文学-外国文学` */
|
||||||
|
category: string
|
||||||
|
cover: string
|
||||||
|
/** 0-100 的整数;null 表示这次没取到进度(单本失败) */
|
||||||
|
progress: number | null
|
||||||
|
finished: boolean
|
||||||
|
/** ISO 字符串 */
|
||||||
|
lastReadAt: string
|
||||||
|
/** 微信读书的跳转链接,实测是普通 https,浏览器能直接打开 */
|
||||||
|
deepLink: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReadingData = {
|
||||||
|
updatedAt: string
|
||||||
|
source: string
|
||||||
|
books: ReadingBook[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_FILE = path.join(process.cwd(), "src/data/reading.json")
|
||||||
|
|
||||||
|
/** 读不到或格式不对时返回 null,调用方按空态处理,不要抛错。 */
|
||||||
|
export function getReadingData(): ReadingData | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) as ReadingData
|
||||||
|
if (!parsed || !Array.isArray(parsed.books)) return null
|
||||||
|
return parsed
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信读书的时间戳统一按 `YYYY-MM-DD` 展示(官方 Skill 文档的硬性要求),
|
||||||
|
* 顺带避开「N 天前」这类文案在静态站上会随构建时间变旧的问题。
|
||||||
|
*/
|
||||||
|
export function formatReadingDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return ""
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return ""
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user