feat(content): 置顶契约与计数徽标组件(5h T1 契约部分)
置顶(T1):
- src/lib/post-order.ts 新增 sortPosts:置顶优先 → pubDate 倒序 → id 兜底,
把首页双轨/归档/书架四处排序口径收敛到一处
- postsSchema 增 pinned;PostList 三处渲染路径加灰阶 pin 小徽标;后台置顶开关
- 语义边界:只影响列表顺序,不改 pubDate/URL/RSS 时间语义
计数徽标(T4 契约组件):
- src/components/geek/ViewMeta.astro:眼睛 = 浏览数、气泡 = 评论数,
本地 SVG + tokens 着色,不引第三方图片
- 数据只用 Artalk 匿名端点:GET comments?limit=1 一次拿到 count 与 page.pv;
page 模式先 POST pages/pv 计一次浏览,readonly 模式零写入
- SSR 占位「–」:无 JS/接口失败不显示 0 误导读者
验证: pnpm astro build 48 页 exit 0;config-check 208 字段/65 hint 全过;
sortPosts 四调用点替换后构建通过
Spec: 2026-11-02_blog-5h-content-social (T1 契约)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
ed473e996a
commit
6538311387
12 files changed
+388
-9
No files matched your search
@@ -0,0 +1,91 @@
|
|||||||
|
# Phase 5h:内容表达能力与社交反馈 - 项目范围
|
||||||
|
|
||||||
|
<!-- spec-format: 2 -->
|
||||||
|
|
||||||
|
## 1. 问题定义
|
||||||
|
- **项目目标**:①文章支持置顶(作者指定优先)②瞬间的文字与文章同级表达力(字体可换、颜色可换、多段落/列表/强调)③瞬间每条可独立评论 ④浏览量与评论数用统一的「眼睛/气泡」计数徽标呈现(对齐开源页 GitHub 档案的眼图标语汇)⑤开源页开篇从 GitHub README 直接移植导致:`<div align="center">` 内联对齐失效、shields/vercel/snake/komarev 等外链图在慢网下空白破版、README 的「About Me」与开源页主题无关——重构为原生组件并从 README 解耦 ⑥关于页承接读完 README 后仍想保留的个人叙述 ⑦新增一篇「写作模板」示例文章,把可用排版能力一次演示完
|
||||||
|
- **目标用户**:站长(写得更自由,少打字面 HTML)、读者(读到更丰富且不破版的页)
|
||||||
|
- **核心价值**:内容表达力与后台编辑力对上,同时把外链依赖与版式事故面砍掉;社交反馈(浏览/评论)在灰阶体系内可量化可见
|
||||||
|
|
||||||
|
## 2. 假设与待确认
|
||||||
|
|
||||||
|
### 2.1 已确认事实
|
||||||
|
- Artalk v2 匿名可读:`GET /artalk/api/v2/comments?site_name=…&page_key=…&limit=1` 返回 `{count, roots_count, page:{pv}}`(生产实测,2026-11-02);`POST /artalk/api/v2/pages/pv` 返回 `{pv}`(现有组件在用)
|
||||||
|
- 富文本管线已存在且已净化:`renderRich`(markdown + rehype-content-sanitize 标签/属性/协议三层白名单)与 `rehype-class-whitelist`(35 项排版类,单一事实源 `public/admin/typography-classes.js`)
|
||||||
|
- 现有 6 自托管字族(含 font-huiwen 子集化 420KB 单页)与 16 彩色语义类 + 4 灰阶语义类
|
||||||
|
- 开源页 README 现由 `intro_from: "off"` 关闭,缓存文件 `content/.github-readme.md`(3138B)含 6 类外链图(readme-typing-svg / shields.io / github-readme-stats / streak-stats / activity-graph / snake / komarev)
|
||||||
|
- 首页/归档/书架三处列表排序均为 `pubDate` 降序,无置顶概念(`grep pinned` 只命中开源页仓库白名单)
|
||||||
|
|
||||||
|
### 2.2 关键假设
|
||||||
|
- 瞬间富文本走与全站文案同一条净化管线即可满足「字体/颜色可换」且不引入 XSS 面(危险标签整棵丢弃、协议白名单 http/https/mailto)
|
||||||
|
- 每条瞬间一个 Artalk page_key(`/moments/#<slug>`)不会与页面级 pv 冲突;Artalk 实例按需挂载(展开才 init)可避免 "one page one Artalk" 的全局实例冲突
|
||||||
|
- 开源页可用本地数据(已有 repo 元数据 + GitHub GraphQL 拉取)自绘 followers/stars/语言占比,无需任何第三方徽章服务
|
||||||
|
|
||||||
|
### 2.3 待确认问题
|
||||||
|
- [x] 瞬间文字是否允许 HTML 类名(字体/颜色)→ 用户原话「字体可换、颜色可换等」,按富文本管线放行 35 项白名单类
|
||||||
|
- [x] 开源页要不要与 README 完全一致 → 用户原话「可以自行进行美化,无需完全一致」,按原生组件重构
|
||||||
|
- [x] About Me 类内容放哪 → 用户原话「有些可以直接移植到关于页面中」,迁到 about.yaml blocks
|
||||||
|
|
||||||
|
### 2.4 可选解释与取舍
|
||||||
|
- 置顶只作用于列表排序与视觉标记,不改 URL / 不改 pubDate 语义(订阅源按时间仍正确)
|
||||||
|
- 计数徽标一次只拉当前页的 pv(POST 计一次)与评论数(GET 读);瞬间列表按可视区懒拉,避免 N 次请求把首屏拖慢
|
||||||
|
- 开源页保留 `intro_from` 开关作为 compat,但默认 off 且文档标注「不推荐」;README 渲染通道不删除(后台仍可切回)
|
||||||
|
|
||||||
|
### 2.5 已证伪路径
|
||||||
|
- 直接渲染 README HTML 并试图用 CSS 修 `align="center"`:内联属性与 sanitize 的交互不可控,且外链图仍会空白破版 → 弃,改原生组件
|
||||||
|
- 每条瞬间都 init 一个 Artalk 实例常驻:内存与请求成本高、且 Artalk 对多实例的支持未验证 → 改为展开时挂载
|
||||||
|
- 用第三方徽章服务显示访客数(komarev 等):外链依赖 + 灰阶体系外颜色 → 弃,用 Artalk pv 自绘
|
||||||
|
|
||||||
|
## 3. 功能范围
|
||||||
|
|
||||||
|
### 3.1 核心功能(MVP)
|
||||||
|
- [ ] T1 文章置顶:posts/postsEn schema `pinned`、四处列表排序(首页双轨/归档/书架)、视觉标记(灰阶 pin)、后台开关
|
||||||
|
- [ ] T2 瞬间富文本:moments `text` 支持 markdown + 白名单排版类(字体/颜色/强调/多段/列表)、净化管线复用、后台改 markdown 编辑器、旧纯文本零回归
|
||||||
|
- [ ] T3 瞬间逐条评论:每条 moment 独立 page_key 的 Artalk 评论区(折叠、展开时挂载)、评论数联动、双语页共用同一 key
|
||||||
|
- [ ] T4 计数徽标:新增 `ViewMeta` 组件(眼睛图标 = 浏览数、气泡图标 = 评论数),接入文章页/瞬间页/关于页/开源页
|
||||||
|
- [ ] T5 开源页重构:原生 GitHub 档案卡(头像/名字/关注者/星标/语言占比,本地数据自绘)替换 README 外链图;`align` 失效与破图问题消失;README 通道降级为可选
|
||||||
|
- [ ] T6 关于页承接:把 README 的 About Me / Find Me 迁为 about.yaml blocks(prose + linklist),开源页只留开源相关
|
||||||
|
- [ ] T7 写作模板示例:中英双语示例文章,演示标题层级/彩色强调/字族/引用/代码/图/表/链接卡片/折叠,并作为置顶功能的活样本;docs 与后台 hint 同步
|
||||||
|
|
||||||
|
### 3.2 扩展功能
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 3.3 不在范围内
|
||||||
|
- 评论回复通知/邮件推送、点赞/投票、阅读进度、站点级总访问量统计(Artalk 无匿名聚合端点)
|
||||||
|
|
||||||
|
## 4. 最小实现路径
|
||||||
|
- 最简单可行方案:置顶先用 boolean + 排序键;瞬间富文本直接把 `text` 交给既有 `renderRich`;评论用现有 Artalk 组件克隆一份带折叠的;计数徽标只做 pv + count 两个数
|
||||||
|
- 暂不引入:置顶权重/多级置顶、瞬间的图片轮播、评论楼中楼定制、开源页的实时 Star 曲线
|
||||||
|
- 不做的抽象:通用「内容社交层」框架(只服务现有页面)
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
- 技术栈:Astro 7.4.4 + zod schema + Artalk v2 HTTP API + GitHub GraphQL(已有 token)
|
||||||
|
- 证据协议:evidence: runs
|
||||||
|
- 本轮允许改动:src/content.config.ts、src/components/{PostList,ArtalkComments}.astro、src/components/pages/{HomePage,MomentsPage,BooksPage}.astro、src/components/geek/{ViewMeta,GithubProfile}.astro(新)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro、src/pages/{about,opensource,en/about,en/opensource}.astro、src/lib/{projects,site-content,heatmap}.ts、content/site/{about,opensource}.yaml、content/posts/*(新增示例文章)、content/posts-en/*、public/admin/{config.yml,typography-classes.js}、docs/*、tests/e2e.mjs
|
||||||
|
- 本轮不应触碰:nginx/ESA、灯箱与图库(GalleryPage/PhotoZoom)、Header/Footer 结构、构建链顺序、tokens.css/global.css 基础尺度
|
||||||
|
- Git integration branch:`spec/2026-11-02_blog-5h-content-social`
|
||||||
|
|
||||||
|
### 5.1 验证策略
|
||||||
|
- 范围级别:package
|
||||||
|
- 变更对象:内容模型(置顶/瞬间富文本)、渲染组件(计数/评论/档案卡)、后台配置、示例内容
|
||||||
|
- 快速检查:`pnpm astro build`(node26)+ `python3 scripts/build-perf/config-check.py .`
|
||||||
|
- 集成检查:`E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(37 项,本包新增置顶顺序/徽标存在/瞬间富文本断言)+ content-lint 0/0/0
|
||||||
|
- 全项目检查:生产实测四条(锁屏外链零依赖、瞬间评论可发可见、徽标数值与 Artalk 后台一致、关于页承接内容渲染)
|
||||||
|
- 升级触发:若动到 BaseLayout/全局样式或 Artalk 主题接管层,升级为全站视觉复核(glm-5.3-flash:max,≤6 图/轮)
|
||||||
|
|
||||||
|
## 6. 成功标准与验证方式
|
||||||
|
- [ ] 置顶文章排在列表首位且带可见标记,取消置顶后恢复时间序 -> verify: task_0001
|
||||||
|
- [ ] 瞬间文字可用字体类与彩色强调,危险标签被净化丢弃,旧纯文本渲染不变 -> verify: task_0002
|
||||||
|
- [ ] 每条瞬间可展开评论区并成功发帖,双语页共用同一评论流 -> verify: task_0003
|
||||||
|
- [ ] 文章/瞬间/关于/开源四页显示「浏览数 + 评论数」徽标且数值与 Artalk 一致 -> verify: task_0004
|
||||||
|
- [ ] 开源页零第三方图片请求,档案卡数据来自本地/GraphQL,居中与破图问题不再出现 -> verify: task_0005
|
||||||
|
- [ ] README 的个人叙述出现在关于页,开源页只保留开源相关内容 -> verify: task_0006
|
||||||
|
- [ ] 示例文章渲染全部演示能力且构建通过,docs/hint 同步 -> verify: task_0007
|
||||||
|
|
||||||
|
## 7. 风险与约束
|
||||||
|
- 灰阶外壳红线不变:徽标图标与数字用 tokens(`--ink-*`/`--line`),不引入第三方彩色徽章
|
||||||
|
- 彩色仅正文强调(≤3 处/页)——示例文章本身要守纪律,成为正面样本而非反例
|
||||||
|
- 瞬间富文本必须复用 `renderRich` 净化链,禁止新增 set:html 直通路径
|
||||||
|
- Artalk 多实例风险:评论区折叠 + 展开挂载,若实测冲突则退回「逐条跳转到锚点页」
|
||||||
|
- 双语链路:所有新文案走 `t()`/`pickBi`,en 轨不得出现中文硬编码
|
||||||
|
- 多车道并行:content.config.ts / public/admin/config.yml 由 orchestrator 先落契约再放行,车道只改各自 boundary 内文件
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Phase 5h:内容表达能力与社交反馈 - 任务拆解
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交
|
||||||
|
- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build`
|
||||||
|
- **契约先行**:`src/content.config.ts` 与 `public/admin/config.yml` 由 orchestrator 先落契约(T1 时一并提交),车道不得再改这两个文件;需要新字段一律回报 orchestrator
|
||||||
|
- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e → 用 18925 端口起快照服务 → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`
|
||||||
|
|
||||||
|
## 阶段一:实现
|
||||||
|
|
||||||
|
- [ ] T1 文章置顶(契约 + 排序 + 标记 + 后台)
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: src/content.config.ts(postsSchema 加 `pinned: z.boolean().optional()`,posts/postsEn 共用)、src/components/PostList.astro(置顶优先排序 + pin 标记)、src/pages/archives/index.astro(排序)、src/components/pages/{HomePage,BooksPage}.astro(排序)、public/admin/config.yml(posts 集合 pinned 开关 + 中文 hint)、src/i18n/ui.ts(置顶标签)
|
||||||
|
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -q "pinned" src/content.config.ts && grep -q "pinned" src/components/PostList.astro && grep -q "pinned" public/admin/config.yml`
|
||||||
|
|
||||||
|
- [ ] T2 瞬间富文本(字体可换 / 颜色可换 / 多形式)
|
||||||
|
- claim: worker-one
|
||||||
|
- boundary: src/components/pages/MomentsPage.astro(`text` 由纯文本 `<p>` 改为走 `renderRich` 净化+排版类管线,保留 inline 语义;空文本老数据零回归)、public/admin/config.yml 中 moments 集合 `text` 字段改 markdown widget + 提示可用排版类(**仅在 orchestrator 提交的字段块内改 widget 类型,不改结构**)、docs 提示留给 T7
|
||||||
|
- verify: `E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS" && grep -qE "renderRich|renderMarkdown" src/components/pages/MomentsPage.astro && python3 -c "import yaml;d=yaml.safe_load(open('public/admin/config.yml'));m=[c for c in d['collections'] if c['name']=='moments'][0];t=[f for f in m['fields'] if f['name']=='text'][0];assert t['widget']=='markdown',t"`
|
||||||
|
|
||||||
|
- [ ] T3 瞬间逐条评论(Artalk 独立 page_key + 折叠懒挂载)
|
||||||
|
- claim: worker-one
|
||||||
|
- boundary: 新增 src/components/MomentComments.astro(折叠 disclosure,展开时首次挂载 Artalk;page_key 约定 `/moments/#<slug>`,en 页同一 key 不分裂;评论数徽标复用 T4 的 ViewMeta 计数接口)、src/components/pages/MomentsPage.astro(每条卡片挂评论区)
|
||||||
|
- verify: `test -f src/components/MomentComments.astro && grep -q "MomentComments" src/components/pages/MomentsPage.astro && grep -q "moments/#" src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||||
|
|
||||||
|
- [ ] T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
||||||
|
- claim: op-artalk
|
||||||
|
- boundary: src/components/geek/ViewMeta.astro(**契约组件,由 orchestrator 在 T1 提交时一并落盘**,车道只消费不改语义)、src/components/ArtalkComments.astro(pv 小字行改为 ViewMeta,评论数取 `comments?limit=1` 的 count)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro(挂 ViewMeta)
|
||||||
|
- verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||||
|
|
||||||
|
- [ ] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
|
||||||
|
- claim: op-gitea
|
||||||
|
- boundary: 新增 src/components/geek/GithubProfile.astro(头像/名字/简介/关注者/总星标/语言占比,数据来自 projects.ts 与 GraphQL;零第三方图片与徽章)、src/lib/projects.ts(补统计聚合:followers/stars/language 汇总,失败降级不炸构建)、src/pages/opensource.astro、src/pages/en/opensource.astro(用 GithubProfile 替换 readme 渲染段;README 通道保留开关但默认 off 且标注不推荐)、content/site/opensource.yaml(注释说明 intro_from 现状)
|
||||||
|
- verify: `grep -q "GithubProfile" src/pages/opensource.astro && python3 -c "import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad"`
|
||||||
|
|
||||||
|
- [ ] T6 关于页承接 README 的个人叙述
|
||||||
|
- claim: op-gitea
|
||||||
|
- boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动)
|
||||||
|
- verify: `python3 -c "import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||||
|
|
||||||
|
- [ ] T7 写作模板示例文章 + 文档与 hint 收尾
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: content/posts/writing-template.md、content/posts-en/writing-template.md(en 手写 locked:true 直译,不交给 TMT 重译)、docs/写作指南.md(模板用法 + 排版类清单去重指引)、docs/管理台.md(瞬间富文本/逐条评论/置顶/徽标说明)、public/admin/config.yml(moments text hint、posts pinned 已含)
|
||||||
|
- verify: `test -f content/posts/writing-template.md && test -f content/posts-en/writing-template.md && grep -q "pinned: true" content/posts/writing-template.md && grep -qE "font-kai|c-blue" content/posts/writing-template.md && grep -q "写作模板" docs/写作指南.md`
|
||||||
@@ -38,6 +38,7 @@ collections:
|
|||||||
- { label: 日期, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
|
- { label: 日期, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
|
||||||
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
|
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
|
||||||
- { label: 标签, name: tags, widget: list, required: false }
|
- { label: 标签, name: tags, widget: list, required: false }
|
||||||
|
- { label: 置顶, name: pinned, widget: boolean, required: false, default: false, hint: '置顶文章排在首页/归档/书架最前;只影响顺序,不改日期、URL 与 RSS 时间语义' }
|
||||||
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
|
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
|
||||||
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
|
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
|
||||||
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
|
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
||||||
|
{post.data.pinned && (
|
||||||
|
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
|
||||||
|
)}
|
||||||
{key !== 'reading' &&
|
{key !== 'reading' &&
|
||||||
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||||
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
||||||
@@ -130,6 +133,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
||||||
|
{post.data.pinned && (
|
||||||
|
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
|
||||||
|
)}
|
||||||
{post.data.category !== 'reading' &&
|
{post.data.category !== 'reading' &&
|
||||||
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||||
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
||||||
@@ -174,6 +180,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
||||||
|
{post.data.pinned && (
|
||||||
|
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
|
||||||
|
)}
|
||||||
{cat !== 'reading' &&
|
{cat !== 'reading' &&
|
||||||
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||||
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
||||||
@@ -261,6 +270,18 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden; color: var(--accent); }
|
overflow: hidden; color: var(--accent); }
|
||||||
|
/* 置顶标记:灰阶 hairline 小徽标,不抢标题注意力 */
|
||||||
|
.pin {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-4);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
.tag {
|
.tag {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* 浏览 / 评论计数徽标(Phase 5h T4):眼睛图标 = 浏览数,气泡图标 = 评论数。
|
||||||
|
*
|
||||||
|
* 为什么这么设计:
|
||||||
|
* - 语汇对齐开源页从 GitHub README 移植过来的「👀 Visitors」徽章,但改成本地
|
||||||
|
* SVG + tokens 着色,不引入任何第三方图片(komarev/shields 在慢网下空白破版)。
|
||||||
|
* - 数据只用 Artalk 匿名可读端点(2026-11-02 生产实测):
|
||||||
|
* GET /artalk/api/v2/comments?site_name=…&page_key=…&limit=1
|
||||||
|
* → { count, roots_count, page: { pv } } (一次拿到评论数与浏览量)
|
||||||
|
* POST /artalk/api/v2/pages/pv { page_key, site_name } → { pv }
|
||||||
|
* - `mode="readonly"` 只读(瞬间列表每条都要显示,逐条计数会污染数据);
|
||||||
|
* `mode="page"` 计入一次浏览(文章/关于/开源等整页场景)。
|
||||||
|
*
|
||||||
|
* SSR 渲染占位「–」:无 JS / 接口失败时退化为一个短横,不显示 0 误导读者。
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
/** 评论流键(与 ArtalkComments 的 pageKey 同语义,双语页共用) */
|
||||||
|
pageKey: string;
|
||||||
|
locale?: 'zh' | 'en';
|
||||||
|
/** page = 计入一次浏览;readonly = 只读,不改动计数 */
|
||||||
|
mode?: 'page' | 'readonly';
|
||||||
|
/** 是否显示评论数(瞬间逐条场景也显示) */
|
||||||
|
showComments?: boolean;
|
||||||
|
/** 是否显示浏览数 */
|
||||||
|
showPv?: boolean;
|
||||||
|
/** 视觉变体:inline = 行内小字(默认);chip = 带底框的徽标条 */
|
||||||
|
variant?: 'inline' | 'chip';
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
pageKey,
|
||||||
|
locale = 'zh',
|
||||||
|
mode = 'page',
|
||||||
|
showComments = true,
|
||||||
|
showPv = true,
|
||||||
|
variant = 'inline',
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const L = {
|
||||||
|
pv: locale === 'zh' ? '次浏览' : 'views',
|
||||||
|
cm: locale === 'zh' ? '条评论' : 'comments',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<p
|
||||||
|
class:list={['viewmeta', variant === 'chip' && 'chip']}
|
||||||
|
data-viewmeta
|
||||||
|
data-page-key={pageKey}
|
||||||
|
data-mode={mode}
|
||||||
|
data-i18n-pv={L.pv}
|
||||||
|
data-i18n-cm={L.cm}
|
||||||
|
>
|
||||||
|
{showPv && (
|
||||||
|
<span class="vm-item" title={L.pv}>
|
||||||
|
<svg class="vm-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1.3 8S3.9 3.9 8 3.9 14.7 8 14.7 8 12.1 12.1 8 12.1 1.3 8 1.3 8Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.25"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"></path>
|
||||||
|
<circle cx="8" cy="8" r="1.9" fill="none" stroke="currentColor" stroke-width="1.25"></circle>
|
||||||
|
</svg>
|
||||||
|
<span data-vm-pv aria-label={L.pv}>–</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showComments && (
|
||||||
|
<span class="vm-item" title={L.cm}>
|
||||||
|
<svg class="vm-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.4 3.2h11.2v7.3H7.1L4 13.2v-2.7H2.4z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.25"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
<span data-vm-cm aria-label={L.cm}>–</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 同一页可能有多个实例(瞬间时间线逐条),脚本只跑一份,统一批量处理。
|
||||||
|
const SITE = 'wait4x.online';
|
||||||
|
const API = '/artalk/api/v2';
|
||||||
|
|
||||||
|
interface Meta {
|
||||||
|
count?: number;
|
||||||
|
page?: { pv?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fill = (root: HTMLElement, meta: Meta) => {
|
||||||
|
const pv = root.querySelector<HTMLElement>('[data-vm-pv]');
|
||||||
|
const cm = root.querySelector<HTMLElement>('[data-vm-cm]');
|
||||||
|
const locale = document.documentElement.lang === 'en' ? 'en' : 'zh';
|
||||||
|
const unitPv = locale === 'zh' ? '次浏览' : 'views';
|
||||||
|
const unitCm = locale === 'zh' ? '条评论' : 'comments';
|
||||||
|
if (pv) {
|
||||||
|
const n = meta.page?.pv;
|
||||||
|
pv.textContent = typeof n === 'number' ? `${n} ${unitPv}` : '–';
|
||||||
|
}
|
||||||
|
if (cm) {
|
||||||
|
const n = meta.count;
|
||||||
|
cm.textContent = typeof n === 'number' ? `${n} ${unitCm}` : '–';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOne = async (root: HTMLElement) => {
|
||||||
|
const key = root.dataset.pageKey;
|
||||||
|
if (!key) return;
|
||||||
|
const mode = root.dataset.mode ?? 'page';
|
||||||
|
try {
|
||||||
|
// page 模式先计一次浏览(POST 绕 ESA 缓存),再读评论数;
|
||||||
|
// readonly 模式只读:一次 GET 同时拿到 pv 与 count,零写入。
|
||||||
|
if (mode === 'page') {
|
||||||
|
const bumped = await fetch(`${API}/pages/pv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page_key: key, site_name: SITE }),
|
||||||
|
});
|
||||||
|
const data = (await bumped.json()) as Meta;
|
||||||
|
const res = await fetch(
|
||||||
|
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
|
||||||
|
);
|
||||||
|
const meta = (await res.json()) as Meta;
|
||||||
|
fill(root, { count: meta.count, page: { pv: data.pv ?? meta.page?.pv } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
|
||||||
|
);
|
||||||
|
fill(root, (await res.json()) as Meta);
|
||||||
|
} catch {
|
||||||
|
/* 接口不可用:保留占位「–」,不显示 0 误导读者 */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
for (const root of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
||||||
|
void loadOne(root);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', run);
|
||||||
|
run();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viewmeta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.viewmeta.chip {
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.vm-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.vm-icon {
|
||||||
|
flex: none;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,13 +5,15 @@ import { getCollection } from 'astro:content';
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
import type { Locale } from '../../lib/locale';
|
import type { Locale } from '../../lib/locale';
|
||||||
|
import { sortPosts } from '../../lib/post-order';
|
||||||
import { Image } from 'astro:assets';
|
import { Image } from 'astro:assets';
|
||||||
|
|
||||||
interface Props { locale?: Locale }
|
interface Props { locale?: Locale }
|
||||||
const { locale = 'zh' } = Astro.props;
|
const { locale = 'zh' } = Astro.props;
|
||||||
|
|
||||||
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')))
|
const books = sortPosts(
|
||||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
|
||||||
|
);
|
||||||
|
|
||||||
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
|
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
|
||||||
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
|
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import ProjectWall from '../geek/ProjectWall.astro';
|
|||||||
import Heatmap from '../geek/Heatmap.astro';
|
import Heatmap from '../geek/Heatmap.astro';
|
||||||
import SocialLinks from '../SocialLinks.astro';
|
import SocialLinks from '../SocialLinks.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
|
import { sortPosts } from '../../lib/post-order';
|
||||||
import type { Locale } from '../../lib/locale';
|
import type { Locale } from '../../lib/locale';
|
||||||
import {
|
import {
|
||||||
homeConfig,
|
homeConfig,
|
||||||
@@ -30,9 +31,10 @@ const cfg = await homeConfig(locale);
|
|||||||
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||||||
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
|
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
|
||||||
|
|
||||||
const posts = (
|
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
|
||||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
|
const posts = sortPosts(
|
||||||
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
|
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||||
|
);
|
||||||
|
|
||||||
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
|
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
|
||||||
const enTitle = locale === 'en'
|
const enTitle = locale === 'en'
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ const postsSchema = z.object({
|
|||||||
video: z.string().optional(),
|
video: z.string().optional(),
|
||||||
/** 视频封面(可选):同 public/media 下的图片 URL */
|
/** 视频封面(可选):同 public/media 下的图片 URL */
|
||||||
poster: z.string().optional(),
|
poster: z.string().optional(),
|
||||||
|
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
draft: z.boolean().optional(),
|
draft: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ const dict = {
|
|||||||
|
|
||||||
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
|
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
|
||||||
pv_viewed: { zh: '人看过', en: 'views' },
|
pv_viewed: { zh: '人看过', en: 'views' },
|
||||||
|
pinned_label: { zh: '置顶', en: 'Pinned' },
|
||||||
|
comment_count: { zh: '条评论', en: 'comments' },
|
||||||
|
moment_comments: { zh: '评论', en: 'Comments' },
|
||||||
lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' },
|
lb_hint: { zh: '滚轮缩放 · 双击放大 · Esc 关闭', en: 'Wheel to zoom · Double-click · Esc' },
|
||||||
lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' },
|
lb_hint_touch: { zh: '双指缩放 · 双击放大', en: 'Pinch to zoom · Double-tap' },
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 文章列表排序契约(置顶功能,Phase 5h T1)
|
||||||
|
*
|
||||||
|
* 为什么集中在这里:首页双轨 / 归档折叠 / 书架 / en 归档四处列表各自写
|
||||||
|
* `sort((a,b)=>b.pubDate-a.pubDate)`,加置顶必须四处同步,否则同一篇文章
|
||||||
|
* 在不同页面位置不一致。排序键固定为「置顶优先 → 时间倒序」,时间相等时
|
||||||
|
* 用 id 兜底保证构建确定(同一份内容两次构建产出一致,便于快照比对)。
|
||||||
|
*
|
||||||
|
* 置顶语义:`pinned: true` 只影响列表展示顺序,不改 pubDate、不改 URL、
|
||||||
|
* 不影响 RSS/订阅源的时间语义。
|
||||||
|
*/
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
|
type SortablePost = { id: string; data: { pubDate: Date; pinned?: boolean } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 置顶优先、其次按发布日期倒序。
|
||||||
|
*
|
||||||
|
* @param posts 任意文章条目数组(不修改入参,返回新数组)
|
||||||
|
* @returns 新数组;`pinned` 为真者在前,其内部与其余条目均按 pubDate 倒序,
|
||||||
|
* 同日按 id 升序保证确定性
|
||||||
|
*/
|
||||||
|
export function sortPosts<T extends SortablePost>(posts: T[]): T[] {
|
||||||
|
return [...posts].sort((a, b) => {
|
||||||
|
const pin = Number(b.data.pinned === true) - Number(a.data.pinned === true);
|
||||||
|
if (pin !== 0) return pin;
|
||||||
|
const dt = b.data.pubDate.getTime() - a.data.pubDate.getTime();
|
||||||
|
if (dt !== 0) return dt;
|
||||||
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import PostList from '../../components/PostList.astro';
|
import PostList from '../../components/PostList.astro';
|
||||||
|
import { sortPosts } from '../../lib/post-order';
|
||||||
|
|
||||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
|
||||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
const posts = sortPosts(
|
||||||
|
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||||
);
|
);
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
import PostList from '../../../components/PostList.astro';
|
import PostList from '../../../components/PostList.astro';
|
||||||
|
import { sortPosts } from '../../../lib/post-order';
|
||||||
|
|
||||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
const posts = sortPosts(
|
||||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||||
);
|
);
|
||||||
const enTitle = Object.fromEntries(
|
const enTitle = Object.fromEntries(
|
||||||
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
|
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
|
||||||
|
|||||||
Reference in new issue
Block a user