Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e792a70cc | ||
|
|
cef648db9a | ||
|
|
40346c3f21 | ||
|
|
ce587236e2 | ||
|
|
90574edce7 | ||
|
|
3bd8369eda | ||
|
|
b789d30a5d |
No files matched your search
@@ -0,0 +1,92 @@
|
||||
# Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水 - 完成总结
|
||||
|
||||
## 交付结论
|
||||
- 结果:完成
|
||||
- 完成时间:2026-10-25 13:46
|
||||
|
||||
## 假设回顾
|
||||
### 已验证假设
|
||||
- Sveltia 内建图片优化保持关闭(开了会抹 EXIF 断构建期提取管线)
|
||||
- 书评 description 由正文首段精炼可接受(用户后续可自行策展润色)
|
||||
|
||||
### 仍未完全验证的假设
|
||||
- 无
|
||||
|
||||
## 交付范围
|
||||
### 已交付
|
||||
- T1 /terminal 终端彩蛋页
|
||||
- T2 /books 书评画廊
|
||||
- T3 Sveltia 管理台试水
|
||||
- T5 /opensource 开源独立页(用户追加需求)
|
||||
- T4 验证收官
|
||||
|
||||
### 未交付
|
||||
- 无
|
||||
|
||||
### 偏差说明
|
||||
- 无
|
||||
|
||||
## 简化决策
|
||||
### 保持简单的关键选择
|
||||
- 最简单可行方案:三车道并行按预研方案实施;Sveltia 试水不改现有任何流(只加不改)
|
||||
- 暂不引入:终端插件化命令系统、书封真实图片扫描
|
||||
- 不做的抽象/配置化:多管理后端适配
|
||||
|
||||
### 本轮明确不做的内容
|
||||
- moments PWA 上传端点、/stack·/colophon 页(后续池)
|
||||
|
||||
## 变更边界
|
||||
### 本轮主要改动模块
|
||||
- 见 tasks.md 的 boundary 记录
|
||||
|
||||
### 明确未触碰的区域
|
||||
- moments PWA 上传端点、/stack·/colophon 页(后续池)
|
||||
|
||||
## 验证证据
|
||||
### 备注
|
||||
- 机器证据:runs.jsonl(见验证范围的机器验证率)
|
||||
|
||||
## 验证范围
|
||||
- 级别:package
|
||||
- 机器验证率:5/5
|
||||
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
|
||||
|
||||
## 哲学生效证据
|
||||
### 行为成效回填
|
||||
- Development Record 行为门禁通过
|
||||
|
||||
### 一致性门禁回顾
|
||||
- 验收面(checklist 或成功标准)已通过
|
||||
|
||||
## Git Records
|
||||
- Date Time:2026-10-25 13:46
|
||||
- Scope:Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水
|
||||
- Feature:T1 /terminal 终端彩蛋页;T2 /books 书评画廊;T3 Sveltia 管理台试水;T5 /opensource 开源独立页(用户追加需求);T4 验证收官
|
||||
- Action:Commit / Push
|
||||
- Effect:Development Record 行为门禁通过
|
||||
- Commit:pending local commit
|
||||
- 提交区间:f1ee07838d577125f8f1677e6b662a3bb320ce32..1b610f0bbf430aae3f001cf0c8138ab775fc0662
|
||||
- Push:not-run (push stage follows commit)
|
||||
|
||||
## AGENTS.md 增量提议
|
||||
```diff
|
||||
Astro 7 内容层双坑(EXIF 全空调试半日定位):①loader 包装层在 baseLoader.load() 之后 ctx.store.set 的字段会被解析阶段静默丢弃——必须拦截 baseLoader 的写入时机内联增强(代理 store.set,enrich Promise 收集后 await);②持久化数据仓(node_modules/.astro)增量复用旧条目——改 loader 逻辑后必须清数据仓或换 loader name,否则旧值复利胜出。团队协作:车道临时服务端口要与主验证端口分离(18742 Sveltia 冲突教训);多车道并发时 add -A 会卷入他车道文件(提交信息如实备案或收窄 add)。Sveltia×gitea 机制:整文件 JSON=sidecar 同构、删条目连带独占 jpg(实测推翻文档)、omit_empty_optional_fields 会丢必需空字段。裸 clone 构建改造是 Sveltia 自动上线的硬前置(下一包首任务)。
|
||||
```
|
||||
|
||||
## 被拒绝的扩展提议
|
||||
- 无
|
||||
|
||||
## 门禁证据
|
||||
- check_spec_package.py exit 0
|
||||
|
||||
## 遗留事项
|
||||
- 无;问题处置见下方结构化区块
|
||||
|
||||
## 问题处置
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": [],
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"terminal\" dist/terminal/index.html", "exit": 0, "duration_s": 5.636, "stdout_tail_sha256": "8523a98a4dd54e5d8b6298e9f9e56fc6eb56afd1d70821ce5c1a0a8d871673a7", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:42:41+00:00"}
|
||||
{"task": "task_0002", "cmd": "pnpm astro build && test -f dist/books/index.html -a -f dist/en/books/index.html", "exit": 0, "duration_s": 4.88, "stdout_tail_sha256": "b05fcfb369a91ea39a036fc67dd5a29bcefe1db26e624bfd9b7a678b40f57b87", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:42:46+00:00"}
|
||||
{"task": "task_0003", "cmd": "test -f public/admin/config.yml && docker exec gitea grep -q \"cors\" /data/gitea/conf/app.ini", "exit": 0, "duration_s": 0.094, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:42:46+00:00"}
|
||||
{"task": "task_0004", "cmd": "node tests/e2e.mjs 2>&1 | grep -q \"30/30 PASS\"", "exit": 0, "duration_s": 14.517, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:43:01+00:00"}
|
||||
{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"terminal\" dist/terminal/index.html", "exit": 0, "duration_s": 10.825, "stdout_tail_sha256": "39e841a84204b8c060604577adb41f13605ef755f90a4dac28ae4ed7f6b07dee", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:10+00:00"}
|
||||
{"task": "task_0002", "cmd": "pnpm astro build && test -f dist/books/index.html -a -f dist/en/books/index.html", "exit": 0, "duration_s": 4.703, "stdout_tail_sha256": "167f39f390b7753e6f76bab112f849fca4f16d8fa06e319b0e5363c6fb7ba8eb", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:15+00:00"}
|
||||
{"task": "task_0003", "cmd": "test -f public/admin/config.yml && docker exec gitea grep -q \"cors\" /data/gitea/conf/app.ini", "exit": 0, "duration_s": 0.088, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:15+00:00"}
|
||||
{"task": "task_0004", "cmd": "pnpm astro build && test -f dist/opensource/index.html -a -f dist/en/opensource/index.html", "exit": 0, "duration_s": 5.218, "stdout_tail_sha256": "0975341de4c977c9261a1b80c4619480804a65775c330f6bb6a389c73d158868", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:20+00:00"}
|
||||
{"task": "task_0005", "cmd": "node tests/e2e.mjs 2>&1 | grep -q \"30/30 PASS\"", "exit": 0, "duration_s": 14.389, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "9325637dacb5cbd2a4a86329e669a85bbf5428735a2bb72c5ad7a82e00ea1624", "at": "2026-10-25T05:45:34+00:00"}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水 - 项目范围
|
||||
|
||||
<!-- spec-format: 2 -->
|
||||
|
||||
## 1. 问题定义
|
||||
- **项目目标**:三个预研方案落地——终端彩蛋(极客人格记忆点)、书评索引画廊(阅读轨策展面)、Sveltia 管理台(照片上传/编辑体验升级试水)
|
||||
- **目标用户**:站长(写作/管理体验)与读者(浏览乐趣)
|
||||
- **核心价值**:二期池前三项交付;Sveltia 试水失败可全身而退
|
||||
|
||||
## 2. 假设与待确认
|
||||
|
||||
### 2.1 已确认事实
|
||||
- 三方案预研完成(op-artalk A+B / op-gitea Sveltia,含坑清单与工作量)
|
||||
- Sveltia 机制全对上:整文件 JSON=sidecar 同构、media_folder '' 裸文件名、token 登录、保存即 commit→webhook 自动构建
|
||||
- gitea CORS 未配是唯一硬前置
|
||||
|
||||
### 2.2 关键假设
|
||||
- Sveltia 内建图片优化保持关闭(开了会抹 EXIF 断构建期提取管线)
|
||||
- 书评 description 由正文首段精炼可接受(用户后续可自行策展润色)
|
||||
|
||||
### 2.3 待确认问题
|
||||
- [x] Sveltia 试水体验是否合手 → 结论:值得留(双冒烟过,编辑器 aria/草稿保存扎实);生产自动化待裸 clone 改造(下一包首任务)
|
||||
|
||||
### 2.4 可选解释与取舍
|
||||
- /books 不进主导航(书评列表行内入口)> 导航加第五项 -> 防膨胀
|
||||
- 色脊排版卡 > 满色书封卡(mood 深浅不一跨亮暗风险)-> 色脊
|
||||
|
||||
### 2.5 已证伪路径
|
||||
- 无
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 核心功能(MVP)
|
||||
- [x] T1 /terminal 彩蛋页
|
||||
- [x] T2 /books 书评画廊(zh/en)
|
||||
- [x] T3 Sveltia 管理台试水(含 gitea CORS)
|
||||
- [x] T5 /opensource 开源独立页(2026-10-25 用户追加:与文章/图库/关于平权)
|
||||
- [x] T4 验证收官
|
||||
|
||||
### 3.2 扩展功能
|
||||
- /books 进导航(若用户要求)
|
||||
|
||||
### 3.3 不在范围内
|
||||
- moments PWA 上传端点、/stack·/colophon 页(后续池)
|
||||
|
||||
## 4. 最小实现路径
|
||||
- 最简单可行方案:三车道并行按预研方案实施;Sveltia 试水不改现有任何流(只加不改)
|
||||
- 暂不引入:终端插件化命令系统、书封真实图片扫描
|
||||
- 不做的抽象/配置化:多管理后端适配
|
||||
|
||||
## 5. 技术决策
|
||||
- 技术栈:预研方案原样(vanilla TS 终端 / 色脊卡静态页 / Sveltia 官方集成)
|
||||
- 证据协议:evidence: runs
|
||||
- 本轮允许改动:src/pages/、src/content.config.ts、content/posts/(五篇补字段)、public/admin/、infra/gitea/README、gitea 运行时 [cors]
|
||||
- 本轮不应触碰:content/photos 既有 sidecar 结构、发布链、生产 nginx
|
||||
- Git integration branch:`spec/2026-10-25_add-terminal-books-admin`
|
||||
|
||||
### 5.1 验证策略
|
||||
- 范围级别:package
|
||||
- 变更对象:两个新页面、posts schema 扩展、admin 静态目录、gitea 运行时
|
||||
- 快速检查:`pnpm astro build`
|
||||
- 集成检查:terminal/books 产物断言 + Sveltia 双冒烟走通 commit→webhook→build
|
||||
- 全项目检查:E2E 30 项 + 新页视觉复核;仅在 gitea 运行时或发布链变更时升级范围
|
||||
- 升级触发:只有 Sveltia 冒烟引发 gitea 行为异常时,才升级验证范围并记录理由
|
||||
|
||||
## 6. 成功标准与验证方式
|
||||
- [x] /terminal 可交互(help/ls/theme/sudo 彩蛋实跑) -> verify: task_0004
|
||||
- [x] /books zh/en 五书呈现(色脊+mood) -> verify: task_0002
|
||||
- [x] Sveltia 冒烟双通过(改+建全链) -> verify: task_0003
|
||||
|
||||
## 7. 风险与约束
|
||||
- gitea restart 窗口(秒级,公告无需);Sveltia 保存直连 main——已有 webhook 管线为安全网
|
||||
@@ -0,0 +1,36 @@
|
||||
# Phase 5b:/terminal 彩蛋 + /books 书评画廊 + Sveltia 管理台试水 - 任务拆解
|
||||
|
||||
## 使用规则
|
||||
- boundary/verify 齐备;verify 只读断言;三车道并行(mesh_reserve 各自路径)
|
||||
|
||||
## 阶段一:实现
|
||||
|
||||
- [x] T1 /terminal 终端彩蛋页
|
||||
- claim: op-artalk(预研方案 A 已在案:命令注册表/真实 input/历史翻阅/Tab 补全/sudo 彩蛋/XSS=textContent 纪律/isComposing 守卫)
|
||||
- boundary: 新建 src/pages/terminal.astro 单文件组件(--code-bg 恒深色例外区);不进主导航(? 帮助浮层加一行暗入口提示);零外部依赖
|
||||
- verify: `pnpm astro build && grep -q "terminal" dist/terminal/index.html`
|
||||
|
||||
- [x] T2 /books 书评画廊
|
||||
- claim: orchestrator(预研方案 B 在案:色脊排版卡/年份分组/规整网格)
|
||||
- boundary: posts schema 加可选 description/book_author 两字段;新建 src/pages/books.astro(+en/books);五篇书评补字段(description 用正文首段精炼,book_author 按书实填);导航"文章"旁不加新项——/books 从书评列表行入口链接(防导航膨胀)
|
||||
- verify: `pnpm astro build && test -f dist/books/index.html -a -f dist/en/books/index.html`
|
||||
|
||||
- [x] T3 Sveltia 管理台试水
|
||||
- claim: op-gitea(预研方案在案:CORS 前置/config.yml 草案/双冒烟/五坑清单)
|
||||
- boundary: gitea app.ini 加 [cors] 段并重启(运行时操作,README 台账补记);public/admin/{index.html,config.yml,sveltia 本地 js};双冒烟(改旧 sidecar 补 alt / 新建条目传图走全链);不加站内链接(试水期)
|
||||
- verify: `test -f public/admin/config.yml && docker exec gitea grep -q "cors" /data/gitea/conf/app.ini`
|
||||
|
||||
- [x] T5 /opensource 开源独立页(用户追加需求)
|
||||
- claim: orchestrator
|
||||
- boundary: ProjectWall 加 limit prop(默认 6 不变首页);opensource.astro + en/opensource.astro(全量仓+热力图+开源叙事);Header 导航第四项(i18n nav_opensource);BooksPage 回退契约与自托管徽标随后续提交补强
|
||||
- verify: `pnpm astro build && test -f dist/opensource/index.html -a -f dist/en/opensource/index.html`
|
||||
|
||||
- [x] T4 验证收官
|
||||
- depends-on: task_0001, task_0002, task_0003
|
||||
- claim: orchestrator
|
||||
- boundary: E2E 复跑 + terminal/books 截图视觉复核 + Sveltia 冒烟结论 + 归档推送
|
||||
- verify: `node tests/e2e.mjs 2>&1 | grep -q "30/30 PASS"`
|
||||
|
||||
---
|
||||
|
||||
**当前进度**:由脚本计算,无需手动维护
|
||||
@@ -4,6 +4,8 @@ pubDate: 2026-03-31
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#b45309'
|
||||
bookAuthor: '萨缪尔·贝克特'
|
||||
description: '无望之待里,陪伴是囚笼唯一的呼吸缝隙。'
|
||||
---
|
||||
|
||||
### 谁是戈多?为何等待?
|
||||
|
||||
@@ -4,6 +4,8 @@ pubDate: 2026-03-25
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#0f766e'
|
||||
bookAuthor: '阿利斯泰尔·麦克劳德'
|
||||
description: '秋是萧瑟之感:父亲、老马与少年,在失落后各自直面现实。'
|
||||
---
|
||||
|
||||
### 秋-斯科特之秋、父亲之秋、少年之秋(冬的预演)
|
||||
|
||||
@@ -4,6 +4,8 @@ pubDate: 2026-03-09
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#7c3aed'
|
||||
bookAuthor: '威廉·萨默塞特·毛姆'
|
||||
description: '面纱之下:理想的殉道者与真实的幸存者。'
|
||||
---
|
||||
|
||||
****面纱之下:理想的殉道者和真实的幸存者****
|
||||
|
||||
@@ -4,6 +4,8 @@ pubDate: 2026-02-09
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#166534'
|
||||
bookAuthor: '刘楚昕'
|
||||
description: '迷失于黑夜时,不妨抬头看看星空。'
|
||||
---
|
||||
|
||||
### 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
|
||||
|
||||
@@ -4,6 +4,8 @@ pubDate: 2026-02-26
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#9f1239'
|
||||
bookAuthor: '伊恩·麦克尤恩'
|
||||
description: '因一个人、一张图、一句台词,看了一本书。'
|
||||
---
|
||||
|
||||
### 你要回来,我会等你。
|
||||
|
||||
@@ -50,6 +50,8 @@ sqlite 直接热拷贝会撕裂,备份前 `docker compose down` 或用 `sqlite
|
||||
|
||||
## 已知坑
|
||||
|
||||
- **[cors] 段已启用(2026-10-25,Sveltia CMS 试水)**:`ALLOW_DOMAIN = https://wait4x.online`(试水期的本地源已于收尾移除);`HEADERS` 必须含 `Authorization`(token 认证跨源必需,默认仅 Content-Type,User-Agent)。依据:docs.gitea.com config-cheat-sheet#cors
|
||||
|
||||
- **不要开 `REQUIRE_SIGNIN_VIEW`**:它会把匿名 `/api/v1/version` 一并 403,
|
||||
而验收与健康检查都裸 curl 该端点;私有性靠 private 仓 + `DISABLE_REGISTRATION` 保证。
|
||||
- nginx 的 `client_max_body_size 100M`(git push 大 pack)与 app.ini
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# yaml-language-server: $schema=https://unpkg.com/@sveltia/cms/schema/sveltia-cms.json
|
||||
# Sveltia CMS 配置(T3 试水):仅 photos 集合,见 docs 预研报告
|
||||
# 纪律:不开内建图片优化(transformations 会把原图转 webp 抹掉 EXIF,构建期提取报废)
|
||||
backend:
|
||||
name: gitea
|
||||
repo: wait4x/blog
|
||||
branch: main
|
||||
base_url: https://git.wait4x.online
|
||||
api_root: https://git.wait4x.online/api/v1
|
||||
|
||||
media_folder: content/photos
|
||||
site_url: https://wait4x.online
|
||||
|
||||
output:
|
||||
# Astro zod optional 兼容:空可选字段不写文件(官方 Astro 指南建议)
|
||||
omit_empty_optional_fields: true
|
||||
|
||||
collections:
|
||||
- name: photos
|
||||
label: 图库照片
|
||||
folder: content/photos
|
||||
# 整文件 JSON(非 frontmatter)= 现有 sidecar 同构;不设 format 条目不列出
|
||||
format: json
|
||||
create: true
|
||||
# 入口相对:jpg 与 sidecar 同目录;image 引用写裸文件名(loader new URL 等价解析)
|
||||
media_folder: ''
|
||||
public_folder: ''
|
||||
summary: '{{fields.group}} · {{fields.title}}'
|
||||
fields:
|
||||
# title 必填:Astro schema z.string() 无 .optional(),omit 纪律下必需且有值才会落键(空 title 会让 main 不可构建)
|
||||
- { label: 标题, name: title, widget: string, required: true }
|
||||
- { label: 图片, name: image, widget: image }
|
||||
- { label: 分组, name: group, widget: select, options: [street, landscape] }
|
||||
- { label: 替代文本, name: alt, widget: string, required: false }
|
||||
- label: EXIF 覆盖(一般留空,构建期自动从图提取)
|
||||
name: exif
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: 机身, name: make, widget: string, required: false }
|
||||
- { label: 镜头, name: lens, widget: string, required: false }
|
||||
- { label: 光圈值, name: fNumber, widget: number, required: false }
|
||||
- { label: 曝光秒, name: exposureTime, widget: number, required: false }
|
||||
- { label: ISO, name: iso, widget: number, required: false }
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Sveltia CMS</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- pinned 本地 bundle(0.252.0):官方模板仅此一个 script,无 CSS link、无 type=module -->
|
||||
<script src="./sveltia-cms.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ const nav = [
|
||||
{ href: '/archives/', label: t('nav_posts', curLocale) },
|
||||
{ href: '/gallery/', label: t('nav_gallery', curLocale) },
|
||||
{ href: '/about/', label: t('nav_about', curLocale) },
|
||||
{ href: '/opensource/', label: t('nav_opensource', curLocale) },
|
||||
];
|
||||
const switchHref = localePath(path, curLocale === 'zh' ? 'en' : 'zh');
|
||||
const switchLabel = curLocale === 'zh' ? 'EN' : '中';
|
||||
|
||||
@@ -41,7 +41,12 @@ const mmdd = (d: Date) =>
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div class="track-block">
|
||||
<h3 class="track">{TRACK_LABEL[cat]}</h3>
|
||||
<h3 class="track">
|
||||
{TRACK_LABEL[cat]}
|
||||
{cat === 'reading' && (
|
||||
<a class="shelf-link" href={locale === 'en' ? '/en/books/' : '/books/'}>{locale === 'en' ? 'Books →' : '书架 →'}</a>
|
||||
)}
|
||||
</h3>
|
||||
<ul>
|
||||
{rows.map((post) => (
|
||||
<li>
|
||||
@@ -79,6 +84,7 @@ const mmdd = (d: Date) =>
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.shelf-link { float: right; font-size: 12.5px; color: var(--accent); font-weight: 400; }
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -21,6 +21,7 @@ const routes: Record<string, string> = {
|
||||
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>文章</td></tr>
|
||||
<tr><td><kbd>g</kbd> <kbd>g</kbd></td><td>图库</td></tr>
|
||||
<tr><td><kbd>g</kbd> <kbd>m</kbd></td><td>瞬间</td></tr>
|
||||
<tr><td><kbd>/terminal</kbd></td><td>终端彩蛋</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// 双源数据见 src/lib/projects.ts;语言色点用 linguist 常用色映射
|
||||
import { loadProjects, type RepoCard } from '../../lib/projects';
|
||||
|
||||
interface Props {
|
||||
/** 展示上限:首页精选 6;/opensource 全量传大数 */
|
||||
limit?: number;
|
||||
}
|
||||
const { limit = 6 } = Astro.props;
|
||||
const LANG_COLORS: Record<string, string> = {
|
||||
Astro: '#ff5a03', TypeScript: '#3178c6', JavaScript: '#f1e05a',
|
||||
Python: '#3572a5', Go: '#00add8', Rust: '#dea584', Shell: '#89e051',
|
||||
@@ -11,7 +16,7 @@ const LANG_COLORS: Record<string, string> = {
|
||||
|
||||
let projects: RepoCard[] = [];
|
||||
try {
|
||||
projects = await loadProjects();
|
||||
projects = (await loadProjects()).slice(0, limit);
|
||||
} catch {
|
||||
projects = []; // 兜底已在 lib 内处理,此处再守一层:空墙不破构建
|
||||
}
|
||||
@@ -34,7 +39,7 @@ try {
|
||||
{p.language}
|
||||
</span>
|
||||
)}
|
||||
<span class="stars">★ {p.stars}</span>
|
||||
<span class="stars">{p.source === 'gitea' ? 'self-hosted' : `★ ${p.stars}`}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
// 书评索引画廊(/books):色脊排版卡 × 年份分组规整网格
|
||||
// 预研方案 B:色脊天然安全(mood 深浅跨亮暗对比度风险回避),零 JS
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
|
||||
interface Props { locale?: Locale }
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
|
||||
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
|
||||
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
|
||||
const firstPara = (body: string | undefined): string => {
|
||||
const line = (body ?? '').split(/\n\s*\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('#') && !l.startsWith('---')) ?? '';
|
||||
return line.replace(/[*_>`\[\]]/g, '').slice(0, 60);
|
||||
};
|
||||
const years = [...new Set(books.map((b) => b.data.pubDate.getFullYear()))].sort((a, b) => b - a);
|
||||
const L = {
|
||||
shelf: locale === 'zh' ? '书架' : 'Books',
|
||||
count: (n: number) => (locale === 'zh' ? `${n} 本` : `${n} books`),
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={`${L.shelf} · wait4x`} description={locale === 'zh' ? '读过的书与一句策展' : 'Books read, one line each'}>
|
||||
<h1 class="page-title">{L.shelf}<span class="count mono">{L.count(books.length)}</span></h1>
|
||||
{years.map((year) => (
|
||||
<section class="year-group">
|
||||
<h2 class="y-label mono">{year}</h2>
|
||||
<ul class="shelf">
|
||||
{books.filter((b) => b.data.pubDate.getFullYear() === year).map((b) => (
|
||||
<li>
|
||||
<a
|
||||
class={`book ${locale === 'en' ? 'en' : ''}`}
|
||||
href={locale === 'en' ? `/en/archives/${b.id}/` : `/archives/${b.id}/`}
|
||||
style={`--mood:${b.data.mood ?? 'var(--ink-4)'}`}
|
||||
>
|
||||
<i class="spine" aria-hidden="true"></i>
|
||||
<div class="bk-body">
|
||||
<strong class="bk-title">{b.data.title}</strong>
|
||||
{b.data.bookAuthor && <span class="bk-author">{b.data.bookAuthor}</span>}
|
||||
{(b.data.description || firstPara(b.body)) && (
|
||||
<span class="bk-desc">{b.data.description ?? firstPara(b.body)}</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.page-title {
|
||||
font-size: var(--text-h1);
|
||||
color: var(--ink-1);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.count { font-size: 13px; color: var(--ink-3); }
|
||||
.year-group { margin-top: var(--space-5); }
|
||||
.y-label { font-size: 16px; color: var(--ink-3); margin-bottom: var(--space-3); }
|
||||
.shelf {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
/* 色脊卡:纸底 + mood 竖脊 + 文楷书名;hover 边框转 mood(.repo 同语言) */
|
||||
.book {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--paper-line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||
}
|
||||
.book:hover { border-color: var(--mood); transform: translateY(-2px); }
|
||||
.spine {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
background: var(--mood);
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
.bk-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.bk-title {
|
||||
font-family: var(--font-kai);
|
||||
font-size: 17px;
|
||||
color: var(--paper-ink);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.en .bk-title { font-family: var(--font-serif); }
|
||||
.bk-author {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.bk-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,10 @@ const postsSchema = z.object({
|
||||
tags: z.array(z.string()),
|
||||
/** 情绪色,仅书评轨使用;#RRGGBB */
|
||||
mood: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
||||
/** 一句话策展评语(/books 书架卡;缺省回退正文首段) */
|
||||
description: z.string().optional(),
|
||||
/** 原书作者(书架卡署名) */
|
||||
bookAuthor: z.string().optional(),
|
||||
draft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const dict = {
|
||||
nav_posts: { zh: '文章', en: 'Posts' },
|
||||
nav_gallery: { zh: '图库', en: 'Gallery' },
|
||||
nav_about: { zh: '关于', en: 'About' },
|
||||
nav_opensource: { zh: '开源', en: 'Open Source' },
|
||||
search_label: { zh: '搜索', en: 'Search' },
|
||||
search_placeholder: { zh: '搜索文章…(Esc 关闭)', en: 'Search posts… (Esc to close)' },
|
||||
search_hint: { zh: '索引由 Pagefind 构建期生成 · 静态站零后端', en: 'Index built by Pagefind · zero-backend static site' },
|
||||
|
||||
+28
-13
@@ -87,34 +87,49 @@ export async function resolvePhotoExif(
|
||||
|
||||
/**
|
||||
* photos 集合加载器 = glob(JSON) + EXIF 构建期补全。
|
||||
* entry.data.image 经 z.image() 校验后已是 ImageMetadata(不含源文件路径),
|
||||
* 因此从 JSON 原文重读 `image` 字符串,按“相对 JSON 文件”定位源图交给 exifr。
|
||||
*
|
||||
* 坑:dev 模式下单文件变更由内层 glob loader 的 watcher 增量处理,会绕过本包装层,
|
||||
* 改图或改 JSON 后需重启 dev server 才能补全 EXIF;SSG 构建每次全量 sync,无此问题。
|
||||
* 实现教训(2026-10-25 双日调试定位):Astro 7 内容层在 load() 结束后
|
||||
* 会按 loader 的原始写入序列做 schema 解析——包装层在 baseLoader.load()
|
||||
* 之后用 ctx.store.set 补写的字段会在解析阶段被静默丢弃(exif 全空但
|
||||
* 无任何报错的根因)。因此改为**拦截写入**:代理 baseLoader 看到的
|
||||
* store,在它 set 每个条目的那一刻异步增强,增强完成的 Promise 收集
|
||||
* 后在 load 返回前 await 完毕。
|
||||
*/
|
||||
export function photosWithExifLoader(base = './content/photos'): Loader {
|
||||
const baseLoader = glob({ pattern: '**/*.json', base });
|
||||
return {
|
||||
name: 'photos-with-exif',
|
||||
load: async (ctx) => {
|
||||
await baseLoader.load(ctx);
|
||||
const root = fileURLToPath(ctx.config.root);
|
||||
await Promise.all(
|
||||
ctx.store.keys().map(async (id) => {
|
||||
const entry = ctx.store.get(id);
|
||||
if (!entry?.filePath) return;
|
||||
const jsonPath = join(root, entry.filePath);
|
||||
const origSet = ctx.store.set.bind(ctx.store);
|
||||
const pending: Promise<void>[] = [];
|
||||
|
||||
const enrich = async (entry: Record<string, unknown>) => {
|
||||
const filePath = entry.filePath as string | undefined;
|
||||
if (!filePath || !filePath.endsWith('.json')) return entry;
|
||||
const jsonPath = join(root, filePath);
|
||||
const raw = JSON.parse(await readFile(jsonPath, 'utf8')) as {
|
||||
image?: string;
|
||||
exif?: Partial<PhotoExif>;
|
||||
};
|
||||
if (typeof raw.image !== 'string') return;
|
||||
if (typeof raw.image !== 'string') return entry;
|
||||
const imagePath = new URL(raw.image, pathToFileURL(dirname(jsonPath) + '/'));
|
||||
const exif = await resolvePhotoExif(raw.exif, imagePath);
|
||||
ctx.store.set({ ...entry, data: { ...entry.data, exif } });
|
||||
}),
|
||||
return { ...entry, data: { ...(entry.data as object), exif } };
|
||||
};
|
||||
|
||||
// Object.create 保留 store 其余方法/绑定;仅拦截 set
|
||||
const proxiedStore = Object.create(ctx.store);
|
||||
proxiedStore.set = (entry: Record<string, unknown>) => {
|
||||
pending.push(
|
||||
enrich(entry)
|
||||
.then((e) => origSet(e))
|
||||
.catch(() => origSet(entry)),
|
||||
);
|
||||
};
|
||||
|
||||
await baseLoader.load({ ...ctx, store: proxiedStore });
|
||||
await Promise.all(pending);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import BooksPage from '../components/pages/BooksPage.astro';
|
||||
---
|
||||
<BooksPage locale="zh" />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import BooksPage from '../../components/pages/BooksPage.astro';
|
||||
---
|
||||
<BooksPage locale="en" />
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import ProjectWall from '../../components/geek/ProjectWall.astro';
|
||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
---
|
||||
<BaseLayout title="Open Source · wait4x" description="My open-source activity: repos, contributions, and this site's source">
|
||||
<h1 class="page-title">Open Source</h1>
|
||||
<p class="blurb">This site is itself open source: <a class="link" href="https://git.wait4x.online/wait4x/blog">git.wait4x.online/wait4x/blog</a> — the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.</p>
|
||||
<Heatmap labels={{
|
||||
title: t('heatmap_title', 'en'), summary: t('heatmap_summary', 'en'),
|
||||
less: t('heatmap_less', 'en'), more: t('heatmap_more', 'en'), note: t('heatmap_note', 'en'),
|
||||
}} />
|
||||
<ProjectWall limit={99} />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.page-title { font-size: var(--text-h1); color: var(--ink-1); }
|
||||
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import ProjectWall from '../components/geek/ProjectWall.astro';
|
||||
import Heatmap from '../components/geek/Heatmap.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
---
|
||||
<BaseLayout title="开源 · wait4x" description="我的开源活动:仓库、贡献与本站源码">
|
||||
<h1 class="page-title">开源<span class="count mono">Open Source</span></h1>
|
||||
<p class="blurb">本站本身即开源:<a class="link" href="https://git.wait4x.online/wait4x/blog">git.wait4x.online/wait4x/blog</a> —— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。</p>
|
||||
<Heatmap labels={{
|
||||
title: t('heatmap_title', 'zh'), summary: t('heatmap_summary', 'zh'),
|
||||
less: t('heatmap_less', 'zh'), more: t('heatmap_more', 'zh'), note: t('heatmap_note', 'zh'),
|
||||
}} />
|
||||
<ProjectWall limit={99} />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.page-title { font-size: var(--text-h1); color: var(--ink-1); display: flex; align-items: baseline; gap: var(--space-3); }
|
||||
.count { font-size: 13px; color: var(--ink-4); }
|
||||
.blurb { margin-top: var(--space-3); margin-bottom: var(--space-5); font-size: 15px; color: var(--ink-2); line-height: 1.7; max-width: 640px; }
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
---
|
||||
// /terminal 彩蛋页:交互终端,零依赖单文件实现
|
||||
// 命令注册表唯一事实源;输出一律 DOM API + textContent(无 innerHTML,XSS 纪律)
|
||||
// 实现集提炼自 Phase 5b 预研(satnaing/terminal-portfolio 实读 + CodeNerve 反例)
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="terminal" description="wait4x 的终端彩蛋:help 开始探索">
|
||||
<section class="term-shell" id="term-shell" aria-label="交互终端彩蛋">
|
||||
<div class="term-bar" aria-hidden="true">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="term-title">wait4x@blog: ~</span>
|
||||
</div>
|
||||
|
||||
<div class="term-body">
|
||||
<!-- 欢迎语静态渲染:无 JS / 构建期首屏即有内容 -->
|
||||
<div class="term-out" id="term-out" aria-live="polite">
|
||||
<p class="line">wait4x terminal v1.0 —— 键入 help 查看可用命令</p>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<p class="line dim">此彩蛋需要 JavaScript:静态站无运行时框架,仅此页例外。</p>
|
||||
</noscript>
|
||||
|
||||
<form id="term-form" class="term-form" autocomplete="off">
|
||||
<label class="prompt" for="term-input">wait4x@blog:~$</label>
|
||||
<!-- 输入行独立成行:移动端软键盘弹出时不遮挡回显 -->
|
||||
<input
|
||||
id="term-input"
|
||||
class="term-input"
|
||||
type="text"
|
||||
aria-label="终端命令输入"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// ClientRouter 软导航会整体换 DOM:全部监听挂 document 常驻 + pathname 守卫短路,
|
||||
// 元素引用一律现取不缓存(站内 CommandPalette/Header 同款模式,见各自注释)
|
||||
const PATH = '/terminal/';
|
||||
|
||||
const COMMANDS: { name: string; desc: string }[] = [
|
||||
{ name: 'clear', desc: '清屏(等同 Ctrl+L)' },
|
||||
{ name: 'exit', desc: '退出终端,返回首页' },
|
||||
{ name: 'help', desc: '列出可用命令' },
|
||||
{ name: 'ls', desc: '列出站内页面(可点击跳转)' },
|
||||
{ name: 'open', desc: 'open gitea —— 打开代码托管(新标签页)' },
|
||||
{ name: 'sudo', desc: '试试看' },
|
||||
{ name: 'theme', desc: '切换亮/暗主题' },
|
||||
{ name: 'whoami', desc: '我是谁' },
|
||||
];
|
||||
|
||||
// 唯一允许 open 的外部目标:白名单硬编码,永不拼接用户输入
|
||||
const OPEN_TARGETS: Record<string, string> = {
|
||||
gitea: 'https://git.wait4x.online/wait4x',
|
||||
};
|
||||
|
||||
const SITE_ROUTES: { path: string; label: string }[] = [
|
||||
{ path: '/', label: '首页' },
|
||||
{ path: '/archives/', label: '文章' },
|
||||
{ path: '/gallery/', label: '图库' },
|
||||
{ path: '/moments/', label: '瞬间' },
|
||||
{ path: '/about/', label: '关于' },
|
||||
{ path: '/en/', label: 'English home' },
|
||||
];
|
||||
|
||||
// 模块级仅存终端语义状态(不存 DOM 引用);history 顺序旧→新
|
||||
const history: string[] = [];
|
||||
let histPos = 0;
|
||||
|
||||
function isTerminalPage(): boolean {
|
||||
return location.pathname === PATH || location.pathname === '/terminal';
|
||||
}
|
||||
|
||||
function out(): HTMLElement | null {
|
||||
return document.getElementById('term-out');
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement | null {
|
||||
return document.getElementById('term-input') as HTMLInputElement | null;
|
||||
}
|
||||
|
||||
function print(text: string, cls = ''): void {
|
||||
const box = out();
|
||||
if (!box) return;
|
||||
const p = document.createElement('p');
|
||||
p.className = cls ? `line ${cls}` : 'line';
|
||||
p.textContent = text; // 纪律:一切回显走 textContent
|
||||
box.appendChild(p);
|
||||
}
|
||||
|
||||
function printLink(path: string, label: string): void {
|
||||
const box = out();
|
||||
if (!box) return;
|
||||
const p = document.createElement('p');
|
||||
p.className = 'line';
|
||||
const a = document.createElement('a');
|
||||
a.href = path;
|
||||
a.textContent = `${path.padEnd(14)}${label}`;
|
||||
a.className = 'term-link';
|
||||
p.appendChild(a);
|
||||
box.appendChild(p);
|
||||
}
|
||||
|
||||
function scrollToBottom(): void {
|
||||
const shell = document.getElementById('term-shell');
|
||||
shell?.scrollIntoView({ block: 'nearest' });
|
||||
const box = out();
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function run(rawLine: string): void {
|
||||
const line = rawLine.trim();
|
||||
print(`wait4x@blog:~$ ${rawLine}`, 'echo');
|
||||
if (!line) return;
|
||||
history.push(line);
|
||||
histPos = history.length;
|
||||
|
||||
const [cmd, ...args] = line.split(/\s+/);
|
||||
switch (cmd) {
|
||||
case 'help': {
|
||||
// 注册表即帮助:单一事实源
|
||||
const width = Math.max(...COMMANDS.map((c) => c.name.length)) + 2;
|
||||
for (const c of COMMANDS) print(`${c.name.padEnd(width)}${c.desc}`);
|
||||
break;
|
||||
}
|
||||
case 'whoami':
|
||||
print('wait4x —— 阅读 · 技术 · 摄影,博客在 gitea 开源(open gitea)');
|
||||
break;
|
||||
case 'ls':
|
||||
for (const r of SITE_ROUTES) printLink(r.path, r.label);
|
||||
break;
|
||||
case 'theme': {
|
||||
const root = document.documentElement;
|
||||
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
root.dataset.theme = next;
|
||||
localStorage.setItem('theme', next); // 与 Header/Keyboard 的 t 键同存储键
|
||||
print(`theme → ${next}`);
|
||||
break;
|
||||
}
|
||||
case 'open': {
|
||||
const target = args[0];
|
||||
const url = target ? OPEN_TARGETS[target] : undefined;
|
||||
if (!url) {
|
||||
print(`用法: open <target>;可用: ${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
print(`opening ${url} …`, 'dim');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sudo':
|
||||
print('wait4x is not in the sudoers file. This incident will be reported.', 'err');
|
||||
break;
|
||||
case 'clear': {
|
||||
out()?.replaceChildren();
|
||||
break;
|
||||
}
|
||||
case 'exit':
|
||||
print('logout', 'dim');
|
||||
setTimeout(() => {
|
||||
location.href = '/';
|
||||
}, 600);
|
||||
break;
|
||||
default:
|
||||
print(`command not found: ${cmd}(help 查看可用命令)`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function submitLine(): void {
|
||||
const el = input();
|
||||
if (!el) return;
|
||||
run(el.value);
|
||||
el.value = '';
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function tabComplete(current: string): void {
|
||||
if (!current) return;
|
||||
const hits = COMMANDS.filter((c) => c.name.startsWith(current)).map((c) => c.name);
|
||||
if (hits.length === 1) {
|
||||
const el = input();
|
||||
if (el) el.value = hits[0];
|
||||
} else if (hits.length > 1) {
|
||||
// 多命中:先回显当前输入再列候选,与真实 shell 一致
|
||||
print(`wait4x@blog:~$ ${current}`, 'echo');
|
||||
print(hits.join(' '), 'dim');
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function historyStep(dir: -1 | 1): void {
|
||||
const el = input();
|
||||
if (!el) return;
|
||||
const next = histPos + dir;
|
||||
if (next < 0 || next > history.length) return;
|
||||
histPos = next;
|
||||
el.value = next === history.length ? '' : history[next];
|
||||
}
|
||||
|
||||
document.addEventListener('submit', (e) => {
|
||||
if (!isTerminalPage()) return;
|
||||
const form = e.target as HTMLElement;
|
||||
if (form.id !== 'term-form') return;
|
||||
e.preventDefault();
|
||||
submitLine();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!isTerminalPage()) return;
|
||||
const el = e.target as HTMLElement;
|
||||
if (el.id !== 'term-input') return;
|
||||
|
||||
// 中文输入法组词中的 Enter 不是提交;Tab 亦然
|
||||
if (e.isComposing) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitLine();
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const cur = (el as HTMLInputElement).value.trim();
|
||||
tabComplete(cur);
|
||||
} else if (e.ctrlKey && e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault();
|
||||
out()?.replaceChildren();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
historyStep(-1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
historyStep(1);
|
||||
}
|
||||
});
|
||||
|
||||
// 点击终端区域聚焦输入框;有选区(用户在复制输出)时不抢焦点
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!isTerminalPage()) return;
|
||||
const shell = (e.target as HTMLElement).closest?.('#term-shell');
|
||||
if (!shell) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return;
|
||||
if ((e.target as HTMLElement).tagName === 'A') return;
|
||||
input()?.focus();
|
||||
});
|
||||
|
||||
// View Transitions:软导航抵达/回到本页时恢复焦点与光标末位
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
if (!isTerminalPage()) return;
|
||||
const el = input();
|
||||
if (el) {
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
el.setSelectionRange(end, end);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 恒深色例外区:--code-bg 亮暗同值,终端不跟随主题(本页唯一例外,彩蛋语义) */
|
||||
.term-shell {
|
||||
max-width: 720px;
|
||||
margin: var(--space-6) auto;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.term-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3d4149;
|
||||
}
|
||||
.term-title {
|
||||
margin-left: 8px;
|
||||
color: #6e7681;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.term-body { padding: 14px 16px 16px; }
|
||||
.term-out { max-height: 60vh; overflow-y: auto; }
|
||||
.line { margin: 0; color: var(--code-ink); white-space: pre-wrap; word-break: break-word; }
|
||||
.line.echo { color: #adbac7; }
|
||||
.line.dim { color: #6e7681; }
|
||||
.line.err { color: #f85149; }
|
||||
.term-link { color: #539bf5; text-decoration: none; }
|
||||
.term-link:hover { text-decoration: underline; }
|
||||
.term-form {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.prompt { color: #7ee787; white-space: nowrap; }
|
||||
.term-input {
|
||||
flex: 1;
|
||||
min-width: 0; /* flex 子项收缩时保输入可用(窄屏) */
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--code-ink);
|
||||
font: inherit;
|
||||
caret-color: #7ee787;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
const BASE = 'http://127.0.0.1:18742';
|
||||
const BASE = 'http://127.0.0.1:18743';
|
||||
const SHOTS = '/tmp/e2e-shots';
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
|
||||
Reference in new issue
Block a user