512 lines
28 KiB
Markdown
512 lines
28 KiB
Markdown
# 时之刃的小站
|
||
|
||
个人博客源码 —— [tianxuetao.top](https://tianxuetao.top)
|
||
|
||
一个基于 Astro 的静态博客,主题为 [VermilionVoid](https://github.com/Lapis0x0/VermilionVoid)。除了常规的文章系统,还有碎碎念、读书页(微信读书同步)、学习追踪与加密文章。
|
||
|
||
站点配置集中在两个文件,改站名或换域名通常只需要动第一个:
|
||
|
||
| 文件 | 管什么 |
|
||
|---|---|
|
||
| `src/data/site.ts` | 站名、域名、描述、关键词、主题色、统计分析 |
|
||
| `src/data/profile.ts` | 昵称、签名、头像、社交链接、作者卡片底部的状态小字 |
|
||
|
||
评论配置在 `src/data/comments.ts`。
|
||
|
||
> 日常操作(写文章、发偶得、加照片、部署)看 **[docs/操作指南.md](docs/操作指南.md)**,那份更偏"照着做";本文偏项目结构与原理。
|
||
|
||
---
|
||
|
||
## 技术栈
|
||
|
||
| 层 | 选型 |
|
||
|---|---|
|
||
| 框架 | [Astro](https://astro.build) 5(`output: "static"` 纯静态) |
|
||
| 交互组件 | React 19(islands)+ Radix UI + Tailwind CSS 4 |
|
||
| 代码块 | Expressive Code(行号、折叠、语言徽章、自定义复制按钮) |
|
||
| 数学公式 | remark-math + KaTeX |
|
||
| 站内搜索 | Pagefind(构建时索引) |
|
||
| 页面切换 | Astro View Transitions(`<ClientRouter />`) |
|
||
| 部署 | `./deploy.sh`(rsync 到树莓派 + 云服务器) |
|
||
| 评论系统 | 自托管的 [twikoo](https://twikoo.js.org)(见下) |
|
||
|
||
包管理器是 **pnpm**。
|
||
|
||
## 快速开始
|
||
|
||
```bash
|
||
pnpm install
|
||
```
|
||
|
||
```bash
|
||
pnpm dev
|
||
```
|
||
|
||
| 命令 | 作用 |
|
||
|---|---|
|
||
| `pnpm dev` | 本地开发服务器(默认 `http://localhost:4321`) |
|
||
| `pnpm build` | 构建到 `dist/`,并跑 Pagefind 建立搜索索引 |
|
||
| `pnpm preview` | 本地预览构建产物 |
|
||
| `pnpm check` | Astro 的类型与内容校验(本项目没有单元测试框架,这就是唯一的自动检查) |
|
||
| `pnpm verify:css` | 单独跑构建产物 CSS 自检(`pnpm build` 和 `deploy.sh` 里都会自动跑) |
|
||
| `pnpm new-thought "..."` | 快速创建一条「偶得」(见下) |
|
||
|
||
## 项目结构
|
||
|
||
```
|
||
src/
|
||
├── pages/ 路由入口(每个文件对应一个 URL)
|
||
├── layouts/ 页面骨架
|
||
├── components/ Astro / React 组件,可复用的原子组件放 components/ui/
|
||
├── content/ Markdown 内容集合(见下)
|
||
├── data/ 站点配置:站点信息、个人信息、相册、评论、友链
|
||
├── styles/ 全局 CSS
|
||
├── scripts/ 浏览器侧交互(lifecycle 契约,见下)
|
||
├── plugins/ 自定义 remark / rehype / Expressive Code 插件
|
||
├── lib/ hooks/ 支撑逻辑
|
||
public/ 原样拷贝到产物根目录的静态资源
|
||
├── assets/images/ 文章配图
|
||
├── gallery/<id>/ 相册照片,构建时自动扫描
|
||
scripts/ 内容工具脚本
|
||
```
|
||
|
||
## 交互层
|
||
|
||
除了 React islands,站点还有一批「绑全局监听 / 操作 DOM」的原生脚本(主题切换、
|
||
代码复制、github 卡片、灯箱、移动端菜单、评论、回到顶部)。它们都注册在
|
||
`src/scripts/interactions.ts`,由 `src/layouts/BaseLayout.astro` 引一次。
|
||
|
||
因为页面切换走 `<ClientRouter />`(Astro View Transitions),DOM 每次导航都会被换掉,
|
||
而**模块脚本只会求值一次**。所以新增交互不要自己监听 `astro:page-load`、也不要用
|
||
`window.__xxxBound` 防重复绑定,改用 `src/scripts/lifecycle.ts`:
|
||
|
||
```ts
|
||
import { onPage } from "@/scripts/lifecycle"
|
||
|
||
onPage("my-widget", () => {
|
||
const el = document.querySelector("[data-my-widget]")
|
||
if (!el) return // 本页没有这个组件:不挂载,也不报错
|
||
const onClick = () => { /* ... */ }
|
||
el.addEventListener("click", onClick)
|
||
return () => el.removeEventListener("click", onClick) // astro:before-swap 时调用
|
||
})
|
||
```
|
||
|
||
`lifecycle` 在 `astro:before-swap` 卸载全部、在 `astro:page-load` 挂载全部,
|
||
并保证首屏、切页、模块重复求值这三种情况下都不会重复绑定或漏绑。
|
||
|
||
页面级的小交互如果**不需要 JS** 就别写 JS:阅读进度条和回到顶部按钮的显隐都由
|
||
CSS 滚动驱动动画(`animation-timeline: scroll()`)负责,脚本只管点击与老浏览器降级。
|
||
|
||
### ⚠️ 滚动驱动动画的两条硬规矩
|
||
|
||
2026-09-21 踩过一次:线上按钮不显示、本地 dev 一切正常。原因是 Lightning CSS
|
||
(Tailwind v4 的压缩器)把 `animation-timeline` 并进了 `animation` 简写,产出
|
||
`animation: linear both back-to-top-reveal scroll(root)` —— 而 WebKit 不认简写里的
|
||
timeline 分量([webkit#293276](https://wiki.webkit.org/show_bug.cgi?id=293276)),
|
||
**整条声明被判无效丢弃**,元素永远停在初始的隐藏样式;更坑的是
|
||
`CSS.supports("animation-timeline: scroll()")` 照样返回 true,运行时特性检测看不出问题
|
||
([lightningcss#815](https://github.com/parcel-bundler/lightningcss/issues/815))。因此:
|
||
|
||
1. **写 CSS 时**:`animation-timeline` / `animation-range` 那条规则的选择器要和
|
||
`animation` 简写那条**不同**(现有代码用 `.reading-progress > .reading-progress-bar`
|
||
和 `.back-to-top[data-back-to-top]`),否则压缩器会把两条相邻的同选择器规则合并。
|
||
刻意用后代 / 属性选择器而不是 `:where()`,是为了让特异性不低于简写那条 ——
|
||
`animation` 简写会把 `animation-timeline` 重置回 `auto`。
|
||
2. **写 JS 时**:不要用 `CSS.supports()` 判断滚动动画是否可用,直接读
|
||
`getComputedStyle(el).animationName` —— 只有这个才能反映「规则到底有没有活下来」。
|
||
`src/scripts/back-to-top.ts` 就是这么做的,CSS 那边一旦失效它会自动接管。
|
||
|
||
`pnpm build` 和 `deploy.sh` 都会跑 `scripts/verify-build-css.mjs` 拦截这类回退。
|
||
|
||
## 写作
|
||
|
||
内容集合定义在 `src/content/config.ts`,共三个:
|
||
|
||
### `posts` —— 正式文章
|
||
|
||
放在 `src/content/posts/`,支持子目录分类。
|
||
|
||
```yaml
|
||
---
|
||
title: 文章标题
|
||
published: 2026-08-17
|
||
updated: 2026-08-18 # 可选
|
||
description: 摘要 # 可选,用于 SEO 和卡片
|
||
image: /images/cover.webp # 可选,封面
|
||
tags: [Astro, 前端]
|
||
category: TechnicalTutorials
|
||
pinned: false # 置顶
|
||
draft: false # 草稿不会被构建
|
||
---
|
||
```
|
||
|
||
**加密文章**:给 frontmatter 加上 `encrypted: true` 和 `password: "你的密码"`,该文章正文会被加密,需输入密码才能阅读。适合半公开的内容。
|
||
|
||
**免责声明**:`disclaimer` 字段可以引用 `src/data/disclaimers.ts` 里预设的声明文案。
|
||
|
||
### `thoughts` —— 偶得
|
||
|
||
短内容,放在 `src/content/thoughts/`。只有 `published` 是必填,其余可选。
|
||
|
||
```yaml
|
||
---
|
||
published: 2026-09-14T12:36:47.215Z # 必填。务必带时区,见下方说明
|
||
title: 可选标题
|
||
location: 张掖 # 可选,显示在日期旁
|
||
weather: 晴 27°C # 可选
|
||
images: # 可选,public 下的绝对路径
|
||
- /gallery/GalleryFav/某图.webp
|
||
tags: [生活]
|
||
pinned: false # 可选,置顶排在最前
|
||
---
|
||
```
|
||
|
||
用脚本创建更省事:
|
||
|
||
```bash
|
||
pnpm new-thought "换了散热,温度降了 15 度。" --location 张掖 --tags 硬件 --pinned
|
||
```
|
||
|
||
> **`published` 一定要带时区**(`2026-09-14T12:36:47Z` 这种)。写成不带时区的 `2026-09-14 12:36`,YAML 会按 UTC 解析,显示时会偏移。偶得页按 `src/data/site.ts` 的 `timezone` 渲染,所以带时区的时间才会显示正确。
|
||
|
||
### `studies` —— 专题
|
||
|
||
```yaml
|
||
---
|
||
title: 主题
|
||
status: 在读 # 在读 | 沉淀中 | 暂搁 | 已结
|
||
started: 2026-08
|
||
field: 领域 # 可选
|
||
subtitle: 副标题 # 可选
|
||
epigraph: 题记 # 可选
|
||
---
|
||
```
|
||
|
||
## 相册
|
||
|
||
照片放在 `public/gallery/<相册id>/`,构建时**自动扫描目录**,不需要在配置里逐个列出。
|
||
|
||
新增一个相册 = 两件事:
|
||
|
||
1. 在 `src/data/gallery.ts` 的 `albums` 里加一项(`id` 要和目录名一致)
|
||
2. 建目录 `public/gallery/<id>/` 并放照片
|
||
|
||
```ts
|
||
{
|
||
id: "GalleryFav",
|
||
name: "喜欢的壁纸",
|
||
description: "相册描述",
|
||
location: "甘肃-张掖",
|
||
date: "2026-07-18",
|
||
tags: ["壁纸", "收藏"], // 用于索引页筛选
|
||
}
|
||
```
|
||
|
||
封面优先级:`cover` 字段 > 目录里的 `cover.*` 文件 > 第一张图。
|
||
|
||
> 照片是原样下发的静态资源,**不经过 Astro 优化**。分辨率高的图建议先压成 WebP 再放进来,否则单页可能几十 MB。
|
||
|
||
## 自定义
|
||
|
||
想把这个博客改成你自己的,主要改这几处:
|
||
|
||
| 想改什么 | 改哪里 |
|
||
|---|---|
|
||
| 站名、域名、描述、主题色、统计 | `src/data/site.ts` |
|
||
| 昵称、签名、头像、社交链接、状态小字 | `src/data/profile.ts` |
|
||
| 相册 | `src/data/gallery.ts` + `public/gallery/` |
|
||
| 评论(twikoo) | `src/data/comments.ts` |
|
||
| 友链列表 | `src/data/friends.json` |
|
||
| 首页打字机文案 | `src/components/hero.tsx` 顶部的 `phrases` |
|
||
| 导航栏、页脚 | `src/components/site-header.astro`、`src/components/footer.astro` |
|
||
| 配色与全局样式 | `src/styles/global.css` |
|
||
| 代码块主题 | `astro.config.mjs` 里 `expressiveCode` 的 `themes` |
|
||
| 免责声明模板 | `src/data/disclaimers.ts` |
|
||
| 头像、favicon 等静态资源 | `public/` |
|
||
|
||
代码风格:Astro/TS/TSX 一律 2 空格缩进。组件命名 `PascalCase` 与 `kebab-case` 混用,跟着所在目录的既有风格走即可。项目没有配 formatter,改动尽量贴合周围代码。
|
||
|
||
### 浅色配色:暖纸底,不用亮白
|
||
|
||
浅色模式**刻意不用纯白**。纯白(L\*≈99)在屏幕上偏刺眼,而且大面积纯色没有视觉落点,看久了发死。整套浅色 token 压到 L\*≈96 并给了一点暖黄彩度(oklch hue 84 ≈ 米黄),墨色也换成暖褐而非纯黑:
|
||
|
||
| token | 值 | 说明 |
|
||
|---|---|---|
|
||
| `--background` | `oklch(0.964 0.014 84)` | 页面底 `#f8f3e9` |
|
||
| `--surface-subtle` | `oklch(0.952 0.016 84)` | 区块底 `#f4eee3`,比页面底再深一档 |
|
||
| `--card` | `oklch(0.98 0.011 84)` | 卡片**比页面底亮**,靠明度差拉出层次 `#fcf8f0` |
|
||
| `--foreground` | `oklch(0.235 0.013 68)` | 暖墨 `#221d18`,不是 `#000` |
|
||
| `--muted-foreground` | `oklch(0.505 0.012 70)` | 元信息,对比度 5.3:1 |
|
||
| `--border` / `--input` | `oklch(0.888 0.016 84)` | |
|
||
| `--codeblock-bg` | `oklch(0.945 0.016 84)` | 代码块阴槽 `#f2ece1`,比页面底更深且同色相 |
|
||
| `--codeblock-topbar-bg` | `oklch(0.915 0.017 84)` | 代码块顶栏 `#e8e2d7` |
|
||
|
||
> `--codeblock-bg` 原来(`oklch(0.97 0.003 85)`)是**中性灰白**,在暖纸底上会变成一块冷白补丁,很跳。改配色时注意:代码块要比 `--background` 更深、色相一致,不能只是"接近白"。
|
||
|
||
浓淡的三档(页面底是 `0.964`):
|
||
| 档 | 块体 | 顶栏 | 与页面底的明度差 |
|
||
|---|---|---|---|
|
||
| 轻(当前) | `0.945` `#f2ece1` | `0.915` `#e8e2d7` | 1.06:1 |
|
||
| 中 | `0.925` `#ece6da` | `0.893` `#e1dbcf` | 1.12:1 |
|
||
| 重 | `0.905` `#e5dfd3` | `0.873` `#dbd4c7` | 1.19:1 |
|
||
|
||
对比度都还留得很足(正文 15:1、次要文字 7.5:1、主色链接 4.9:1,WCAG AA 要求 4.5:1),所以是「柔和」不是「发灰」。**暗色模式没动。**
|
||
|
||
调暖 / 调冷就改这几个数字的**色相**(84 = 米黄,70 更偏橙,95 更偏柠檬);想更白就把 L 往上推、彩度往下压。
|
||
|
||
### 纸纹底
|
||
|
||
`--paper-noise` 是一段内联 SVG 的 `feTurbulence` 分形噪声,不需要任何图片文件:
|
||
|
||
```css
|
||
--paper-noise: url("data:image/svg+xml,...feTurbulence baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'...");
|
||
--paper-noise-opacity: 0.2; /* 暗色 0.22 */
|
||
```
|
||
|
||
由 `body::before` 铺成一层 `position: fixed` 的整屏颗粒(`z-index: 1`、`pointer-events: none`)。它有两个作用:给纯色底一点纸的质感;让 `backdrop-filter` 有细节可模糊 —— 背后是纯色时磨砂等于不存在。
|
||
|
||
调参:
|
||
|
||
- **颗粒粗细**看 `baseFrequency`,越大越细(0.7–0.9 是细砂纸,0.3 会变成明显斑块)
|
||
- **浓淡**看 `--paper-noise-opacity`。实测颗粒亮度标准差:0.05 → 0.7,0.2 → 1.9,0.3 → 2.9(0 = 完全平整)。2 左右是「看得出是纸、但不觉得脏」的档位
|
||
- `stitchTiles='stitch'` 不能删,否则平铺有接缝
|
||
- 伪元素用的是**灰阶 alpha 噪点**(`feColorMatrix saturate=0`),所以不需要 `mix-blend-mode`,亮暗通用
|
||
|
||
### 移动端字号
|
||
移动端(<768px)的字号比桌面端小一档,用 Tailwind 的移动优先写法表达 —— 基础值是手机用的,`md:` 前缀是桌面恢复值:
|
||
|
||
```html
|
||
<!-- 正文:手机 15.5px / 1.78,≥768px 回到 16.5px / 1.85 -->
|
||
prose-p:text-[15.5px] md:prose-p:text-[16.5px]
|
||
prose-p:leading-[1.78] md:prose-p:leading-[1.85]
|
||
```
|
||
|
||
改动集中在 `ArticleContent.astro`(正文、标题、引用、列表)和 `article-list.tsx`(列表区标题)。**不要动 `html` 的根字号**,那会连带缩放所有 rem 间距,容易连锁出问题。
|
||
|
||
### 移动端分类胶囊条
|
||
|
||
手机端侧栏(`sidebar.tsx`)被排在文章列表**下面**(实测第一篇文章 y=999、侧栏 y=3212),分类筛选基本摸不到。所以 `article-list.tsx` 在列表顶部额外渲染了一条吸附胶囊条:
|
||
|
||
- `sticky top-[var(--header-h)]`,跟在固定页眉下面;`-mx-6` 拉通全宽,内部再 `px-6` 对齐正文
|
||
- 左端第一个胶囊是当前模式,末尾一个胶囊用来在「分类 / 标签」之间切换
|
||
- 两端渐隐提示可以横向滑(靠 `filterEdges` 状态控制显隐),滚动条用 `.no-scrollbar` 藏掉
|
||
- **复用同一份** `categories` / `activeCategory` / `handleCategoryChange`,不写第二套逻辑;同时把 `sidebar.tsx` 里那两个块加了 `hidden lg:block`,避免同页出现两份
|
||
|
||
`--header-h: 69px` 是固定页眉的实测高度,移动端菜单和这条胶囊条都靠它定位。**改页眉内边距或头像尺寸时记得同步改它**,否则吸附位置会错。
|
||
|
||
### 页脚
|
||
|
||
`src/components/footer.astro`(原来是个 React 组件 `footer.tsx`,为了能在构建期 `await` 统计数据改成了 Astro 组件;引用方式随之从 `import { Footer } from "@/components/footer"` 变成 `import Footer from "@/components/footer.astro"`)。
|
||
|
||
结构:
|
||
|
||
| 区块 | 内容 | 显示范围 |
|
||
|---|---|---|
|
||
| 站点信息卡片 | 建站时间 / 已运行 / 文章 / 偶得 / 总字数 / 最近更新,卡片样式沿用侧栏作者卡片 | 全部 |
|
||
| 最新文章 | 最近 4 篇(按发布时间倒序,不把置顶提前) | 桌面 |
|
||
| 联系 | 社交图标 + 订阅 RSS | 全部 |
|
||
| 底栏 | 版权 / 备案 / 主题署名 | 全部 |
|
||
|
||
两处**刻意不放**的东西:
|
||
|
||
- **「导航」栏已删除** —— 它是顶部导航的逐项复制(9 条名字和目标完全一样),手机上白占 312px。桌面端改用「最新文章」,把重复变成发现。不影响 SEO:顶部导航和移动端菜单里的链接在每页 HTML 里都还在。
|
||
- **页脚不再放「我的介绍」** —— 侧栏作者卡片已经有了,再放一份是重复。页脚那份的视觉位置让给了站点信息卡片。
|
||
|
||
「我的介绍 → 关于页」在**侧栏作者卡片**里(`sidebar.tsx`):站名和签名那两行包在同一个 `<a href="/about/">` 里,不做 hover 装饰。只能包这两行——下面的社交图标本身是 `<a>`,`<a>` 不能嵌套 `<a>`。`aria-label` 是必须的,否则读屏会把「站名 + 签名」念成链接名。
|
||
|
||
统计数据在 `src/lib/site-stats.ts`:文章数/偶得数/分类数/标签数/总字数在构建时算,「已运行 N 天」和「最近更新」由页脚里的内联脚本按**真实时间**刷新(站点久不重建也不会停在构建那天),服务端会先渲染一份构建时兜底值,关 JS 也不空。加减统计项改 `footer.astro` 里的 `statCells` 数组即可。
|
||
|
||
### 磨砂玻璃
|
||
|
||
`global.css` 里有四个 token:
|
||
|
||
| token | 默认 | 用途 |
|
||
|---|---|---|
|
||
| `--glass-bg` | `color-mix(in oklch, var(--card) 72%, transparent)` | 面板底色,由 `--card` 派生,亮/暗自动跟随 |
|
||
| `--glass-border` | `color-mix(in oklch, var(--border) 75%, transparent)` | 面板描边 |
|
||
| `--glass-blur` | `14px` | 模糊半径 |
|
||
| `--glass-saturate` | `1.5` | 饱和度补偿,纯模糊会发灰 |
|
||
|
||
用法:`background-color: var(--glass-bg); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));`
|
||
|
||
**只给「浮在内容上方、面积不大」的元素用**(移动端菜单面板、页眉、搜索面板)。不要给文章卡片大面积用,一屏十几个元素同时 `backdrop-filter` 会明显掉帧。另外记得带上 `-webkit-` 前缀,并在 `@supports not (backdrop-filter: blur(1px))` 里回退成不透明的 `var(--card)`。
|
||
|
||
> 移动端页眉目前**故意关掉**了常驻磨砂(`site-header.astro` 的 `@media (max-width: 767px)` 那段):固定元素持续模糊会让移动端合成开销变大。要开的话建议用「滚动后才开」的方式,别让它一直生效。
|
||
|
||
|
||
## 书架与「最近在读」
|
||
|
||
书架是**两个独立页面**,由侧栏「馆藏」互相导航(`src/components/bookshelf-content.astro` 一个组件,靠 `view` 属性决定渲染哪一块):
|
||
|
||
| 页面 | 路径 | 内容 | 侧栏数量 |
|
||
|---|---|---|---|
|
||
| 在读 | `/bookshelf/` | 微信读书里没读完的 | 未读完本数 |
|
||
| 书籍 | `/bookshelf/books/` | 手工书单(`books.json`)+ 微信读书已读完 | 两者相加 |
|
||
|
||
顶部导航「书架」指向 `/bookshelf/`,所以点进来先落在**在读**页。
|
||
|
||
两块内容:
|
||
|
||
**1. 最近在读** —— 来自微信读书,构建时抓取,页面本身不发任何请求。
|
||
|
||
```
|
||
微信读书 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)。默认抓**整架**(1 + N 个请求,书架几十本完全够用);想只抓最近动过的几本就 `pnpm reading --top 12`
|
||
- **`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**(别用它),但 `book.readingTime` 是真数据,累计时长取后者(已存进 JSON 的 `readingSeconds`,卡片暂未展示)
|
||
- `book.startReadingTime` **官方文档没列,但回包里确实有**,就是「开始阅读时间」;只有 readUpdateTime 那 26 本里有 8 本没有它(基本都是没怎么打开的书)
|
||
- `deepLink` 实测是普通 https(`https://weread.qq.com/book-detail?...`),浏览器能直接打开
|
||
- `category` 是 `文学-外国文学` 这种「大类-子类」复合串
|
||
- 时间戳统一按 `YYYY-MM-DD` 展示(官方硬性要求),顺带避开「N 天前」在静态站上会随构建时间变旧
|
||
|
||
展示逻辑:
|
||
|
||
| 归到哪 | 条件 |
|
||
|---|---|
|
||
| 最近在读 | `progress < 100`(含 0%,标「还没开始」) |
|
||
| 书籍 → 「微信读书 · 已读完」分组 | `progress === 100` |
|
||
|
||
**两处用的是同一个组件 `src/components/reading-card.astro`**,保证样式统一 —— 横版卡片、封面在左、信息在右,只有底部两行不同:
|
||
|
||
| | 第 1 行 | 第 2 行 |
|
||
|---|---|---|
|
||
| 在读 | 进度条 + 百分比 | 最近阅读 X |
|
||
| 已读完 | 开始 X | 读完 Y |
|
||
|
||
「已读完」那一组混在「书籍」区里,但没有书评/简介,所以点击直接跳微信读书而不是弹窗。它们带 `data-weread-card` 和 `data-category="微信读书"`,所以搜索框和「领域」筛选同样作用得到(领域里会多出一个「微信读书」选项)。过滤掉的:私密阅读(`secret === 1`)、从没打开过的书;有声书不处理。
|
||
|
||
**2. 书籍** —— 来自 `public/data/books.json`,由 `bookshelf-content.astro` 在构建时 import(文件必须存在)。另外两个文件 `papers.json` / `comics.json` 对应的漫画、论文分类已经移除,文件保留但不再被引用。
|
||
|
||
`books.json` 为空时,**统计行(N 本已读 / N 本推荐 / N 个领域)、搜索框、领域和评价筛选条会整块隐藏** —— 一排「0」加一排没东西可筛的按钮纯占地方。往 `books.json` 加了书它们会自动回来,不用改代码。手工书单和微信读书都已读完都为空时,会显示一句兜底提示。
|
||
|
||
**两个页面共用同一个组件**,不会出现样式跑偏:`bookshelf-content.astro` 收一个 `view: "reading" | "books"`,只是决定渲染哪一块。侧栏那两行是**真链接**(带 `aria-current` 和主色高亮表示当前页),不再是页内锚点。
|
||
|
||
## 部署
|
||
|
||
主用 `./deploy.sh`,构建后 rsync 到树莓派和云服务器:
|
||
|
||
```bash
|
||
./deploy.sh # 构建 + 部署到两个目标(覆盖生产前会确认)
|
||
./deploy.sh --pi-only # 只推树莓派(内网预览)
|
||
./deploy.sh --cloud-only # 只推云服务器
|
||
./deploy.sh --dry-run # 只显示 rsync 会做什么
|
||
./deploy.sh --skip-build # 不重新构建,推现有 dist/
|
||
./deploy.sh -y # 跳过确认
|
||
```
|
||
|
||
目标地址和 SSH 密钥在脚本顶部的**配置区**。脚本会在构建失败或产物异常时**直接中止**——因为推送用的是 `rsync --delete`,拿一个残缺的 `dist/` 上去会清空线上目录。
|
||
|
||
### 两个目标各自怎么服务的
|
||
|
||
| 目标 | 服务方式 | 地址 |
|
||
|---|---|---|
|
||
| 云服务器 | **1Panel 管理的 Caddy 容器**(`1Panel-caddy-meZ7`),配置在 `/opt/1panel/apps/caddy/caddy/data/conf/Caddyfile`,站点根 `/srv/astro-blog` | <https://tianxuetao.top> |
|
||
| 树莓派 | **Caddy 容器**(`blog-caddy`,`--restart unless-stopped`),配置在 `/home/pi/caddy/Caddyfile`,站点根 `/home/pi/www/Astro_firefly` | <http://192.168.1.87> |
|
||
|
||
**两个都开了压缩**(`encode zstd gzip`)。这个别删:`twikoo.min.js` 裸传 506 KB、压完 160 KB,主 CSS 231 KB → 41 KB。Caddy 按 `Accept-Encoding` 自动协商 zstd / gzip。
|
||
|
||
Pi 上原本没有 web 服务(文件 rsync 过去了但 80 端口没人听),2026-09-15 补了 Caddy 容器。重建命令:
|
||
|
||
```bash
|
||
ssh pi@192.168.1.87
|
||
mkdir -p /home/pi/caddy/{data,config}
|
||
cat > /home/pi/caddy/Caddyfile <<'EOF'
|
||
:80 {
|
||
root * /srv
|
||
encode zstd gzip
|
||
file_server
|
||
}
|
||
EOF
|
||
docker run -d --name blog-caddy --restart unless-stopped -p 80:80 \
|
||
-v /home/pi/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \
|
||
-v /home/pi/caddy/data:/data -v /home/pi/caddy/config:/config \
|
||
-v /home/pi/www/Astro_firefly:/srv:ro caddy:alpine
|
||
```
|
||
|
||
> 云服务器的 Caddyfile 是 1Panel 生成的。手动改完之后 1Panel 那边可能不认识,**改前先按它已有的习惯备份**(`Caddyfile.bak.<说明>.<时间戳>`),改完用 `docker exec 1Panel-caddy-meZ7 caddy validate --config /etc/caddy/Caddyfile` 校验再 `caddy reload`——reload 是原子的,配置有错会保留旧配置继续跑,站点不会挂。
|
||
|
||
`vercel.json` 仍然保留,把它接到 Vercel 也可以。走 Vercel 时需要在项目里配置环境变量 `PUBLIC_TWIKOO_ENV_ID`(评论后端地址,留空则不渲染评论区);本地开发写进 `.env`(已 gitignore),参考 `.env.example`。
|
||
|
||
---
|
||
|
||
## 评论系统
|
||
|
||
使用 [twikoo](https://twikoo.js.org),后端为自托管实例。
|
||
|
||
配置在 `src/data/comments.ts`:
|
||
|
||
```ts
|
||
export const commentConfig = {
|
||
twikooEnvId: "https://twikoo.tianxuetao.top",
|
||
lang: "zh-CN",
|
||
stripTrailingSlash: true,
|
||
}
|
||
```
|
||
|
||
`twikooEnvId` 留空则整个评论区不渲染;也可以用环境变量 `PUBLIC_TWIKOO_ENV_ID` 覆盖,优先级更高。
|
||
|
||
**前端脚本不走 CDN**,而是自托管的 `public/assets/js/twikoo.min.js`。国内访问 jsDelivr 很不稳定(实测 3 秒以上,且常被墙),走 CDN 会让评论区长时间空白甚至加载不出来。该文件的版本要与后端保持一致(当前都是 **1.7.20**,可用 `GET_FUNC_VERSION` 查后端版本),升级后端时记得同步替换它。
|
||
|
||
样式分两层,**两个文件必须放在 `src/components/Comments.astro` 的静态 `<link>` 里**:
|
||
|
||
| 文件 | 作用 |
|
||
|---|---|
|
||
| `public/assets/css/twikoo.css` | 官方原样,从 npm 的 `twikoo@<版本>/dist/twikoo.css` 取,**不要手改** |
|
||
| `public/assets/css/twikoo-custom.css` | 主题适配层,颜色引用主题变量,自动跟随亮/暗色 |
|
||
|
||
`twikoo.css` 必须是**静态 `<link>`**,不能只靠 `twikoo.min.js` 运行时注入。脚本会把样式 `appendChild` 到 `<head>`,而 `ClientRouter` 切页时会清掉 head 里它不认识的 `<style>`;脚本本身又只执行一次(`window.twikoo` 已存在就跳过),样式不会重新注入。症状是从一篇带评论的文章点进另一篇后,评论区排版全崩——markdown 图标被撑成几百像素、文件输入框露出来、表情面板塌成一列,**整页刷新才恢复**。静态 `<link>` 放在 `<body>` 里,切页时会随新页面一起换进来,因此不受影响。
|
||
|
||
`twikoo-custom.css` 的选择器统一加了 `#twikoo` 前缀,保证压过官方样式。
|
||
|
||
> 升级 twikoo 时,`twikoo.min.js` 和 `twikoo.css` 要**一起换**,都从同一个 npm 版本的 `dist/` 里取。
|
||
> 顺带一提:官方 `twikoo.nocss.js` 是 all 完整版去掉 CSS(997 KB),比 `twikoo.min.js`(506 KB,CSS 打包在内)大近一倍,本站服务器又没开 gzip,所以没有采用它。
|
||
|
||
前端逻辑在 `src/components/Comments.astro`,处理了两件事:
|
||
|
||
- **切页重新挂载**:本站启用了 Astro 的 `ClientRouter`。注意 `astro:page-load` 在**首屏也会触发**,所以用 `dataset.mounted` 标记防止重复初始化;切页后是新容器、没有该标记,会正常重新挂载。
|
||
- **评论归属路径**:`trailingSlash` 是 `"always"`,但评论 key 统一去掉结尾斜杠。浏览器提交的是**百分号编码**过的 `location.pathname`(如 `/posts/%E7%94%9F%E6%B4%BB/...`),后端按原样存储和匹配 —— 所以去斜杠后的编码路径正好与历史数据一致。改掉这条规则,老评论就会对不上。
|
||
|
||
评论出现在:文章页、关于页、友链页。
|
||
|
||
主题原作者的自托管评论后端 `cwd-api/` 已随迁移移除。
|
||
|
||
---
|
||
|
||
## Fork 须知
|
||
|
||
如果你要基于这个仓库搭自己的博客,至少改这几处:
|
||
|
||
1. **`src/data/site.ts`** —— 站名、域名、描述、主题色,以及 `analytics` 里的统计 ID(每一项留空就不加载对应脚本)。`astro.config.mjs` 的 `site` 已经引用这里的 `site.url`,不用单独改。
|
||
2. **`src/data/profile.ts`** —— 昵称、签名、头像、社交链接、状态小字。
|
||
3. **`src/data/comments.ts`** —— twikoo 后端地址,填你自己部署的实例。
|
||
4. **`src/data/friends.json`** —— 友链列表。
|
||
|
||
另外这几处仍是从主题原作者仓库带过来的内容,按需清空替换:
|
||
|
||
- `src/content/` 下的文章、碎碎念、学习专题
|
||
- `public/data/*.json` 的书架数据
|
||
- `public/images/vote/` 的赞赏收款码(关于页已不再引用)
|
||
|
||
## 致谢
|
||
|
||
文章系统的部分设计参考了 [Fuwari](https://github.com/saicaca/fuwari),主题为 [VermilionVoid](https://github.com/Lapis0x0/VermilionVoid)。
|
||
|
||
## 许可
|
||
|
||
代码部分可自由参考取用。`src/content/` 下的文章内容版权归作者所有,转载请注明出处。
|