Merge branch 'spec/2026-10-25_add-blog-i18n'
Phase 3 双语交付:/en/ 全站 + TMT 翻译管线 + 6 篇译文 + hreflang/RSS;八任务机器验证收敛归档。 Spec: 2026-10-25_add-blog-i18n
This commit is contained in:
commit
5405e33f81
36 files changed
+2348
-652
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
# Phase 3:双语骨架 + 腾讯 TMT 翻译管线 - 完成总结
|
||||||
|
|
||||||
|
## 交付结论
|
||||||
|
- 结果:完成
|
||||||
|
- 完成时间:2026-10-25 09:40
|
||||||
|
|
||||||
|
## 假设回顾
|
||||||
|
### 已验证假设
|
||||||
|
- TMT TextTranslate 单请求上限 2000 字符,长文需分块(按段落聚合)
|
||||||
|
- TMT 译文对文学内容质量有限,AI 标注 + 可人工润色锁定是补偿机制
|
||||||
|
|
||||||
|
### 仍未完全验证的假设
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 交付范围
|
||||||
|
### 已交付
|
||||||
|
- T1 astro i18n 配置 + locale 管道
|
||||||
|
- T2 UI 字典 + 组件串接
|
||||||
|
- T3 语言切换组件(Header 中英互跳)
|
||||||
|
- T4 /en/ 页面骨架 + AI 译文标注组件
|
||||||
|
- T5 TMT 翻译管线
|
||||||
|
- T6 存量翻译执行 + 标注落页
|
||||||
|
- T7 hreflang + 双语 RSS + sitemap locale
|
||||||
|
- T8 构建验收 + staging
|
||||||
|
|
||||||
|
### 未交付
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 偏差说明
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 简化决策
|
||||||
|
### 保持简单的关键选择
|
||||||
|
- 最简单可行方案:posts-en 独立集合 + TMT 管线脚本(幂等增量:未变文章不重译)+ /en/ 薄页面复用现有组件
|
||||||
|
- 暂不引入:runtime i18n 框架、cookie 语言记忆(切换即跳转,浏览器语言检测二期)
|
||||||
|
- 不做的抽象/配置化:不多语言框架化(zh/en 硬两语)
|
||||||
|
|
||||||
|
### 本轮明确不做的内容
|
||||||
|
- 上线切换(Phase 4)、Halo 退役(Phase 5)
|
||||||
|
|
||||||
|
## 变更边界
|
||||||
|
### 本轮主要改动模块
|
||||||
|
- 见 tasks.md 的 boundary 记录
|
||||||
|
|
||||||
|
### 明确未触碰的区域
|
||||||
|
- 上线切换(Phase 4)、Halo 退役(Phase 5)
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
### 备注
|
||||||
|
- 机器证据:runs.jsonl(见验证范围的机器验证率)
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
- 级别:package
|
||||||
|
- 机器验证率:8/8
|
||||||
|
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
|
||||||
|
|
||||||
|
## 哲学生效证据
|
||||||
|
### 行为成效回填
|
||||||
|
- Development Record 行为门禁通过
|
||||||
|
|
||||||
|
### 一致性门禁回顾
|
||||||
|
- 验收面(checklist 或成功标准)已通过
|
||||||
|
|
||||||
|
## Git 记录
|
||||||
|
- 日期时间:2026-10-25 09:40
|
||||||
|
- 范围:Phase 3:双语骨架 + 腾讯 TMT 翻译管线
|
||||||
|
- 功能:T1 astro i18n 配置 + locale 管道;T2 UI 字典 + 组件串接;T3 语言切换组件(Header 中英互跳);T4 /en/ 页面骨架 + AI 译文标注组件;T5 TMT 翻译管线;T6 存量翻译执行 + 标注落页;T7 hreflang + 双语 RSS + sitemap locale;T8 构建验收 + staging
|
||||||
|
- 操作:提交 / 推送
|
||||||
|
- 成效:Development Record 行为门禁通过
|
||||||
|
- 提交:pending local commit
|
||||||
|
- 提交区间:44bfb23efbd0bea97ae058c06c54b08d38ad73df..4aa79fca065c6aa70fa8a8ce1bf62b2b383f661e
|
||||||
|
- 推送:not-run (push stage follows commit)
|
||||||
|
|
||||||
|
## AGENTS.md 增量提议
|
||||||
|
```diff
|
||||||
|
i18n 纪律:页面间不能传 props,双语页必须 body 提取为 components/pages/*Page 薄化(zh/en 各一层皮);页面搬目录后 import 相对深度全线要审;HTML 注释不可进 frontmatter 模块区;ts 端点首行误带 --- 会报 undefined:2:7。TMT 管线:1800+ 字符包服务端静默截断(CHUNK_LIMIT=1600 + ratio<0.3 检测 + 段边界二分);幂等 state 落 content/posts-en/.translation-state.json;locked:true 人工润色锁。翻译脚本幂等可重跑,新文章 push 后手动跑一次即可。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 被拒绝的扩展提议
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 门禁证据
|
||||||
|
- check_spec_package.py exit 0
|
||||||
|
|
||||||
|
## 遗留事项
|
||||||
|
- 无;问题处置见下方结构化区块
|
||||||
|
|
||||||
|
## 问题处置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"issues": [],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{"task": "task_0001", "cmd": "pnpm astro build", "exit": 0, "duration_s": 4.295, "stdout_tail_sha256": "258b6510265dd12dc6ed8735a3401809fea696ec4128ff4c3c7957434787528a", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:39:57+00:00"}
|
||||||
|
{"task": "task_0002", "cmd": "pnpm astro build && grep -q \"zh\" dist/index.html", "exit": 0, "duration_s": 4.095, "stdout_tail_sha256": "5252dbe3bc5801ef8c78152b058ae5e484b56f73313d1d0862a953a50c96e66b", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:02+00:00"}
|
||||||
|
{"task": "task_0003", "cmd": "pnpm astro build && grep -q 'href=\"/en/\"' dist/index.html", "exit": 0, "duration_s": 4.047, "stdout_tail_sha256": "b00fc1939ea9163faa87b8189ed82f6d53cc4ff4015e172acf3cd621755e6c33", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:06+00:00"}
|
||||||
|
{"task": "task_0004", "cmd": "pnpm astro build && test -f dist/en/index.html", "exit": 0, "duration_s": 9.479, "stdout_tail_sha256": "b607a66135561ec1eb2d65c5b0e92152ca208093e957cd2513b456ad5474d3bf", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:15+00:00"}
|
||||||
|
{"task": "task_0005", "cmd": "python3 scripts/translate/translate_posts.py --dry-run", "exit": 0, "duration_s": 0.067, "stdout_tail_sha256": "221074b3fe93cda975031e431ad7c9a4f9caba896b3170266391374ef06b0dc6", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:15+00:00"}
|
||||||
|
{"task": "task_0006", "cmd": "test $(ls content/posts-en/*.md 2>/dev/null | wc -l) -eq 6", "exit": 0, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:15+00:00"}
|
||||||
|
{"task": "task_0007", "cmd": "pnpm astro build && grep -q hreflang dist/index.html && test -f dist/en/rss.xml", "exit": 0, "duration_s": 4.122, "stdout_tail_sha256": "2b0ed80c6ead9f0686bc4f24b1709d7eaeb0c0e8a34ff30f270d165d8bdaae05", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:19+00:00"}
|
||||||
|
{"task": "task_0008", "cmd": "bash infra/deploy/build.sh && test -f ~/docker_apps/blog/www-staging/en/index.html", "exit": 0, "duration_s": 5.51, "stdout_tail_sha256": "446db6c8e871aff5f7bbd4bec72a4f66a75446546a7b4e702bf1b12c334e6b72", "tree_fp": "50380ff9858de824ef43db6cc156bdbb9530e5e77dfcf8a6475bdb4a75d84837", "at": "2026-10-25T01:40:25+00:00"}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Phase 3:双语骨架 + 腾讯 TMT 翻译管线 - 项目范围
|
||||||
|
|
||||||
|
<!-- spec-format: 2 -->
|
||||||
|
|
||||||
|
## 1. 问题定义
|
||||||
|
- **项目目标**:全站中英实时切换(预构建达成毫秒级):/en/ 路由 + UI 字典 + hreflang + 腾讯云 TMT 构建前翻译管线(.en.md 入仓、可锁定、AI 标注)
|
||||||
|
- **目标用户**:站长(中文写作)/ 英文读者(一键切换)
|
||||||
|
- **核心价值**:「所有内容实时转英语」由双语预构建实现,零运行时翻译成本
|
||||||
|
|
||||||
|
## 2. 假设与待确认
|
||||||
|
|
||||||
|
### 2.1 已确认事实
|
||||||
|
- 腾讯密钥已落位 ~/docker_apps/blog/tencent-translate-credentials.txt(0600,标签 Secert* 拼写容错处理)
|
||||||
|
- Astro 内置 i18n 路由(defaultLocale zh、prefixDefaultLocale false → zh 原路径 / en 前缀 /en/)
|
||||||
|
- 内容量小(6 文 21 图 1 瞬间),TMT 免费额度(500 万字符/月)完全覆盖
|
||||||
|
- 评论共键已定(ArtalkComments pageKey 同 slug)
|
||||||
|
|
||||||
|
### 2.2 关键假设
|
||||||
|
- TMT TextTranslate 单请求上限 2000 字符,长文需分块(按段落聚合)
|
||||||
|
- TMT 译文对文学内容质量有限,AI 标注 + 可人工润色锁定是补偿机制
|
||||||
|
|
||||||
|
### 2.3 待确认问题
|
||||||
|
- [x] 翻译引擎由 LLM 改腾讯 TMT(用户拍板,与 Zotero 插件同款凭据)→ 结论:按 TMT 执行
|
||||||
|
|
||||||
|
### 2.4 可选解释与取舍
|
||||||
|
- content/posts-en/ 独立集合(slug 与 zh 1:1 映射)> 同目录 .en.md 后缀(glob loader 模式更简单、无重名歧义)-> 选独立集合
|
||||||
|
- 非文章页(图库/瞬间/关于)/en/ 侧 UI 字典切换 + 内容回退中文标注 > 全量翻译(瞬间/图库 caption 量大价值低)-> 选 UI 切换+回退
|
||||||
|
|
||||||
|
### 2.5 已证伪路径
|
||||||
|
- 无
|
||||||
|
|
||||||
|
## 3. 功能范围
|
||||||
|
|
||||||
|
### 3.1 核心功能(MVP)
|
||||||
|
- [x] T1 astro i18n 配置 + locale 管道
|
||||||
|
- [x] T2 UI 字典 + 组件串接(Header/Footer/SearchDialog/图库/瞬间/关于)
|
||||||
|
- [x] T3 语言切换组件(Header,中英互跳同路径 /en/ 前缀)
|
||||||
|
- [x] T4 /en/ 页面骨架(index/archives 文章路由真实双语,其余页 UI 英文化+内容回退标注)
|
||||||
|
- [x] T5 TMT 翻译管线(TC3 签名/分块/frontmatter 处理/.en.md 生成/锁定/dry-run)
|
||||||
|
- [x] T6 存量 6 文翻译执行 + AI 译文标注组件
|
||||||
|
- [x] T7 hreflang 双向 + 双语 RSS + sitemap locale 覆盖
|
||||||
|
- [x] T8 构建验收(/en/ 路由全量产物 + 翻译产物抽检)
|
||||||
|
|
||||||
|
### 3.2 扩展功能
|
||||||
|
- 瞬间/图库 caption 的批量翻译(二期按需)
|
||||||
|
|
||||||
|
### 3.3 不在范围内
|
||||||
|
- 上线切换(Phase 4)、Halo 退役(Phase 5)
|
||||||
|
|
||||||
|
## 4. 最小实现路径
|
||||||
|
- 最简单可行方案:posts-en 独立集合 + TMT 管线脚本(幂等增量:未变文章不重译)+ /en/ 薄页面复用现有组件
|
||||||
|
- 暂不引入:runtime i18n 框架、cookie 语言记忆(切换即跳转,浏览器语言检测二期)
|
||||||
|
- 不做的抽象/配置化:不多语言框架化(zh/en 硬两语)
|
||||||
|
|
||||||
|
## 5. 技术决策
|
||||||
|
- 技术栈:Astro i18n 内置路由 + python TC3 直签(零 SDK 依赖)
|
||||||
|
- 证据协议:evidence: runs
|
||||||
|
- 本轮允许改动:astro.config.mjs、src/、content/posts-en/、scripts/translate/、.spec/
|
||||||
|
- 本轮不应触碰:content/posts(zh 源只读)、infra/、生产
|
||||||
|
- Git integration branch:`spec/2026-10-25_add-blog-i18n`
|
||||||
|
|
||||||
|
### 5.1 验证策略
|
||||||
|
- 范围级别:package
|
||||||
|
- 变更对象:i18n 配置、src 组件、posts-en 集合、翻译脚本
|
||||||
|
- 快速检查:`pnpm astro build`
|
||||||
|
- 集成检查:/en/ 路由产物存在(index+6 文章页)+ hreflang 头存在 + rss.en 产出
|
||||||
|
- 全项目检查:staging 更新 + rollback-dryrun 仍绿;仅在构建链或部署脚本变更时升级范围重跑
|
||||||
|
- 升级触发:只有改动构建链、部署脚本或 nginx 相关面时,才升级验证范围并记录理由
|
||||||
|
|
||||||
|
## 6. 成功标准与验证方式
|
||||||
|
- [x] /en/ 与 /en/archives/{6 slug}/ 路由产物全存在 -> verify: task_0008
|
||||||
|
- [x] 翻译管线幂等(重跑零新增 API 调用,锁文件/哈希比对) -> verify: task_0005
|
||||||
|
- [x] AI 译文标注出现在 /en/ 文章页 -> verify: task_0006
|
||||||
|
- [x] hreflang 双向 + en RSS 产出 -> verify: task_0007
|
||||||
|
|
||||||
|
## 7. 风险与约束
|
||||||
|
- TMT 签名实现错误(TC3 步骤繁)→ 脚本内自检模式(--selftest 打印签名串供比对官方文档) -> 管线任务内覆盖
|
||||||
|
- 文学译文质量 → AI 标注 + 人工可改 posts-en 文件后 frontmatter locked: true 跳过重译 -> 机制内置
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Phase 3:双语骨架 + 腾讯 TMT 翻译管线 - 任务拆解
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
- 每个任务 boundary/verify 齐备;verify 首个反引号 span 为执行命令;迁移教训:verify 只读断言(不重跑生成器)
|
||||||
|
|
||||||
|
## 阶段一:实现
|
||||||
|
|
||||||
|
- [x] T1 astro i18n 配置 + locale 管道
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: 只改 astro.config.mjs(i18n 段)与 src/lib/locale.ts(当前语言工具)
|
||||||
|
- verify: `pnpm astro build`
|
||||||
|
|
||||||
|
- [x] T2 UI 字典 + 组件串接
|
||||||
|
- depends-on: task_0001
|
||||||
|
- claim: op-gitea(mesh 车道,预约 src/i18n/ 与 Footer/SearchDialog/gallery/moments/about)
|
||||||
|
- boundary: 新建 src/i18n/ui.ts(zh/en 字典)并串接 Footer/SearchDialog/gallery/moments/about 页的静态文案;不碰 Header/BaseLayout(orchestrator 的语言切换在那)
|
||||||
|
- verify: `pnpm astro build && grep -q "zh" dist/index.html`
|
||||||
|
|
||||||
|
- [x] T3 语言切换组件(Header 中英互跳)
|
||||||
|
- depends-on: task_0001
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: 只改 src/components/Header.astro 与 src/lib/locale.ts
|
||||||
|
- verify: `pnpm astro build && grep -q 'href="/en/"' dist/index.html`
|
||||||
|
|
||||||
|
- [x] T4 /en/ 页面骨架 + AI 译文标注组件
|
||||||
|
- depends-on: task_0001
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: 新建 src/pages/en/{index.astro,archives/[...slug].astro,archives/index.astro,gallery,moments,about}(复用组件+locale prop);src/components/AiTranslatedBadge.astro;posts-en 集合定义进 content.config.ts
|
||||||
|
- verify: `pnpm astro build && test -f dist/en/index.html`
|
||||||
|
|
||||||
|
- [x] T5 TMT 翻译管线
|
||||||
|
- claim: op-artalk(mesh 车道,预约 scripts/translate/)
|
||||||
|
- boundary: 新建 scripts/translate/{tencent_tmt.py,translate_posts.py}:TC3-HMAC-SHA256 签名(--selftest 模式打印中间量)、段落分块(<2000 字符)、frontmatter 透传(title 翻译、locked:true 跳过)、.en.md 生成到 content/posts-en/、原文哈希记录防重译;凭据从宿主 0600 文件读(标签 Secert* 容错);不碰 src/、astro 配置
|
||||||
|
- verify: `python3 scripts/translate/translate_posts.py --dry-run`
|
||||||
|
|
||||||
|
- [x] T6 存量翻译执行 + 标注落页
|
||||||
|
- depends-on: task_0004, task_0005
|
||||||
|
- claim: op-artalk(执行)+ orchestrator(验收)
|
||||||
|
- boundary: 真实调用 TMT 译 6 文入 content/posts-en/;/en/archives 模板挂 AiTranslatedBadge(T4 已备)
|
||||||
|
- verify: `test $(ls content/posts-en/*.md 2>/dev/null | wc -l) -eq 6`
|
||||||
|
|
||||||
|
- [x] T7 hreflang + 双语 RSS + sitemap locale
|
||||||
|
- depends-on: task_0004
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: BaseLayout 注入 hreflang 双向;src/pages/en/rss.xml.ts;sitemap 配置 i18n
|
||||||
|
- verify: `pnpm astro build && grep -q hreflang dist/index.html && test -f dist/en/rss.xml`
|
||||||
|
|
||||||
|
- [x] T8 构建验收 + staging
|
||||||
|
- depends-on: task_0002, task_0003, task_0004, task_0006, task_0007
|
||||||
|
- claim: orchestrator
|
||||||
|
- boundary: build.sh 跑 staging;抽检 /en/ 三页(首页/一文/图库)渲染
|
||||||
|
- verify: `bash infra/deploy/build.sh && test -f ~/docker_apps/blog/www-staging/en/index.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**当前进度**:由脚本计算,无需手动维护
|
||||||
@@ -18,6 +18,11 @@ const date =
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://wait4x.online',
|
site: 'https://wait4x.online',
|
||||||
integrations: [pagefind(), sitemap()],
|
integrations: [pagefind(), sitemap()],
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'zh',
|
||||||
|
locales: ['zh', 'en'],
|
||||||
|
routing: { prefixDefaultLocale: false },
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.GIT_COMMIT_SHA': JSON.stringify(sha),
|
'import.meta.env.GIT_COMMIT_SHA': JSON.stringify(sha),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"posts/csge-lei-pei-zhi.md": {
|
||||||
|
"sha256": "bd23b63a6453829fe3dccd4e261f2e9526917edc5d2fb8e23cac7e7371db140f"
|
||||||
|
},
|
||||||
|
"posts/deng-dai-ge-duo.md": {
|
||||||
|
"sha256": "45dee1a0c7572ac0e18392798cc9c9fd755bc64562bf87872ed238de72b30892"
|
||||||
|
},
|
||||||
|
"posts/ni-tan.md": {
|
||||||
|
"sha256": "07881bcae9ab5549ed888af4abbf314a487d29bdc03b22a60d0a19977c30321f"
|
||||||
|
},
|
||||||
|
"posts/shu-zui.md": {
|
||||||
|
"sha256": "da9c4603b7e9bb03336466bbd725df2b25230d2d1e6621c8cfd616cfdc6b7ae2"
|
||||||
|
},
|
||||||
|
"posts/hai-feng-zhong-shi-luo-de-xie-se-kui-zeng.md": {
|
||||||
|
"sha256": "52d1221b6193600b54d1bd4676786604207662651769ed503802de5555d87edc"
|
||||||
|
},
|
||||||
|
"posts/mian-sha.md": {
|
||||||
|
"sha256": "0f80f12b0a3811d026e8300e49921c4a14056b5b04b8cb61b04b04afdcec1eb5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
title: "Various configurations of CS"
|
||||||
|
pubDate: 2026-04-28
|
||||||
|
category: tech
|
||||||
|
tags: [Linux]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Cloud Server
|
||||||
|
|
||||||
|
## 1.1 Docker-related Operations
|
||||||
|
|
||||||
|
- **Docker Installation**
|
||||||
|
|
||||||
|
<https://www.cnblogs.com/Lenbrother/p/18673403>
|
||||||
|
|
||||||
|
<https://mirrors.tuna.tsinghua.edu.cn/help/debian/>
|
||||||
|
|
||||||
|
<https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install docker-ce \
|
||||||
|
docker-ce-cli \
|
||||||
|
containerd.io \
|
||||||
|
docker-buildx-plugin \
|
||||||
|
docker-compose-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Changing the Default Storage Location for Docker**
|
||||||
|
|
||||||
|
### **1. Check the existing storage directory of Docker**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker info
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the “Docker Root Dir” in the output. By default, this directory is located at /var/lib/docker, as shown below:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### **2. Check the location of the docker.service file**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
systemctl status docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The location of the docker.service file is shown below. You need to modify the contents of this file in subsequent steps.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### **3. Stop the docker.service process**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
systemctl stop docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### **4. Move the existing storage directory to a new location**
|
||||||
|
|
||||||
|
Select a directory with sufficient space to serve as the new root directory for Docker. Copy all files from the current root directory to the new directory. Let’s assume the new directory is /data/docker.
|
||||||
|
|
||||||
|
As mentioned in step 1, the current storage directory is /var/lib/docker. Copy all files there to the new directory using the following command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cp -r /var/lib/docker/* /data/docker
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note: The new directory must have read/write permissions for the root account. You can set these permissions directly by running `chmod -R 777 /data/docker`.*
|
||||||
|
|
||||||
|
### **5. Modify the path of the root directory**
|
||||||
|
|
||||||
|
There are two ways to do this. Choose either one as needed.
|
||||||
|
|
||||||
|
- **Modify the docker.service file**
|
||||||
|
|
||||||
|
Add `--data-root=/data/docker` after ExecStart=/usr/bin/dockerd. Separate the paths with spaces. /data/docker should be the new directory from step 4. If there are other configurations, leave them unchanged.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Modify the daemon.json file**
|
||||||
|
|
||||||
|
You can also modify the /etc/docker/daemon.json file to change the source of Docker images. If this file doesn’t exist, create one instead. Here’s an example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data-root": "/data/docker"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: It’s best not to use both methods simultaneously. Conflicting configuration paths can cause the Docker service to fail to start. You can check the detailed logs using the command `journalctl -u docker.service`.
|
||||||
|
|
||||||
|
### **6. Restart the Docker service**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### **7. Verify the status of existing images and containers**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker images
|
||||||
|
docker ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Changing the Docker Image Source**
|
||||||
|
|
||||||
|
Since DockerHub is unavailable in China, you need to change the image source.
|
||||||
|
|
||||||
|
– Method for changing the source:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo mkdir -p /etc/docker
|
||||||
|
sudo vim /etc/docker/daemon.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Write the following content:
|
||||||
|
|
||||||
|
``` json
|
||||||
|
{
|
||||||
|
"registry-mirrors":[
|
||||||
|
"https://docker.1ms.run",
|
||||||
|
"https://docker.xuanyuan.me",
|
||||||
|
"https://hub.docker.hk.sqyyl.cn",
|
||||||
|
"https://docker.rainbond.cc",
|
||||||
|
"https://mirror.iscas.ac.cn",
|
||||||
|
"https://docker.mirrors.sjtug.sjtu.edu.cn",
|
||||||
|
"https://docker.m.daocloud.io"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After saving and exiting, restart the Docker service.
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1.1 **Microsoft 365 E5 Renew X**
|
||||||
|
|
||||||
|
[Docker版本的E5续订服务:Microsoft 365 E5 Renew X_e5 Docker Customization - CSDN博客](https://blog.csdn.net/qq_40605167/article/details/122888580)
|
||||||
|
|
||||||
|
Enable the **IMAP/STMP** or **POP/STMP** feature for the email account.
|
||||||
|
|
||||||
|
The Docker deployment commands are as follows:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
docker run -d -p 1066:1066 -e TZ=Asia/Shanghai -e [sender="XXX@xxx.com](mailto:sender=%22XXX@xxx.com)" -e pwd="LFQSdUsM4rJfUKQB" -e [receiver="XXX@xxx.com](mailto:receiver=%2XXX@xxx.com)" -e adminpwd="****" hanhongyong/ms365-e5-renew-x:pubemail
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace <XXX@xxx.com> with your email address, and \*\*\*\* with your password.
|
||||||
|
|
||||||
|
### 1.1.2 DPanel
|
||||||
|
|
||||||
|
**DPanel** is a **lightweight container management panel** that allows for visual management and monitoring of Docker containers.
|
||||||
|
|
||||||
|
The Docker deployment commands are as follows:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
docker run -d --name dpanel --restart=always \
|
||||||
|
-p 8807:8080 -e APP_NAME=dpanel \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /home/dpanel:/dpanel dpanel/dpanel:lite
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1.3 ZeroTier Client
|
||||||
|
|
||||||
|
- **Step 1: Docker pulls the image**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo docker pull zerotier/zerotier
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Step 2: Create and run the ZeroTier container**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo docker run -d \
|
||||||
|
--name zerotier-one \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
--device=/dev/net/tun \
|
||||||
|
--net=host \
|
||||||
|
--cap-add=NET_ADMIN \
|
||||||
|
--cap-add=SYS_ADMIN \
|
||||||
|
-v /home/wait4x/zerotier-one:/var/lib/zerotier-one \
|
||||||
|
zerotier/zerotier:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Step three: Replace the official files with the self-built Planet files.**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo cp … …
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Docker container.
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo docker restart zerotier-one
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Step four: Adding a private ZeroTier network**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
sudo docker exec zerotier-one zerotier-cli join XXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: "Waiting for Godot"
|
||||||
|
pubDate: 2026-03-31
|
||||||
|
category: reading
|
||||||
|
tags: [read]
|
||||||
|
mood: '#b45309'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Who is Gordo? Why wait?
|
||||||
|
|
||||||
|
**Hopeless Waiting: Self-Salvation and the Prisoner’s Dilemma**
|
||||||
|
|
||||||
|
Godo is the character who runs through the entire book. However, no one has actually seen Godo in reality. Gogo and Didi simply wait for Godo. Is Godo a kind of hope? Or perhaps a belief? Waiting, waiting… Sometimes, they even forget about life itself, choosing to spend their time waiting by committing suicide. So what’s the meaning of waiting for Godo? Isn’t it a spiritual pursuit and a way of redemption? Or maybe waiting for Godo has become their only goal in life. They don’t know what the purpose of waiting is, but waiting for Godo is the only thing they can do, and it’s also the only way for them to escape reality. Whether Godo exists or not doesn’t matter anymore. What matters is the act of “waiting” itself, which serves as a reminder that they haven’t completely lost their physical vitality.
|
||||||
|
|
||||||
|
Perhaps Gordo doesn’t actually exist at all. Gordo is simply a way for Gogo and Didi to escape the real world. It’s the only way they can feel like they still have something to do. It’s a kind of absurd waiting, a waiting that has no meaning at all. And the process of waiting itself is also absurd. Gogo and Didi wait just because they have to wait. The waiting period becomes a very desolate experience for them. They don’t know what to do, they don’t know how to pass the time during this waiting. They just wait, waiting for Gordo to arrive.
|
||||||
|
|
||||||
|
The way humans sustain themselves in the meaningless world is often not through dramatic resistance, but through trivial, repetitive, and even ridiculous daily efforts. This is both a form of compromise and a kind of tragic resilience. However, why not take the brave step of dying, or breaking away from the status quo, and follow the passing of time? Why stay stuck in the present, trapped in the past? Perhaps dying requires determination, and breaking free requires a clear direction. But when both these resources are exhausted, “waiting” becomes the only viable action left.
|
||||||
|
|
||||||
|
Didi always ponders back in time, seeking consistency within the passage of time. Meanwhile, Gogo simply wants to live in the present. We are constantly torn between “trying to understand meaning” and “being forced to endure the moment at hand”. No one truly lives in the future—because it seems that no one has a future at all. The future is merely a waiting period, a constant state of anticipation. People go through day after day, resisting the real world and life in their own unique ways. They coexist with time, sometimes even opposing it, as if to prove that they still exist, and that what they do makes sense.
|
||||||
|
|
||||||
|
So, in this absurd life… time continues to pass as usual. What should we do? With what can we confront emptiness? When waiting becomes fate, when the future is reduced to mere “tomorrow” over and over again, how should we face this inevitable passage of time?
|
||||||
|
|
||||||
|
Between Decline and Rebirth: The Anchorage of Memories and the Flow of Time
|
||||||
|
|
||||||
|
There are five characters in the play, but it seems that only Didi is awake. Only he remembers the past, remembers what happened yesterday. Gogo, Pozo, the lucky guy, and the young man none of them remember anything that happened yesterday. It seems as though they are always living in the present. In reality, time continues to pass. The growth of green leaves on that tree signifies that everything keeps moving forward. They live in the present, but they also bear witness to the past and step towards the future. However, every day that passes is just a repetition of the same events, just like the constant discussions about suicide. Going to death is merely a way to pass time, without truly having the courage to take those steps.
|
||||||
|
|
||||||
|
The lucky person’s incoherent speeches in the play resemble both rational desperation and absurd reflections of reality. When ordered to think, his monologues initially follow a logical structure, using various terms and concepts. But soon, they become chaotic, with words failing to form any coherent meaning at all. It’s as if people try to use reason to combat absurdity, to explain the world that has lost its logic through thought… but all their efforts are in vain, and everything ultimately leads to nothingness.
|
||||||
|
|
||||||
|
On the other hand, Didi’s clarity seems to be another form of luck. He constantly tries to piece together fragments of memories to recall the past—that past which no one else remembers anymore. No one remembers yesterday; only Didi does. Didi fights against the passage of time with his memories, enduring pain yet remaining awake. Gogo, on the other hand, tries to ease the burden of hope, feeling numb but at the same time relaxed. For those around him, time simply doesn’t exist at all—it’s a state of utter absurdity. Dead trees grow leaves, witnessing the changes in the world, but people remain stuck in the same place, cycling endlessly. Didi is aware of the passing of time, but he still remains trapped in the cage of time. All he can do is wait quietly for Gotham to arrive.
|
||||||
|
|
||||||
|
A tree can be dead, or it can still have leaves.
|
||||||
|
Waiting can be futile, or it can be a proof of existence.
|
||||||
|
Memory can be a burden, or it can serve as a point of reference.
|
||||||
|
Absurdity can represent despair, or it can be the beginning of freedom.
|
||||||
|
|
||||||
|
Perhaps in this absurd world, in this meaningless life, there are times when we don’t need to “overcome” the void. Sometimes, simply “continuing” is enough to bring meaning. Just like that tree—between its blooming and withering, that is the answer.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
- Estragon: (after a long silence) What you’re saying is the opposite of what I said, right?
|
||||||
|
Fradimir: It’s a matter of temperament.
|
||||||
|
Estragon: It’s a matter of personality.
|
||||||
|
Fradimir: People are powerless against this.
|
||||||
|
Estragon: It’s useless to try anymore.
|
||||||
|
Fradimir: One’s nature remains unchanged, no matter how hard one tries to change it.
|
||||||
|
|
||||||
|
The more people I meet, the happier I become. Even when dealing with the most humble people, I feel like I’m gaining new experiences, enriching my knowledge, and able to appreciate happiness in a better way.
|
||||||
|
|
||||||
|
- There is a certain amount of tears that can be shed in this world. When someone in one place starts crying, there must be someone else who stops crying in another place. The same goes for laughter. (He laughs.) Therefore, we shouldn’t speak ill of our time. It isn’t any worse than times in the past. (Silence.) Nor should we speak well of our time either.
|
||||||
|
|
||||||
|
- Without him, my thoughts and feelings would probably always be confined to the trivial and mundane matters of everyday life—things that don’t really matter at all. As for what constitutes the highest form of beauty, goodness, or truth, I truly cannot attain it. I also know that I simply don’t deserve it at all.
|
||||||
|
|
||||||
|
- What exactly is so extraordinary about it? Is it actually the heavens themselves? It’s pale yet bright, just like the sky at any moment during the day. (Loudon) The same sky, in the same direction.
|
||||||
|
|
||||||
|
The lights suddenly dimmed. For a moment, the sky turned dark. The moon rose at the end of the stage, climbing higher and higher, silently scattering silver light across the stage.
|
||||||
|
|
||||||
|
People don’t need us every day. To be honest, people don’t exactly need us that much. Others can also do these things. Even if they may not do them as well as we do, at least they can do them just as well. We just heard a cry for help… It seems like he is sending a message to all of humanity. But right now, at this moment, there are only two humans here: us. Whether we like it or not. We must seize this opportunity, while time isn’t yet too late. Since fate has brought us together among these despicable creatures, let’s at least try to represent them properly. Indeed, when we weigh the pros and cons, we remain worthy of our conditions of existence. A tiger will instinctively run to help its kind. Otherwise, it will flee into the depths of the jungle. But that’s not the issue. What we do here is what we must ask ourselves. We are honored to know the answer to that question. Yes, in this vast chaos, there is one thing that is clear: we are waiting for Gordo’s arrival.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: "The lost blood-red gift, lost in the sea breeze"
|
||||||
|
pubDate: 2026-03-25
|
||||||
|
category: reading
|
||||||
|
tags: [read]
|
||||||
|
mood: '#0f766e'
|
||||||
|
---
|
||||||
|
|
||||||
|
### Autumn – The Autumn of Scott, the Autumn of a Father, the Autumn of a Youth (A Prelude to Winter)
|
||||||
|
|
||||||
|
Autumn brings a sense of desolation, signifying the twilight of time. The father has grown old, as has the strong and vigorous Mastro Scott. Under the burden of life, the once youthful and strong father can no longer handle everything with ease and confidence. Faced with his former companions, he has no choice. His two children witness all this: their former playmates must now distance themselves from them. For the first time, they confront the cruelty of reality. For the father, he has lost his most loyal companion, Scott, who has been with him through many hardships. Would the father be willing to sell Scott? No! But he must do so. With children still needing care around him, he is no longer the same person he used to be. Under the weight of life’s burdens, he has no choice. He loves Scott, he loves his children, he loves his wife… To survive, love also requires compromise.
|
||||||
|
|
||||||
|
The youth see firsthand the cruelty of life and the helplessness of growth. For the father, it is the helplessness of a hero at the end of his career; for Scott, it is the tragedy of loyalty being betrayed; for the children, it is the moment when their innocence ends.
|
||||||
|
|
||||||
|
### Darkness Beyond Reach – James’s “Departure”
|
||||||
|
|
||||||
|
At the age of 18, James bids farewell to his parents and grandmother, setting off on a journey to escape Breton Point. He wants to get away from the coal mines, to break free from fate… But he realizes that he can never truly escape them. It seems that since birth, serving the coal mines has been his destined mission, one he cannot escape.
|
||||||
|
|
||||||
|
This is a story of seeking a future, but also of stepping into darkness. Darkness comes from beneath the earth, from the absence of light. It comes from the future, from seeing clearly the destiny that awaits us. It comes from a darkness that we cannot escape. The darkness is vast and endless. We want to leave, to break free from our fate, but we are still trapped in its embrace, carried by it towards the unknown.
|
||||||
|
|
||||||
|
This is a shackle passed down through generations, a fate that originated from this land—Breton Point. It is impossible to change. We try to break free, but it seems like we are pushed forward by fate, towards this vast darkness. This is the longing of a young man, his resistance against fate, his helplessness… And then there is the acceptance of fate after seeing it clearly.
|
||||||
|
|
||||||
|
### The Lost Blood-red Gift in the Sea Wind – The Distant Home
|
||||||
|
|
||||||
|
When I return to the place where I was born, a seemingly successful existence, I meet my father, mother, my former wife, and my son. In the past, I chose to leave this place and go to Montreal. Now, as I return here again, the sea wind is fierce, as if trying to drive me away from these people who once were my family. I cannot hug them anymore, nor can I share a happy life with them.
|
||||||
|
|
||||||
|
For me, they are the blood-red gifts lost in the sea wind. They carry my past, my childhood memories of family and love. But now, all of that has disappeared in the sea wind. To them, I am an outsider. My parents treat me calmly, while my former lover hates me. My child doesn’t even recognize me. My presence might actually disrupt the existing balance, warmth, and happiness.
|
||||||
|
|
||||||
|
This is the helplessness of returning to the past once again, after reaching adulthood. Successfully getting away from that seaside, one waits for another return, only to find that it’s no longer possible to go back. One no longer belongs to that place. The sea breeze has swept away everything, and the cold wind stings everyone’s heart.
|
||||||
|
|
||||||
|
***Hometown is not a place where one can return; it’s a past that can never be reached again.*** We have all become strangers in our own lives. In the sea breeze, we gaze at each other, facing that lost, blood-red gift of the past.
|
||||||
|
|
||||||
|
### Returning to Hometown – The Journey Back
|
||||||
|
|
||||||
|
Returning to the hometown from the big city. Returning to the hometown is different from the return that comes with the lost, blood-red gift in the sea breeze. The story of returning to the hometown involves a more acute conflict. The story of returning to the hometown is based on blood ties and family bonds, but it also involves a sense of separation. Unlike the calmness of the lost, blood-red gift in the sea breeze, the main characters of this journey are from the big city. The father comes from the countryside, deeply bound by darkness, while the mother is a beautiful woman living in the bright and colorful city. As a child, the young man faces a strong opposition between these two emotions during his journey back to the hometown. His father wants to return to his former homeland to see his family again, to step onto that solid ground once more. His mother, however, hates this dark city from the bottom of her heart, unwilling to let her child be exposed to such fate and suffering again.
|
||||||
|
|
||||||
|
Then, in his youth, he was naive. He didn’t know what his parents felt about this dark city. He played with his siblings, searching for happiness in his unfamiliar homeland. But in the eyes of his grandmother, they no longer belong here. They have already left this city, and they have broken away from the family’s bloodline. His father wants to reconnect with the past, while his mother wants him to stay away from this darkness.
|
||||||
|
|
||||||
|
When everything ends, ***everyone waves goodbye, but the train keeps going forward, because it has no choice, and because it doesn’t like to see people waving goodbye***. His father can’t return; he is no longer a true Cape Bretoner. His mother doesn’t want to return; she despises this land from the bottom of her heart. His child can’t return either; he has only a brief interest in his hometown, without any deep attachment.
|
||||||
|
|
||||||
|
### The Gilded Gift of Grayness – The Bitterness of Growing Up
|
||||||
|
|
||||||
|
The youth’s courage and growth, but the pain caused by misunderstandings between parents.
|
||||||
|
|
||||||
|
The youth desires to gain his parents’ recognition through his own efforts, to reduce their pressure on him. But the way to earn money isn’t the traditional way of gaining acceptance. His parents hope that Jesse will be honest and have a sense of morality, and that he won’t go astray. They love Jesse, but they don’t approve of him earning money through gambling, nor do they approve of his behavior of staying out of bars all night and getting drunk.
|
||||||
|
|
||||||
|
On one side, the teenager believes that he has earned money through his own efforts and wants to gain his parents’ approval. On the other side, his parents, based on their moral values and beliefs, think that their child has gone astray and want to correct his “wrongful behaviors”. The teenager accepts his parents’ teachings, endures the “gray gifts” they give him—punishments and discipline—and loses the “golden gifts” of pride and sense of achievement. But in return, he receives the “gifts of growth”—there are always conflicts between a child’s actions and those of their parents. A child’s brave actions may seem insignificant to their parents. This is a problem between generations, as well as between different roles within a family. It’s a bitter but true reality.
|
||||||
|
|
||||||
|
### The perseverance of the older generation, and the departure of the younger ones
|
||||||
|
|
||||||
|
It’s much braver to spend a lifetime doing things that one dislikes, rather than constantly selfishly pursuing dreams and acting on a whim.
|
||||||
|
|
||||||
|
My father loved literature and cherished the idea of freedom. However, in the face of life’s challenges, he chose to bind himself tightly to this ship. He sent his children away, so that they wouldn’t be bound by this ship anymore. He was willing to spend his entire life on this ship, sitting in that chair, peacefully living out his days. He dedicated his life to life itself, to his descendants.
|
||||||
|
|
||||||
|
As the protagonist, I witnessed my father’s choices and his eventual death. In my heart, there was both admiration for my father and contempt for myself. I lacked my father’s courage, but in the end, I made the same decision he would have made: to stay on this ship and carry on his legacy. However, in my mother’s eyes, all of this was in vain. My father is gone now, and this ship no longer deserves its former glory. The younger generation has abandoned it, and it no longer belongs to us anymore.
|
||||||
|
|
||||||
|
This is a conversation between two generations. It reflects the aspirations of the younger generation for a new life, while the older generation maintains their attachment to the past. But the father acts as a bridge between the old and the new eras. He supports his children as they move towards the new era, while he willingly remains trapped in the old times. The death of the father was utterly powerless. In the vast ocean, everything shattered, and the bridge between the new and the old eras was destroyed. I, my brothers, my sisters—to those from the old era, to this land, we are all traitors.
|
||||||
|
|
||||||
|
### The road to Luancenjiao – A struggle between life and death
|
||||||
|
|
||||||
|
A father and son on the verge of death engage in a profound conversation at the end of their lives. The grandmother doesn’t want to live alone, nor does she wish to be confined in a nursing home. She wants to be redeemed by her grandson. Meanwhile, the grandson realizes that his life is coming to an end. In his last moments, he also wants to be close to his grandmother, hoping to receive her redemption. But things don’t always go as we hope. Sitting opposite each other, separated by countless years, neither of them can be redeemed by the other.
|
||||||
|
|
||||||
|
The path to the point of Julan Cen is the way for a grandson to search for past memories, a path to pursue kinship. It is also the road of grandmother’s tragic memories, and ultimately, it leads to death.
|
||||||
|
|
||||||
|
In her youth, her grandmother watched as her husband left her. In her old age, she saw her children grow up and move away from her. She lived alone in a house perched on the edge of a cliff, accompanied only by livestock, the sound of waves crashing against the cliffs, and endless loneliness. At the end of her life, she hoped to be redeemed by her grandchildren. But she failed. Her grandson, who was also facing death, could only allow her to enjoy a brief moment of redemption and love. After that, there was nothing but true loneliness.
|
||||||
|
|
||||||
|
Sun Tzu walked away from here, towards the city. In the last moments of his life, he stepped onto the path that led to Rangcenjiao, in search of the last trace of love. He remembered his grandfather’s death, witnessed the same patterns of behavior shown by his parents towards his grandmother. Then, during his grandmother’s dying moments, he witnessed the final stages of her life. His grandmother passed away. The world before him became dark. Even the last glimmer of hope disappeared. The world within his heart also turned dark. Two kinds of darkness began to merge together, and everything was lost in darkness.
|
||||||
|
|
||||||
|
### Chains woven from blood and sea, waves of growth and fate intertwining
|
||||||
|
|
||||||
|
Brent Island symbolizes barrenness, inheritance, and fate. It has witnessed generations of people struggling to maintain their traditions and traditions. The continuation of miners’ lives, the passing on of fishing traditions—all these have also shown how new generations resist their fate. They flee Brent Island, flee the gloomy cities, flee the sea. It also reflects the gap between generations, the dedication of ancestors towards their homeland, their perseverance in holding onto the facts and beliefs they have chosen. Children yearn for a new life and look forward to the outside world. Ultimately, Brent Island represents the helplessness, pain, hope, and longing that people experience under the constraints of their fate.
|
||||||
|
|
||||||
|
Brighton Point marks the beginning of everything, and also the end of everything. The story begins at the shores of Brighton Point, and ends there as well. This story about growth, about fate, about returning back... it is tightly linked to Brighton Point. Brighton Point is a chain woven from blood and sea… it’s like a unique fortress. New lives emerge from within this place, while old lives remain stubbornly clinging to their former ways. Those who have already left wish to return. This is a land bathed by the sea breeze, filled with sorrow and dignity. Some stay steadfast, others suffer… but these are their eternal roots. It’s also the root that many cannot escape from… even though they seem never to be able to break free from the chains woven by blood and sea… those chains belonging to the Soviet Union.
|
||||||
|
|
||||||
|
Brenton Point marks the beginning of life in this story, as well as the final chapter leading to death. People begin to grow here, escape from here, and return again. They stay steadfast here, and also move on to new lands. This place carries the helplessness people feel towards the challenges of reality; it carries their courage to break free from the cage of fate. It also embodies the indelible connection between those who leave their homeland and the land they once inhabited, along with the paradox of never being able to return. It bears witness to everything that happens on this island, this dimly lit city—every story about growth, every story about ideals and reality. It embodies the struggles of survival, the hope of living, and the tranquility of death. Everything occurs alongside this island, alongside the fierce, salty wind that blows through the calm sea.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
### Autumn
|
||||||
|
|
||||||
|
Every morning brings with it an increasingly dark atmosphere, and its expression becomes even more gloomy. The Atlantic Ocean’s murky waters, with their yellowish-tinted waves, hit the smooth rocks along the shore with great force, without any mercy. These rocks scattered beneath the ever-present cliffs seem to be remnants of some giant who have carelessly dropped them there.
|
||||||
|
|
||||||
|
- The heavy rain fell like countless beads tilted by the wind, covering the entire area with water.
|
||||||
|
|
||||||
|
### The darkness stretches endlessly...
|
||||||
|
|
||||||
|
At that time, we all prayed, including him, for the tendon to remain intact and for any infections to be avoided. After all, that hand was the only one he had left. In the dangerous and unpredictable ocean, we were all passengers who depended on that hand for survival.
|
||||||
|
|
||||||
|
– But all storms eventually diminish into mere strong winds, and ultimately return to calmness. Perhaps without storms and strong winds, we cannot achieve any true peace. Or maybe peace requires those storms as a prerequisite, in order for it to truly manifest its true nature.
|
||||||
|
|
||||||
|
Once you drink the water that comes from beneath the ground, it becomes part of your body. It’s like the blood that a man leaves in a woman’s body—it can change a woman for life, and remains with her forever. That part of the man continues to flow deep within the woman’s body. Such things can keep you awake at night, haunting you until the end of your life.
|
||||||
|
|
||||||
|
I extended my hand towards my grandfather. Suddenly, I felt a twisted and powerful force coming from his hand—it seemed like it could crush my palm completely. His fingers were all deformed; his thumbs were flattened and overly broad. The scars on his hands had been polished to a hard, black texture. Those enormous, irregularly shaped balls at the joints of his fingers indicated that he had suffered severe injuries due to broken bones. Yet, it was still a hand full of terrifying power. For a moment, I had a terrifying thought: perhaps I wouldn’t be able to walk anymore… perhaps I would never be released from this situation. But in the end, my grandfather let go of my hand. I felt free again.
|
||||||
|
|
||||||
|
- Even on those rough and uneven streets, when you realize that it may never be possible to step foot there again, or even to see them once more in this lifetime, they still seem desolate and lonely.
|
||||||
|
|
||||||
|
I saw the expressions on their faces—the expressions of hundreds and thousands of people who have appeared in my past life. I even saw the same expressions myself. I’ve encountered such vehicles before, and I saw the same looks reflected in the glass and mirrors. At this moment, I don’t really belong to their lives at all. I’m just trapped inside this semi-transparent, movable display box. I stay for a while along their sorrowful streets, then disappear. Their lives go on as usual, without any changes. I simply walk through their lives, without being involved with them at all. Another insignificant river carries away its floating debris, while only the banks remain eternal. The water flows towards some unknown destination. Those debris never reach that destination, nor can they ever reach it. In that brief moment, it’s enough for them to categorize me, and then dismiss me as if I weren’t even there: “Our struggles, the spirits buried in our graves… What can he understand?”
|
||||||
|
|
||||||
|
I finally understand that the elders in my past life were much more complex than I judged them to be. Grandpa was emotional, romantic, and loved coal mines. Grandma, on the other hand, was strict, practical, and hated coal mines. There were definitely differences between them. My mother was silent, strong, and obedient; my father was impatient, often becoming rude and inappropriate, yet he also had deep affection that couldn’t be expressed in words. They were so different, but both of them managed to carry on certain aspects of their lives over the past eighteen years, and I inherited those aspects from them. Other than that, I have no idea what kind of life or world I actually lived. Their lives flowed into mine, and my life became one of theirs. There were certainly differences between us, but in many ways, we were much more similar than I thought. Now I realize that perhaps one can live two lives, yet see the same truth.
|
||||||
|
|
||||||
|
It seems that there’s nothing I truly know about myself. But I do understand my mistakes: I wasn’t honest enough with others and with myself.
|
||||||
|
|
||||||
|
The car continued its journey, heading towards the darkness of the night. The headlights of the car searched for that tempting white line. It seemed to rise gently, as if it were pulling us forward, upward, towards some deep place. And we simply followed it, plunging deeper into the boundless darkness.
|
||||||
|
|
||||||
|
### The Lost Gift of Blood in the Sea breeze
|
||||||
|
|
||||||
|
- In the darkness, it’s warm; in the wind, it’s calm.
|
||||||
|
|
||||||
|
The darkness oppresses me, yet it prevents me from falling asleep. Everything I hear and see seems to be mere illusions. They are merely walls of memory, sparks of imagination that keep burning out against those walls.
|
||||||
|
|
||||||
|
### Returning to My Hometown
|
||||||
|
|
||||||
|
Everyone is waving, but the train just keeps going forward. It has no other choice, and it also doesn’t really care about people waving goodbye.
|
||||||
|
|
||||||
|
### A golden gift, tinged with grayness
|
||||||
|
|
||||||
|
He then thought about how terrifying the violence hidden within his father’s body was. That violence roared deep within him, like a turbulent stream beneath a mountain peak. It echoed in some deep, inaccessible caves, where water splashed against the rocks.
|
||||||
|
|
||||||
|
### Ship/Boat
|
||||||
|
|
||||||
|
For her, what we call the “longview” is nothing more than the place where her fearless black eyes gaze, that line separating the sea from the sky.
|
||||||
|
|
||||||
|
It’s much braver to spend a lifetime doing things that you hate, rather than constantly selfishly pursuing your dreams and acting on your own whims and desires.
|
||||||
|
|
||||||
|
Outside that solitary window, whether it’s the ice and snow of winter, the oppressive heat of summer, or in autumn when the wind blows and waves rise, her mother stands alone, gazing out at the sea. It’s still dark when she wakes up, and she hears the sound of shoes on the gravel path leading to the dock. She knows that those footsteps will never stop, because no one in her family wants to go with them to the fishing boats that take them into the sea. What she cannot bear is thinking about her mother’s deep affection for the sea… But when she thinks of you, her heart fills with bitterness: for to her, the former represents loyalty, while you represent betrayal.
|
||||||
|
|
||||||
|
### The way to Dulao Cenjiao
|
||||||
|
|
||||||
|
“We are the descendants of that despair in our hearts. Skye, Rum, Balla, Diri—they are our past.”
|
||||||
|
|
||||||
|
It is quiet and elegant. The beauty of its wings is restrained yet strong. Its extraordinary vitality is fully reflected in its demeanor.
|
||||||
|
|
||||||
|
When I cut my grandmother’s yellowed, dirty nails, I realized that I was allowed to participate in a silent, secret communication. This kind of communication is something that strong people often experience when they interact with those who are weaker. This gap between ability and understanding was something that my grandmother used to impose upon her children—sometimes even their children. This gap further fueled our hidden shortcomings, and adults always carried these flaws onto the next generation. I remember dirty diapers, wet sheets… the first time I tried to speak or walk. I remember Santa Claus coming and going, and all those silly, childish wishes. I remember fearing the passing of time, the fears from childhood that would wake me up from nightmares, and the screams I would let out alone in the middle of the night. I remember the release during the night, the real and imagined secrets and sins… I remember all of this, and I will never forget it. The gap between ability and understanding also includes the tangible support given through physical actions, maintaining health as a way to continue this commitment… Perhaps, it also includes love. In the past, when I thought of my grandmother, I thought more of her abilities. But now, I think of love. Maybe, what I am thinking now is that my grandmother’s strength was always too obvious.
|
||||||
|
|
||||||
|
– No one has ever said that living is an easy thing. Life is simply about surviving.
|
||||||
|
|
||||||
|
We sat at both ends of the kitchen table, looking at each other. It seemed as though there was a gap between us, filled with the long years that have passed since our times.
|
||||||
|
|
||||||
|
She held my hand so tightly, as if I could pull her out of that dark, dreamlike water. I tried to respond with the same strength, because somehow I imagined that she could save me. Suddenly, both of us started crying. We cried for each other, and also for ourselves. We thought we could find strength in each other, but in this moment of fragile tears, we realized that we had found it in each other.
|
||||||
|
|
||||||
|
Sometimes, in the fear of darkness, it’s difficult to distinguish between dreams and reality. We may wake up in the middle of the night, but because the world from our dreams seemed so real, we try to return to that comforting state of oblivion by using our own willpower. Other times, the situation is reversed: we might pinch ourselves or bang our fists against the iron bedposts. Sometimes, nightmares have no boundaries at all.
|
||||||
|
|
||||||
|
All expectations and fears, both those from the past and those of the present, collide and entangle with each other. Sometimes, when we reach the end of the “present,” the “past” seems to become even more significant. Because there is nothing else besides the past; we feel that there is nothing else we can understand. I feel like I’m falling back into the past, hoping to possess more of it, because my future seems to be getting fewer and fewer. Twenty-six years is too short—it isn’t enough. I want to follow in the footsteps of our ancestors, so that I can gain more from what remains of this insignificant present. Those superstitions, herbal remedies, the calls of fate, the sounds of violins that linger in our minds, and those cancer treatments that resemble spider webs… I want to understand them all. What do those所谓的“divine eyes,” psychic visions, dogs’ instincts, and seagulls’ cries have to say about survival and its end? I also want to know those things. I want to return to those priests who can cast spells with their hands. I want to return to those healers who believe in faith-based therapies… but my faith may not be enough. I can go back to any past, but science is too indifferent. I don’t want to die at its hands.
|
||||||
|
|
||||||
|
At that moment, just like the music, the darkness both inside and outside gradually merged into one. They flowed towards each other, blending together, becoming indistinguishable from one another. The differences were erased, leaving only purity behind. There was no space between them, no sound to be heard. This encounter brought all things back into unity.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
title: "Veil"
|
||||||
|
pubDate: 2026-03-09
|
||||||
|
category: reading
|
||||||
|
tags: [read]
|
||||||
|
mood: '#7c3aed'
|
||||||
|
---
|
||||||
|
|
||||||
|
****Under the Veil: Ideal Martyrs and Real Survivors****
|
||||||
|
|
||||||
|
### You cover others’ eyes with veils, but it’s difficult for you to see the world as it really is.
|
||||||
|
|
||||||
|
This is a sentence that the translator wrote in the afterword. It truly reflects the truth hidden within the book “Veil” and its title. People live under a veil, using it to hide from others’ scrutiny, but in doing so, they end up trapped within their own veil.
|
||||||
|
|
||||||
|
It seems that Walter encountered the most tender love and the deepest sorrowful relationships. He loved someone who did not love him back, and in the process, he became trapped in a vortex of emotions, unable to escape. He was wise, with a keen insight into the world, society, and human nature. He understood the hardships of the world, the various appearances of society, and the underlying truths. He knew the true nature of people behind their false appearances. He seemed like a holy figure, yet he also had flawed and "malicious" thoughts. Even until his death, he couldn’t break free from the emotional vortex. In my opinion, Walter’s love for Katie was genuine, and his hatred for betrayal was also sincere. But love always remained with him throughout his life. Before going to Meitan Prefecture, he was a calm observer, an embodiment of reason. He had a precise understanding of truth, and he had his own moral standards and beliefs that could not be violated. These were indelible marks of reason. When he realized the betrayal and rushed to Meitan Prefecture, he was swept away by emotions. He loved and hated at the same time. He hated Katie for betraying him, and he hated himself for falling in love with a lover who went against his principles. At first, he wanted Katie to die as well, but in the end, he forced her to leave. It seemed like he had some relief from marriage and this relationship, but in reality, he never managed to cross the emotional barriers he had built for himself. He was contradictory. Until his death, he couldn’t forgive himself. As he wrote in his last message to the world: “It was a dog that died.” This wasn’t just a metaphor for Katie; it was also his ultimate judgment on his own soul. He finally admitted that the person filled with resentment, who tried to use love to destroy others, was actually the “crazy dog” devoured by hatred. Walter’s life was one of using sharp reason to analyze the world, but he never dared to face the desolate ruins within himself. He died of cholera, but actually, he died under the veil of arrogance and prejudice that could never be removed.
|
||||||
|
|
||||||
|
In contrast, Katie was lucky, and she finally achieved freedom. Under the mask of vanity, what was hidden were the cruelty of the world, the complexity of society, and the unpredictability of human nature. Her initial desire for vanity led her to marry Walter, only to discover that life after marriage wasn’t as she hoped. From the beginning, she knew she didn’t love Walter at all. So, when she arrived in Hong Kong and met Charlie, she fell for him. In Charlie, she saw the qualities she had hoped to find in a partner—humor, charm, and sexiness. But she ignored the true nature of human beings. Before going to Meitan Prefecture, she was vain, afraid, and worried that Walter would discover everything. She was weak, wanting to escape this marriage and be close to Charlie, but she didn’t dare to take action. When Walter forced her to face reality, when everything came to light, she finally saw the evil in human nature. She witnessed Charlie’s indifference and his so-called love being nothing more than empty words. She truly felt fear—fear of death, fear of Walter’s unpredictable thoughts. During her time in Meitan Prefecture, she still lived in memories of the past. She was afraid, unable to see the future. But the temple illuminated by holy light stood still on the other side of the river. She longed for those enchanting colors, eager to start anew. When she took the first step and began working in the monastery, her journey towards awakening began. She slowly reconciled with herself, became less critical of betrayal, and began to understand Charlie better. She started to shed her illusions and recognize her own weakness. She sought the meaning of life, understanding everything around her, even hoping to help Walter out of his suffering. However, until Walter died, they never truly reconciled. She felt guilty toward Walter, seeing her own insignificance. But she couldn’t cross the high wall erected by Walter. She was outside the wall, while Walter remained inside, each living their lives under their own masks, suffering separately. Walter’s death was a kind of “liberation” for him, and also for Katie.
|
||||||
|
|
||||||
|
Walter died because he failed to remove the “veil of rationality and morality”. He destroyed himself while still conscious, leaving behind only a tragic silhouette for the world to see. Meanwhile, Katie survived the disaster. She stripped off the “veil of vanity” during the chaos, and though covered in wounds, she finally saw the true light of day.
|
||||||
|
|
||||||
|
The so-called “veil” not only covers up the truth, but also the courage we need to face our true selves. Walter lacked this courage to accept imperfect love and human nature, and thus he perished in his own despair. Katie, on the other hand, was forced to develop this courage during a life-and-death moment. Thus, she gained freedom.
|
||||||
|
|
||||||
|
In the end, those who died find peace. Those who remain live on, carrying with them memories and lessons from their experiences. Perhaps this is what Maugham wanted to tell us: There is no perfect ending to life. Only by facing the cruel reality of life and still choosing to love and live truly, can one find redemption.
|
||||||
|
|
||||||
|
From betrayal to self-redemption, Katie found herself in a foreign land far from her hometown. She couldn’t escape the temptations of desire. After returning to Hong Kong, she once again fell into Charlie’s arms. But now, she was no longer the same person she was before leaving Meitan Prefecture. Her thoughts had undergone drastic changes. She despised the idea of her body betraying her soul. She decided to leave this place of chaos and pursue freedom once more. Just as she left Meitan Prefecture, she embarked on a new journey toward freedom. Back in her homeland, she saw her mother pass away, her sister become pregnant, and her father finally free himself from his old self. She also wanted to get rid of her old self and embrace a new future. At the end of the story, Katie and her father shared their hearts with each other. Together, they headed to a new place to start anew, to live as a new person.
|
||||||
|
|
||||||
|
### I have hope, and I also have the courage to pursue it.
|
||||||
|
|
||||||
|
Awakening is not a momentary miracle, but rather a long and often frustrating struggle. She will fall, repeat the same mistakes, and struggle in the mire of desire. But true courage lies not in maintaining purity without ever making mistakes, but in having the courage to tear off one's mask after seeing one's own ugliness, and turn towards light.
|
||||||
|
|
||||||
|
Walter died in his "correctness," while Katie lived in her "truth." Walter's story is one of tragic individualism and sacrifice, while Katie's story is one of fortunate individualism and rebirth. Walter felt hopeless, trapped in the mud of hardship, and lacked the courage to face a new life and a new world. He hid himself behind his mask, living in a world built upon his own beliefs, dying "heroically" for them. Katie, on the other hand, lives in a complex society, discovering the true self through introspection, and gaining freedom from a difficult situation. Step by step, she breaks through the false mask to see human nature, the warmth and coldness of society, and the hope of a new world. Her experiences in Meitan Prefecture have led her to self-reflection, and her journey there has given her insights into everything. In death and darkness, she has gained enlightenment and awakened to light. She returned to Hong Kong, where desire once again took hold. This time, she did not sink into the false reality. Finally, with courage, she made her decision and set out on the journey of "leaving."
|
||||||
|
|
||||||
|
At the end of the story, the mask is removed. Katie, with scars on her body, holds her father's hand, walking towards the sunshine of Bahamas. There, there is no false grandeur, no illusions of passion, only a simple yet solid life. Perhaps this is the most gentle answer that James Mangan left for those who struggle between vanity and confusion: Only truth can bring freedom.
|
||||||
|
|
||||||
|
In the story of The Mask, Walter is a highly "heroic" individual who sacrifices himself for his beliefs. Katie, on the other hand, grows up in a complex society, spending her early years amidst desires and vanity. Her journey to Meitan Prefecture brings her clarity in darkness, hope in death, and the true self in a foreign land. She gains freedom in the "new world."
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
- I don’t understand you at all. I really don’t know you at all.
|
||||||
|
|
||||||
|
- I know you are stupid, frivolous, and lacking in intelligence, but I love you. I know your goals and ideals are both vulgar and ordinary, but I still love you. I know you are of second rate quality, but I still love you. It’s funny to think that I try my best to like things that make you happy, and I endure pain just to hide my feelings from you. In fact, I am not ignorant or vulgar, nor do I spread rumors or act foolishly. I know how afraid you are of wisdom, so I do everything I can to make you think I’m a complete idiot, just like everyone else you know. I know you married me just for temporary benefits. I loved you so much that it didn’t matter to me. Most people, as far as I know, feel wronged and even more angry and painful when they love someone but don’t receive love in return. But I’m not like that. I never expected you to love me. I see no reason for you to love me, and I never thought I would be loved in return. I’m grateful to be allowed to love you. When I remember you happily being with me, or when I see the joy in your eyes, I become overjoyed. I try not to let my love bother you, because I know it would be too much for me. So I always watch your reactions, looking for the first signs that my love starts to annoy you. Most husbands see this as a form of power, but I see it as a blessing.
|
||||||
|
|
||||||
|
- He knows that you are vain, cowardly, and selfish. He wants me to see all of this with my own eyes. He knows that you will run away as quickly as a hare when danger approaches. He knows that I was deceived into believing that you loved me, because he knows that you never love anyone else—you only love yourself. He knows that you would sacrifice me in order to escape unharmed.
|
||||||
|
|
||||||
|
- Death comes suddenly and mercilessly, passing through the winding streets.
|
||||||
|
|
||||||
|
- Dawn breaks, and sunlight touches the mist, making it shine like snow descending upon the dying stars.
|
||||||
|
|
||||||
|
- Look, here only you and I are walking on solid ground. The nuns walk in the sky, while your husband walks in the darkness.
|
||||||
|
|
||||||
|
- His soul is torn apart. He has lived in a false illusion. When the truth shatters those illusions, he believes that reality itself has been shattered.
|
||||||
|
|
||||||
|
- At this hour, the streets of the city are empty, more like a dead city than ever before. Passersby look distant and mysterious, making one wonder if they are all ghosts. The sky is clear and cloudless, and the rising sun shines a gentle light over the earth. It’s hard to imagine that, in this joyful, fresh, and bright dawn, the city is struggling to survive under the dark reign of plague. It’s unbelievable that nature (the blue sky, so clear like a child’s heart) remains indifferent during such times.
|
||||||
|
|
||||||
|
- Peace exists only in people’s souls.
|
||||||
|
|
||||||
|
- All people, even humanity as a whole, are like droplets in this river. They flow unpredictably, getting closer together yet still far apart, forming an unnamed current that flows towards the sea. Since everything is fleeting, and nothing matters truly, people still foolishly value trivial things, causing themselves and others to suffer. This is truly sad.
|
||||||
|
|
||||||
|
- Those tiny droplets flow silently along the river, towards the unknown. They seem unique, but to the observer, they are just insignificant parts of the river.
|
||||||
|
|
||||||
|
- What do I understand now? Life is so strange. I feel like someone who has lived all his life near a small pond, suddenly seeing the vast ocean. It makes me feel breathless, but my heart is filled with joy. I don’t want to die; I want to live. So I feel a new courage. I’m like those old sailors, setting sail toward unexplored seas. My soul thirsts for the unknown.
|
||||||
|
|
||||||
|
- Those tiny droplets flow silently, towards that dark, eternal ocean.
|
||||||
|
|
||||||
|
- Path. Some of us seek the path in opium; others look to God. Still others search for whiskey, or try to find love elsewhere. There is only one such path, but it leads nowhere.
|
||||||
|
|
||||||
|
Everything here is both novel and touching. Her soul is like a calm yet dark ocean, with waves rising and falling in a magnificent display of beauty. The atmosphere is somber and overwhelming, making it seem daunting. Suddenly, a ray of sunlight shines through, bringing life, warmth, and joy to everything around her.
|
||||||
|
|
||||||
|
Life is so short. The world is already filled with suffering, yet people still choose to torment themselves. Isn’t that quite pitiful?
|
||||||
|
|
||||||
|
Her personality is like a rural landscape. At first glance, it seems vast and indifferent, but soon, you will discover villages hidden among the trees, along with lush grasslands where rivers flow gently. This beautiful scenery can be surprising, even comforting and reassuring. However, the distant yellow-brown hillsides, with their strong winds, still make it difficult for one to feel truly at ease.
|
||||||
|
|
||||||
|
There’s only one way to win people’s hearts—by becoming someone that people truly want to love.
|
||||||
|
|
||||||
|
I can’t put it into words. I don’t know if the pursuit of beauty is really that important, or if this very way of life itself is already beautiful enough. I think the only thing that allows us to focus on the world we live in without feeling resentment is the beauty that humans continuously create from chaos. The paintings they paint, the music they compose, the books they write… and their lives in general. Among all these things, what is most beautiful is a wonderful life—it’s a perfect work of art.
|
||||||
|
|
||||||
|
The “Tao” refers to both the paths that people walk along, and the beings who walk those paths. It is an eternal path, along which all living things move. Yet it wasn’t created by any living being—it exists inherently as part of life itself. It is everything at the same time, yet it isn’t anything in particular. All things originate from the Tao, conform to its principles, and ultimately return to the Tao. It is a square shape with no four corners; it is a sound that remains unheard; it is a picture without any definite shape. It is a vast net, its meshes so wide they encompass the entire ocean, yet nothing can pass through them. It serves as a refuge for all things in need of shelter. It cannot be found anywhere, but one can “see the Tao” if they refrain from seeking it. The Tao teaches us to have no desires at all, allowing everything to happen naturally. Those who are humble will ultimately achieve preservation, while those who bow down will stand upright. “Misfortune is the harbinger of fortune; fortune lies hidden beneath misfortune.” But who can say when a turning point will occur? Those who seek gentleness and compassion will be as peaceful as children. Gentleness and compassion bring victory to those who attack, and security to those who defend. Those who conquer themselves are the strongest.
|
||||||
|
|
||||||
|
– Freedom! That was the thought that constantly lingered in her mind. Although the future remained uncertain, this idea resembled a thin mist over a river, shining with colors under the sunlight of dawn. Freedom! It meant not only breaking free from the bonds of troubles and the oppressive relationships that weighed down her spirit. Freedom! It meant escaping not only the threat of death, but also from love that weakened her personality, from all the mental constraints that bound her spirit. Freedom meant a liberation from the physical body, a freedom for the spirit to emerge. Along with freedom came courage, and a strong will to ignore whatever might happen.
|
||||||
|
|
||||||
|
She thought she had changed, that her will was strong enough to allow her to return to Hong Kong as a calm and composed woman. New ideas flashed through her mind, like yellow butterflies dancing in the sunlight, filling her with hope for a better future. Freedom seemed to be calling to her, like an elf of light. The entire world seemed like a vast plain, where she could walk forward with confidence. She believed she had freed herself from lust and vile emotions, and could now live a pure and healthy spiritual life. She compared herself to herons flying gracefully over the rice fields at dusk—birds that soar freely in their tranquil minds. But in reality, she remained a slave to desire.
|
||||||
|
|
||||||
|
- I have hope, and I also have the courage to pursue it.
|
||||||
|
|
||||||
|
- You cover others’ eyes with veils, yet you yourself find it difficult to see the truth of the world.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: "Quagmire"
|
||||||
|
pubDate: 2026-02-09
|
||||||
|
category: reading
|
||||||
|
tags: [read]
|
||||||
|
mood: '#166534'
|
||||||
|
---
|
||||||
|
|
||||||
|
When lost in the darkness of the night, it’s advisable to look up at the stars. If one doesn’t know what to believe, then one must face their own conscience.
|
||||||
|
|
||||||
|
The history, as viscous as a mud pit, carries along all the creatures of the world. Amidst the torrents of time and the quagmire of social development, Hengling chose to commit suicide. Hengfeng was killed by stray bullets during his escape. Hengyu left this world peacefully after being redeemed. Guan Zhongqing chose to endure suffering in the aftermath of a fire. Father Matthew De, meanwhile, sought redemption and self-liberation amidst the chaos of time. In the end, he settled down in a foreign land.
|
||||||
|
|
||||||
|
The story begins with Father Matthew arriving in Wuhan from Belgium, aiming to save the poor people who were suffering during those turbulent times. Meanwhile, Guan Zhongqing went to Japan to study and participate in revolutionary struggles, hoping to save his country from ruin. As Guan Zhongqing returned to China and continued his revolutionary efforts, Hengling took his own life at the sound of a gunshot. From that moment on, the fates of Heng’s family, Guan Zhongqing, and Father Matthew became intertwined. The fateful spiral continued, and people began to struggle to survive in that treacherous situation.
|
||||||
|
|
||||||
|
After his father's death, Hengfeng was trapped in the mire of life. He embarked on a journey to find his sister, Hengyu. But everything seemed like something from another time and space—so close, yet so distant. Throughout his life, Hengfeng failed to find his sister at the last moment of his existence. Instead, due to his identity as a member of the Qi people, he was shot to death by a group of soldiers. His death was inevitable, an inevitable outcome of historical development. He was drowned in the darkness by the torrent of history. In the final moments of his life, during his conversation with Father Ma, Hengfeng reflected on his life and the sins that he could never forget. He broke free from his weak self, became brave at that moment, and stepped out of the mire. Guided by conscience, his heart headed towards light. His fate was a vivid portrayal of the transition between the old society and the new era. Dragged along by the currents of fate, he faced uncertainty and confusion. He had committed evil deeds, but there was also a trace of kindness in him. He was afraid, he was weak. Only in the last moments of his life did he become brave, trying to gain inner peace and redemption through his last shred of conscience. He anticipated his own death. In the final moments of his life, he stepped out of the mire. He felt guilty, but he bravely walked into the endless darkness.
|
||||||
|
|
||||||
|
Guan Zhongqing, as one of the most cultured and knowledgeable people of his time, represented the revolutionaries of the new era. The old era was too backward; struggle and revolution were the only ways to embrace the dawn. He dared to sacrifice his life for this bright future. But indeed, as a revolutionary of the new era, he still found himself trapped in the mire of time, being carried forward by the tide of history. Amid the debates among various revolutionary factions and the competition from multiple forces, he found himself trapped within a circle of new powers, each with their own motives and filled with deceit and cunning. As he was swept along by the current of history, he was forced to do so, yet he remained steadfast in his beliefs and conscience. He always upheld the belief in struggle, driving the progress of the revolution forward. He had kindness in his heart, and driven by his conscience, he saved Heng Yu, who was trapped in the mud of life and misfortune. Guan Zhongqing was complex, yet he also had a simple side.
|
||||||
|
|
||||||
|
Father Mathew was a witness and participant in all the events that occurred. He had an insight into the entire course of events. As a priest who sought to save others, he struggled in this mire of history, in the quagmire of human nature. He hesitated, and at times, he even felt hatred. When everything became history, he also found redemption within himself through his own actions.
|
||||||
|
|
||||||
|
The quagmire is the quagmire of time, the quagmire of life, the quagmire of complex human nature. Beneath the quagmire lies endless darkness, while above it stretches the vast starry sky. Believe in kindness, believe in conscience, believe in faith… and step out of this quagmire on one’s own.
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
- When lost in the darkness of the night, it’s worth looking up at the stars. If one doesn’t know what to believe, then one must face their own conscience.
|
||||||
|
|
||||||
|
Once the window was opened, everything from the outside rushed in. On that day, while reading “Xinmin Congbao”, he began to question the ideas of those who supported monarchy for the first time. Before that, like many other students, he believed that China should follow a moderate path towards constitutionalism, and that violent revolutions would lead to social chaos—France after the Great Revolution was a clear example of this. But this was the first time he felt unsure about this idea. Who exactly did the term “monarchy” refer to? Who was the cabinet actually composed of people? He remembered the braid on his head. After that day, whenever he looked at himself in the mirror or saw the braids of other students at school, he always felt a sense of discomfort. Before that day, he thought it was natural for Chinese people to have braids, just as everyone is born with a nose and two eyes. But now, he suddenly found these braids to be extremely ugly and offensive, like an unnecessary organ on a person’s body. People with braids seemed like deformed anomalies.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: "Atonement"
|
||||||
|
pubDate: 2026-02-26
|
||||||
|
category: reading
|
||||||
|
tags: [read]
|
||||||
|
mood: '#9f1239'
|
||||||
|
---
|
||||||
|
|
||||||
|
### You must come back, I will wait for you.
|
||||||
|
|
||||||
|
It was because of a person, because of a picture, because of a line of dialogue, because of reading a book.
|
||||||
|
|
||||||
|
“Dear Cecilia, our fate remains unfinished.”
|
||||||
|
|
||||||
|
Through a series of misunderstandings, an unbreakable love turned into the most absurd tragedy due to a child’s false accusation. Cecilia and Robbie’s love began in their childhood, with no secrets between them. It started with a broken vase by the pool, leading to a series of incredible events. When Robbie bravely expressed his passionate love to Cecilia for the first time, that moment determined that they would always be together forever.
|
||||||
|
|
||||||
|
Brianna’s false accusations led to Robbie’s imprisonment. But three years in prison did not extinguish the flame of their love. Being released from prison became like a lighthouse for Robbie during those three years, helping him endure the hardships and darkness. After three years, they thought they would meet again, but what awaited Robbie and Cecilia was the cruelty of World War II. After being released, Robbie rushed across the English Channel to join the war against Germany. Under the brutal war conditions, life became like imprisonment again. On the battlefield, there were constant dangers, and Robbie was always on the verge of death. Their letters kept him going, enabling him to survive on the battlefield, just for her sake. “You must come back, I will wait for you.” This witnessed their great and faithful love.
|
||||||
|
|
||||||
|
***False accusations and death cannot stop a great love.***
|
||||||
|
|
||||||
|
### There is no self in the face of war
|
||||||
|
|
||||||
|
McEwen’s skillful writing style allows readers to easily understand the cruelty of war and the insignificance and despair of individuals. In the first part, Robbie Turner is still the vibrant young man who could be called Robbie. But by the time the war begins, Robbie Turner has become just a synonym for “Turner,” not the Robbie who had his own identity. In war, he has no choice but to continue fleeing, one defeat after another.
|
||||||
|
|
||||||
|
During his escape, he witnessed everything becoming fragmented in the face of war. Even when he chose to follow his conscience at some point, he couldn’t change reality. He continued to see the deaths of young people and the craters left by bombs. This was a stark contrast to what he saw in the small town where he graduated. There were no clear lakes, no green lawns, no orderly streets or villages. Instead, there was only endless smoke, constant noise, and endless bloodshed, along with rotting corpses and their stench.
|
||||||
|
|
||||||
|
In war, survival becomes the only important thing, and also the only difficult thing.
|
||||||
|
|
||||||
|
### Atonement
|
||||||
|
|
||||||
|
If war hadn’t come, perhaps all this suffering would have been caused by Brianna’s selfish “adult thoughts”. For a 13-year-old girl, could she truly understand what love really means? What lies behind all these things she sees?
|
||||||
|
|
||||||
|
*I am old, silent, and too attached to my remaining life. I face a wave of forgetting, followed by permanent oblivion... Love never ends. As long as my last manuscript remains, my pure and unique sister and her prince will love each other forever... I believe that bringing the lovers in my novels together is not a sign of weakness or avoidance, but rather a great act of kindness, a resistance against forgetfulness and despair. I gave them happiness, but it wasn’t out of selfishness. I hope they will forgive me.*
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
- But what does “guilt” mean in this era? That question doesn’t make sense anymore. Everyone is guilty or innocent. No one can be saved just because of one testimony. There aren’t enough people, enough pens and paper, enough peace and patience to record all the testimonies and collect all the sins committed by everyone. Did you not kill anyone today? But how many people’s deaths did you turn a blind eye to?
|
||||||
|
|
||||||
|
- The river flows with spring tides, towards the sea between blue and green colors.
|
||||||
|
|
||||||
|
- I am old, silent, and too attached to my remaining life. I face a wave of forgetting, followed by permanent oblivion... Love never ends. As long as my last manuscript remains, my pure and unique sister and her prince will love each other forever... I believe that bringing the lovers in my novels together is not a sign of weakness or avoidance, but rather a great act of kindness, a resistance against forgetfulness and despair. I gave them happiness, but it wasn’t out of selfishness. It’s not like that, it’s not even that bad.
|
||||||
|
|
||||||
|
- After our hardships, love begins to grow.
|
||||||
|
|
||||||
|
- You must come back, I will wait for you.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,221 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""腾讯云 TMT (机器翻译) TC3-HMAC-SHA256 签名客户端。
|
||||||
|
|
||||||
|
零第三方依赖(hashlib/hmac/urllib),供 translate_posts.py 编排调用;
|
||||||
|
--selftest 用官方签名文档 (cloud.tencent.com/document/api/213/30654) 公布的
|
||||||
|
cvm 示例中间量做逐字节断言,并打印 tmt 请求的完整签名链中间量(固定假密钥,
|
||||||
|
不触网),供与官方 API Explorer「签名串生成」交叉比对。
|
||||||
|
|
||||||
|
已知约束:单请求 SourceText 上限 2000 字符,分块由编排器负责。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
HOST = "tmt.tencentcloudapi.com"
|
||||||
|
SERVICE = "tmt"
|
||||||
|
ACTION = "TextTranslate"
|
||||||
|
VERSION = "2018-03-21"
|
||||||
|
REGION = "ap-guangzhou" # TMT 为全球服务;该头不参与签名,仅路由用
|
||||||
|
CT = "application/json; charset=utf-8"
|
||||||
|
SIGNED_HEADERS = "content-type;host" # 官方要求的最低签名头集合
|
||||||
|
SOURCE_TEXT_LIMIT = 2000
|
||||||
|
TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_hex(s: str) -> str:
|
||||||
|
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _hmac(key: bytes, msg: str) -> bytes:
|
||||||
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign(secret_id: str, secret_key: str, action: str, service: str, host: str,
|
||||||
|
payload: str, timestamp: int,
|
||||||
|
extra_signed_headers: dict[str, str] | None = None) -> tuple[dict, dict]:
|
||||||
|
"""按 TC3 规范签名,返回 (请求头, 中间量字典)。
|
||||||
|
|
||||||
|
extra_signed_headers: 除 content-type/host 外追加参与签名的头(key 小写),
|
||||||
|
仅供 selftest 复现官方文档示例;正常调用不传。
|
||||||
|
中间量字典含 canonical_request / string_to_sign / signature 等,
|
||||||
|
供 selftest 展示;正常调用方可忽略。
|
||||||
|
"""
|
||||||
|
date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
# Date 必须由时间戳换算 UTC 日期:带本地时区会在跨日时段必然签名失败(官方文档陷阱)
|
||||||
|
credential_scope = f"{date}/{service}/tc3_request"
|
||||||
|
|
||||||
|
signed = {"content-type": CT, "host": host}
|
||||||
|
if extra_signed_headers:
|
||||||
|
signed.update({k.lower(): v for k, v in extra_signed_headers.items()})
|
||||||
|
signed_headers = ";".join(sorted(signed))
|
||||||
|
# 头按 key 的 ASCII 升序拼接,每行 key:value\n 结尾(官方拼接规则)
|
||||||
|
canonical_headers = "".join(f"{k}:{v}\n" for k, v in sorted(signed.items()))
|
||||||
|
hashed_payload = _sha256_hex(payload)
|
||||||
|
canonical_request = (
|
||||||
|
f"POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}"
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed_canonical_request = _sha256_hex(canonical_request)
|
||||||
|
string_to_sign = (
|
||||||
|
f"TC3-HMAC-SHA256\n{timestamp}\n{credential_scope}\n{hashed_canonical_request}"
|
||||||
|
)
|
||||||
|
|
||||||
|
secret_date = _hmac(f"TC3{secret_key}".encode("utf-8"), date)
|
||||||
|
secret_service = _hmac(secret_date, service)
|
||||||
|
secret_signing = _hmac(secret_service, "tc3_request")
|
||||||
|
signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
authorization = (
|
||||||
|
f"TC3-HMAC-SHA256 Credential={secret_id}/{credential_scope}, "
|
||||||
|
f"SignedHeaders={SIGNED_HEADERS}, Signature={signature}"
|
||||||
|
)
|
||||||
|
headers = {
|
||||||
|
"Authorization": authorization,
|
||||||
|
"Content-Type": CT,
|
||||||
|
"Host": host,
|
||||||
|
"X-TC-Action": action,
|
||||||
|
"X-TC-Timestamp": str(timestamp),
|
||||||
|
"X-TC-Version": VERSION,
|
||||||
|
"X-TC-Region": REGION,
|
||||||
|
}
|
||||||
|
debug = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"date": date,
|
||||||
|
"credential_scope": credential_scope,
|
||||||
|
"canonical_request": canonical_request,
|
||||||
|
"hashed_request_payload": hashed_payload,
|
||||||
|
"hashed_canonical_request": hashed_canonical_request,
|
||||||
|
"string_to_sign": string_to_sign,
|
||||||
|
"secret_date_hex": secret_date.hex(),
|
||||||
|
"secret_service_hex": secret_service.hex(),
|
||||||
|
"secret_signing_hex": secret_signing.hex(),
|
||||||
|
"signature": signature,
|
||||||
|
"authorization": authorization,
|
||||||
|
}
|
||||||
|
return headers, debug
|
||||||
|
|
||||||
|
|
||||||
|
def translate(secret_id: str, secret_key: str, text: str,
|
||||||
|
source: str = "zh", target: str = "en",
|
||||||
|
retries: int = 3) -> str:
|
||||||
|
"""调用 TextTranslate 并重试(指数退避 1/2/4s),返回 TargetText。
|
||||||
|
|
||||||
|
网络异常、HTTP 非 2xx、响应含 Response.Error 均触发重试;
|
||||||
|
耗尽后抛 RuntimeError(调用方按篇捕获,不阻断其它篇)。
|
||||||
|
"""
|
||||||
|
if len(text) > SOURCE_TEXT_LIMIT:
|
||||||
|
raise ValueError(f"SourceText 超限: {len(text)} > {SOURCE_TEXT_LIMIT}")
|
||||||
|
|
||||||
|
payload = json.dumps({"Source": source, "Target": target,
|
||||||
|
"ProjectId": 0, "SourceText": text})
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for attempt in range(retries):
|
||||||
|
if attempt:
|
||||||
|
time.sleep(2 ** (attempt - 1))
|
||||||
|
try:
|
||||||
|
headers, _ = sign(secret_id, secret_key, ACTION, SERVICE, HOST,
|
||||||
|
payload, int(time.time()))
|
||||||
|
req = urllib.request.Request(f"https://{HOST}/", data=payload.encode("utf-8"),
|
||||||
|
headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
if "Error" in body.get("Response", {}):
|
||||||
|
err = body["Response"]["Error"]
|
||||||
|
raise RuntimeError(f"TMT {err.get('Code')}: {err.get('Message')}")
|
||||||
|
return body["Response"]["TargetText"]
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError,
|
||||||
|
RuntimeError, KeyError, ValueError) as e:
|
||||||
|
last_err = e
|
||||||
|
raise RuntimeError(f"重试 {retries} 次仍失败: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- selftest:官方文档公布值断言 + 假密钥全链路展示(不触网) ----
|
||||||
|
|
||||||
|
# 官方签名文档 cvm 示例的输入与公布中间量(密钥打码故仅断言无密钥依赖的三组量)
|
||||||
|
_DOC_PAYLOAD = ('{"Limit": 1, "Filters": [{"Values": ["\\u672a\\u547d\\u540d"], '
|
||||||
|
'"Name": "instance-name"}]}')
|
||||||
|
_DOC_HASHED_PAYLOAD = "35e9c5b0e3ae67532d3c9f17ead6c90222632e5b1ff7f6e89887f1398934f064"
|
||||||
|
_DOC_CANONICAL_REQUEST = (
|
||||||
|
"POST\n/\n\n"
|
||||||
|
"content-type:application/json; charset=utf-8\n"
|
||||||
|
"host:cvm.tencentcloudapi.com\n"
|
||||||
|
"x-tc-action:describeinstances\n"
|
||||||
|
"\n"
|
||||||
|
"content-type;host;x-tc-action\n"
|
||||||
|
+ _DOC_HASHED_PAYLOAD
|
||||||
|
)
|
||||||
|
_DOC_HASHED_CANONICAL = "7019a55be8395899b900fb5564e4200d984910f34794a27cb3fb7d10ff6a1e84"
|
||||||
|
_DOC_STRING_TO_SIGN = (
|
||||||
|
"TC3-HMAC-SHA256\n1551113065\n2019-02-25/cvm/tc3_request\n" + _DOC_HASHED_CANONICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def selftest() -> int:
|
||||||
|
"""断言签名拼接与官方文档示例一致;失败 exit 1,全过 exit 0。"""
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
# 官方签名文档 cvm 示例(三头规格)直接走 sign() 代码路径,逐字节复现规范串
|
||||||
|
_, doc_debug = sign("AKIDDemo", "DemoKey", "DescribeInstances", "cvm",
|
||||||
|
"cvm.tencentcloudapi.com", _DOC_PAYLOAD, 1551113065,
|
||||||
|
extra_signed_headers={"x-tc-action": "describeinstances"})
|
||||||
|
if doc_debug["canonical_request"] != _DOC_CANONICAL_REQUEST:
|
||||||
|
failures.append(f"sign() 生成的 CanonicalRequest 与文档不符:\n{doc_debug['canonical_request']!r}")
|
||||||
|
|
||||||
|
hp = _sha256_hex(_DOC_PAYLOAD)
|
||||||
|
if hp != _DOC_HASHED_PAYLOAD:
|
||||||
|
failures.append(f"HashedRequestPayload 不符: {hp}")
|
||||||
|
|
||||||
|
hcr = _sha256_hex(doc_debug["canonical_request"])
|
||||||
|
if hcr != _DOC_HASHED_CANONICAL:
|
||||||
|
failures.append(f"HashedCanonicalRequest 不符: {hcr}")
|
||||||
|
|
||||||
|
sts = f"TC3-HMAC-SHA256\n1551113065\n2019-02-25/cvm/tc3_request\n{hcr}"
|
||||||
|
if sts != _DOC_STRING_TO_SIGN:
|
||||||
|
failures.append(f"StringToSign 不符: {sts!r}")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("[SELFTEST-FAIL]")
|
||||||
|
for f in failures:
|
||||||
|
print(" -", f)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("[SELFTEST-PASS] 官方文档 cvm 示例中间量逐字节一致:"
|
||||||
|
"HashedRequestPayload / HashedCanonicalRequest / StringToSign")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 固定假密钥 + tmt 真实参数,打印全链路中间量(可与官方 API Explorer 比对)
|
||||||
|
fake_id, fake_key = "AKIDFAKEexample0000000000000000000000", "FakeSecretKeyDoNotUse00000"
|
||||||
|
payload = json.dumps({"Source": "zh", "Target": "en", "ProjectId": 0,
|
||||||
|
"SourceText": "你好,世界"}, ensure_ascii=False)
|
||||||
|
headers, debug = sign(fake_id, fake_key, ACTION, SERVICE, HOST, payload, 1551113065)
|
||||||
|
print("[TMT-REQUEST 签名链中间量](假密钥,供与官方 API Explorer 比对)")
|
||||||
|
for k in ("timestamp", "date", "credential_scope", "canonical_request",
|
||||||
|
"hashed_request_payload", "hashed_canonical_request", "string_to_sign",
|
||||||
|
"secret_date_hex", "secret_service_hex", "secret_signing_hex",
|
||||||
|
"signature", "authorization"):
|
||||||
|
print(f"--- {k} ---")
|
||||||
|
print(debug[k])
|
||||||
|
print()
|
||||||
|
print("--- 实际发送 HTTP 头 ---")
|
||||||
|
for k, v in headers.items():
|
||||||
|
print(f"{k}: {v}")
|
||||||
|
print(f"(body) {payload}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--selftest" in sys.argv:
|
||||||
|
sys.exit(selftest())
|
||||||
|
print("用法: python3 tencent_tmt.py --selftest;正常使用经 translate_posts.py 编排",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""中文博客文章批量翻译编排器(腾讯 TMT,zh → en)。
|
||||||
|
|
||||||
|
content/posts/*.md(zh 源,只读)→ 译文写入 content/posts-en/*.md。
|
||||||
|
幂等依据 content/posts-en/.translation-state.json 记录的源内容 sha256:
|
||||||
|
未变且目标存在即跳过(零 API 调用);目标 frontmatter 含 locked: true 时
|
||||||
|
无条件跳过(人工润色保护,--force 亦不覆盖)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 scripts/translate/translate_posts.py --dry-run # 只打印计划,零网络
|
||||||
|
python3 scripts/translate/translate_posts.py # 真实翻译
|
||||||
|
python3 scripts/translate/translate_posts.py --force # 忽略哈希重译
|
||||||
|
|
||||||
|
已知局限:代码块/行内代码不做保护,TMT 可能改写其中内容——需保护的文章在
|
||||||
|
译文 frontmatter 加 locked: true 人工润色。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
POSTS_DIR = REPO_ROOT / "content" / "posts"
|
||||||
|
OUT_DIR = REPO_ROOT / "content" / "posts-en"
|
||||||
|
STATE_FILE = OUT_DIR / ".translation-state.json"
|
||||||
|
# TMT 单请求 SourceText 硬限 2000 字符,聚合打包以此为上限(含段间 \n\n 连接符)
|
||||||
|
CHUNK_LIMIT = 2000
|
||||||
|
CREDENTIALS_FILE = Path.home() / "docker_apps" / "blog" / "tencent-translate-credentials.txt"
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import tencent_tmt # noqa: E402
|
||||||
|
|
||||||
|
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.S)
|
||||||
|
_TITLE_RE = re.compile(r'^(title:\s*)(["\']?)(.+?)\2\s*$', re.M)
|
||||||
|
_LOCKED_RE = re.compile(r"^locked:\s*true\s*$", re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def load_credentials() -> tuple[str, str]:
|
||||||
|
"""解析凭据文件,返回 (secret_id, secret_key)。
|
||||||
|
|
||||||
|
文件实际标签拼写为 SecertId/SecertKey(拼写如此,勿"修正"解析器),
|
||||||
|
兼容 Secret 正拼与 : 或 = 分隔。值只进内存,不打印不入日志。
|
||||||
|
"""
|
||||||
|
text = CREDENTIALS_FILE.read_text(encoding="utf-8")
|
||||||
|
creds: dict[str, str] = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = re.match(r"^\s*Se[cr]e?rt[_ ]?(id|key)\s*[:=]\s*(\S+)\s*$",
|
||||||
|
line, re.I)
|
||||||
|
if m:
|
||||||
|
creds[m.group(1).lower()] = m.group(2)
|
||||||
|
missing = [k for k in ("id", "key") if k not in creds]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"凭据文件缺字段 {missing}: {CREDENTIALS_FILE}")
|
||||||
|
return creds["id"], creds["key"]
|
||||||
|
|
||||||
|
|
||||||
|
def split_post(text: str) -> tuple[str, str]:
|
||||||
|
"""分离 frontmatter 与正文;返回 (frontmatter原文, 正文原文)。
|
||||||
|
|
||||||
|
透传策略要求 frontmatter 除 title 外字节级保留,故不做 YAML 反序列化。
|
||||||
|
"""
|
||||||
|
m = _FRONTMATTER_RE.match(text)
|
||||||
|
if not m:
|
||||||
|
raise ValueError("frontmatter 缺失或格式不符")
|
||||||
|
return m.group(1), m.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked(path: Path) -> bool:
|
||||||
|
"""目标文件 frontmatter 含 locked: true 即视为人工润色锁定。"""
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
m = _FRONTMATTER_RE.match(path.read_text(encoding="utf-8"))
|
||||||
|
return bool(m and _LOCKED_RE.search(m.group(1)))
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def pack_paragraphs(paragraphs: list[str]) -> list[list[str]]:
|
||||||
|
"""按空行段落聚合打包:相邻段以 \\n\\n 连接后不超 CHUNK_LIMIT 则同包。
|
||||||
|
|
||||||
|
单段自身超限时放弃打包(单段单独成包),由调用方在该包翻译时报错。
|
||||||
|
"""
|
||||||
|
packs: list[list[str]] = []
|
||||||
|
current: list[str] = []
|
||||||
|
for para in paragraphs:
|
||||||
|
candidate = current + [para]
|
||||||
|
if current and len("\n\n".join(candidate)) > CHUNK_LIMIT:
|
||||||
|
packs.append(current)
|
||||||
|
current = [para]
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
if current:
|
||||||
|
packs.append(current)
|
||||||
|
return packs
|
||||||
|
|
||||||
|
|
||||||
|
def translate_pack(sid: str, skey: str, joined: str, depth: int = 0) -> str:
|
||||||
|
"""翻译一个包文本;InternalError 或译文异常短时按段边界二分降级。
|
||||||
|
|
||||||
|
中→英实测译文约为源 3 倍字符量,ratio < 0.3 判为服务端静默截断
|
||||||
|
(曾观测 1911 字符包只译回 7 字符);二分在段边界处切,最多 5 层,
|
||||||
|
切无可切抛异常由编排层记篇失败。
|
||||||
|
"""
|
||||||
|
# 静默截断多为偶发,先原样重试一次再考虑二分
|
||||||
|
for _ in range(2):
|
||||||
|
try:
|
||||||
|
result = tencent_tmt.translate(sid, skey, joined)
|
||||||
|
if len(result) >= 0.3 * len(joined):
|
||||||
|
return result
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
if depth >= 4 or len(joined) < 100:
|
||||||
|
raise RuntimeError(f"包翻译异常且降级到底({len(joined)} 字符)")
|
||||||
|
mid = joined.find("\n\n", len(joined) // 2)
|
||||||
|
if mid < 0:
|
||||||
|
mid = len(joined) // 2
|
||||||
|
left, right = joined[:mid], joined[mid:].lstrip("\n")
|
||||||
|
return (translate_pack(sid, skey, left, depth + 1) + "\n\n"
|
||||||
|
+ translate_pack(sid, skey, right, depth + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def translate_body(sid: str, skey: str, body: str) -> str:
|
||||||
|
"""正文翻译:分段聚合 → 逐包翻译 → 按原段序重组。
|
||||||
|
|
||||||
|
包内译文段数与请求不符(TMT 重排了段落边界)时,该包回退为
|
||||||
|
逐段单独请求,保证段落与译文一一对应。
|
||||||
|
"""
|
||||||
|
paragraphs = body.split("\n\n")
|
||||||
|
translated: list[str] = []
|
||||||
|
for pack in pack_paragraphs(paragraphs):
|
||||||
|
result = translate_pack(sid, skey, "\n\n".join(pack))
|
||||||
|
pieces = result.split("\n\n")
|
||||||
|
if len(pieces) != len(pack):
|
||||||
|
# 段落边界失配,回退逐段请求(额外 API 调用,换取严格对齐)
|
||||||
|
pieces = [tencent_tmt.translate(sid, skey, p) for p in pack]
|
||||||
|
translated.extend(p.strip() for p in pieces)
|
||||||
|
return "\n\n".join(translated)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_title(sid: str, skey: str, title: str) -> str:
|
||||||
|
return tencent_tmt.translate(sid, skey, title).strip().strip('"')
|
||||||
|
|
||||||
|
|
||||||
|
def process_post(sid: str, skey: str, src: Path, dst: Path,
|
||||||
|
state: dict, force: bool) -> tuple[str, str]:
|
||||||
|
"""翻译单篇并落盘,返回 (决策, 说明);仅处理需翻译篇,skip 决策在 main 层。
|
||||||
|
|
||||||
|
翻译失败抛出的异常由 main 按篇捕获,不阻断其它篇。
|
||||||
|
"""
|
||||||
|
rel = f"posts/{src.name}"
|
||||||
|
raw = src.read_bytes()
|
||||||
|
sha = hashlib.sha256(raw).hexdigest()
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
|
||||||
|
if is_locked(dst):
|
||||||
|
return "skip-locked", "目标已锁定(人工润色保护)"
|
||||||
|
if not force and state.get(rel, {}).get("sha256") == sha and dst.exists():
|
||||||
|
return "skip-updated", "源未变且目标存在"
|
||||||
|
|
||||||
|
frontmatter, body = split_post(text)
|
||||||
|
tm = _TITLE_RE.search(frontmatter)
|
||||||
|
if not tm:
|
||||||
|
raise ValueError("frontmatter 无 title 字段")
|
||||||
|
|
||||||
|
# title 只送引号内文本;译文内的双引号转义后按原引号风格回填
|
||||||
|
new_title = translate_title(sid, skey, tm.group(3))
|
||||||
|
escaped = new_title.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
new_fm = _TITLE_RE.sub(lambda m: f"{m.group(1)}\"{escaped}\"", frontmatter, count=1)
|
||||||
|
|
||||||
|
new_body = translate_body(sid, skey, body)
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst.write_text(f"---\n{new_fm}\n---\n\n{new_body}".rstrip("\n") + "\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
state[rel] = {"sha256": sha}
|
||||||
|
return "translated", f"{len(text)} 字符 → {len(new_body)} 字符"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="zh→en 博客文章批量翻译(TMT)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只打印计划,零网络调用")
|
||||||
|
parser.add_argument("--force", action="store_true", help="忽略哈希强制重译(locked 仍跳过)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
posts = sorted(POSTS_DIR.glob("*.md"))
|
||||||
|
if not posts:
|
||||||
|
print(f"未发现源文章: {POSTS_DIR}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# dry-run 不触网也不需要凭据;真实模式才读取
|
||||||
|
sid = skey = ""
|
||||||
|
if not args.dry_run:
|
||||||
|
sid, skey = load_credentials()
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
print(f"{'源文章':<46} 决策 说明")
|
||||||
|
print("-" * 100)
|
||||||
|
plan = []
|
||||||
|
for src in posts:
|
||||||
|
dst = OUT_DIR / src.name
|
||||||
|
rel = f"posts/{src.name}"
|
||||||
|
raw = src.read_bytes()
|
||||||
|
sha = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
if is_locked(dst):
|
||||||
|
decision, note = "skip-locked", "目标已锁定(人工润色保护)"
|
||||||
|
elif not args.force and state.get(rel, {}).get("sha256") == sha and dst.exists():
|
||||||
|
decision, note = "skip-updated", "源未变且目标存在"
|
||||||
|
elif args.dry_run:
|
||||||
|
_, body = split_post(raw.decode("utf-8"))
|
||||||
|
packs = pack_paragraphs(body.split("\n\n"))
|
||||||
|
n_chars = len(body)
|
||||||
|
# +1 为 title 请求;正文按包计
|
||||||
|
decision, note = "translate(计划)", (
|
||||||
|
f"正文 {n_chars} 字符/{len(packs)} 包 + title,约 {len(packs) + 1} 次 API")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
decision, note = process_post(sid, skey, src, dst, state, args.force)
|
||||||
|
except Exception as e: # 单篇失败不阻断其它篇
|
||||||
|
decision, note = "failed", str(e)[:120]
|
||||||
|
failures.append(src.name)
|
||||||
|
|
||||||
|
print(f"{src.name:<46} {decision:<14} {note}")
|
||||||
|
plan.append((src.name, decision))
|
||||||
|
|
||||||
|
translated = sum(1 for _, d in plan if d == "translated")
|
||||||
|
skips = sum(1 for _, d in plan if d.startswith("skip"))
|
||||||
|
print("-" * 100)
|
||||||
|
print(f"共 {len(plan)} 篇:翻译 {translated},跳过 {skips},失败 {len(failures)};"
|
||||||
|
+ ("API 调用 0(dry-run 零网络)" if args.dry_run else ""))
|
||||||
|
|
||||||
|
# 无论是否有失败篇,成功篇的哈希都落盘:失败篇重跑时仅重译自身
|
||||||
|
if translated:
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
// AI 译文标注(用户拍板:文学内容机器翻译须明示)
|
||||||
|
import { t } from '../i18n/ui';
|
||||||
|
interface Props { locale?: 'zh' | 'en' }
|
||||||
|
const { locale = 'en' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="ai-note" role="note">{t('ai_translated_note', locale)}<span class="ai-tag">TMT</span></p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ai-note {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.ai-tag {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
color: var(--ink-4);
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
---
|
---
|
||||||
// 页脚:版权 + ICP 备案(法定保留)+ 构建透明槽位(T7 built-from 填充)
|
// 页脚:版权 + ICP 备案(法定保留)+ 构建透明槽位(T7 built-from 填充)
|
||||||
|
import { t } from '../i18n/ui';
|
||||||
|
import type { Locale } from '../lib/locale';
|
||||||
|
|
||||||
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
|
interface Props {
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
const { locale = 'zh' } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="site-footer safe-area">
|
<footer class="site-footer safe-area">
|
||||||
@@ -7,7 +15,7 @@
|
|||||||
<p class="fine">
|
<p class="fine">
|
||||||
© {new Date().getFullYear()} wait4x ·
|
© {new Date().getFullYear()} wait4x ·
|
||||||
<a href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank" class="link">
|
<a href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank" class="link">
|
||||||
陕ICP备2026014860号-1
|
{t('footer_icp', locale)}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<!-- T7 注入:built from {commit} · {time} · Astro(链到 gitea commit) -->
|
<!-- T7 注入:built from {commit} · {time} · Astro(链到 gitea commit) -->
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
import { localeOf, localePath } from '../lib/locale';
|
||||||
|
|
||||||
// 顶部导航:≤4 项纪律(防 higan 十项复发);当前页 aria-current
|
// 顶部导航:≤4 项纪律(防 higan 十项复发);当前页 aria-current
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/archives/', label: '文章' },
|
{ href: '/archives/', label: '文章' },
|
||||||
@@ -8,6 +10,11 @@ const nav = [
|
|||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isCurrent = (href: string) =>
|
const isCurrent = (href: string) =>
|
||||||
path === href || path === href.replace(/\/$/, '') || path.startsWith(href);
|
path === href || path === href.replace(/\/$/, '') || path.startsWith(href);
|
||||||
|
|
||||||
|
// 语言切换:同路径跨前缀跳转(zh 无前缀 ↔ /en/),预构建双语毫秒级
|
||||||
|
const curLocale = localeOf(path);
|
||||||
|
const switchHref = localePath(path, curLocale === 'zh' ? 'en' : 'zh');
|
||||||
|
const switchLabel = curLocale === 'zh' ? 'EN' : '中';
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="site-header safe-area">
|
<header class="site-header safe-area">
|
||||||
@@ -35,6 +42,10 @@ const isCurrent = (href: string) =>
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<!-- 语言切换:与主题钮同为触屏友好的 mono 小钮 -->
|
||||||
|
<li>
|
||||||
|
<a href={switchHref} class="tap nav-link nav-lang" hreflang={curLocale === 'zh' ? 'en' : 'zh'}>{switchLabel}</a>
|
||||||
|
</li>
|
||||||
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
|
<!-- 触屏等价物:主题可视切换(与键盘 t / ThemeScript 同一存储 key) -->
|
||||||
<li>
|
<li>
|
||||||
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
|
<button class="tap nav-link" type="button" data-theme-toggle aria-label="切换明暗">
|
||||||
@@ -97,6 +108,7 @@ const isCurrent = (href: string) =>
|
|||||||
}
|
}
|
||||||
.nav-link:hover { color: var(--ink-1); }
|
.nav-link:hover { color: var(--ink-1); }
|
||||||
.nav-link[aria-current='page'] { color: var(--accent); }
|
.nav-link[aria-current='page'] { color: var(--accent); }
|
||||||
|
.nav-lang { font-family: var(--font-mono); font-size: 13px; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.nav-link { font-size: 14px; padding-inline: var(--space-1); }
|
.nav-link { font-size: 14px; padding-inline: var(--space-1); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
---
|
---
|
||||||
// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c)
|
// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c)
|
||||||
// 入口:Header 搜索钮 + 键盘 `/`
|
// 入口:Header 搜索钮 + 键盘 `/`
|
||||||
|
import { t } from '../i18n/ui';
|
||||||
|
import type { Locale } from '../lib/locale';
|
||||||
|
|
||||||
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
|
interface Props {
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
const { locale = 'zh' } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<dialog id="search-dialog" class="search-dialog">
|
<dialog id="search-dialog" class="search-dialog" data-locale={locale}>
|
||||||
<input id="search-input" type="search" placeholder="搜索文章…(Esc 关闭)" autocomplete="off" />
|
<input id="search-input" type="search" placeholder={t('search_placeholder', locale)} autocomplete="off" />
|
||||||
<ul id="search-results" class="results" aria-live="polite"></ul>
|
<ul id="search-results" class="results" aria-live="polite"></ul>
|
||||||
<p class="search-hint">索引由 Pagefind 构建期生成 · 静态站零后端</p>
|
<p class="search-hint">{t('search_hint', locale)}</p>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { t } from '../i18n/ui';
|
||||||
|
|
||||||
|
// 模块脚本读不到 Astro props:空态文案语言在事件触发时从 dialog 的
|
||||||
|
// data-locale 现取——ClientRouter 换页重建 DOM 后仍能拿到当前页语言
|
||||||
const dlg = document.getElementById('search-dialog') as HTMLDialogElement;
|
const dlg = document.getElementById('search-dialog') as HTMLDialogElement;
|
||||||
const input = document.getElementById('search-input') as HTMLInputElement;
|
const input = document.getElementById('search-input') as HTMLInputElement;
|
||||||
const list = document.getElementById('search-results')!;
|
const list = document.getElementById('search-results')!;
|
||||||
@@ -59,7 +71,10 @@
|
|||||||
li.appendChild(a);
|
li.appendChild(a);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
}
|
}
|
||||||
if (!list.children.length) list.innerHTML = '<li class="empty">无结果</li>';
|
if (!list.children.length) {
|
||||||
|
const loc = (document.getElementById('search-dialog')?.dataset.locale ?? 'zh') as 'zh' | 'en';
|
||||||
|
list.innerHTML = `<li class="empty">${t('search_no_results', loc)}</li>`;
|
||||||
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import IdCard from '../../components/IdCard.astro';
|
||||||
|
import { t } from '../../i18n/ui';
|
||||||
|
import type { Locale } from '../../lib/locale';
|
||||||
|
|
||||||
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
|
interface Props {
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
const { locale = 'zh' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('about_title', locale)} · wait4x`}>
|
||||||
|
<h1 class="page-title">{t('about_title', locale)}</h1>
|
||||||
|
|
||||||
|
<IdCard />
|
||||||
|
|
||||||
|
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
|
||||||
|
<h2 class="sec-title">{t('about_tracks_title', locale)}</h2>
|
||||||
|
<ul>
|
||||||
|
<li class="track-row">
|
||||||
|
<span class="track-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
|
||||||
|
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t('about_track_reading_name', locale)}</strong>
|
||||||
|
<p>{t('about_track_reading_desc', locale)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="track-row">
|
||||||
|
<span class="track-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
|
||||||
|
<path d="M12.5 18.5H20"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t('about_track_tech_name', locale)}</strong>
|
||||||
|
<p>{t('about_track_tech_desc', locale)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="track-row">
|
||||||
|
<span class="track-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
|
||||||
|
<circle cx="12" cy="13" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{t('about_track_photo_name', locale)}</strong>
|
||||||
|
<p>{t('about_track_photo_desc', locale)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="build" aria-label={t('about_build_aria', locale)}>
|
||||||
|
<h2 class="sec-title">{t('about_build_title', locale)}</h2>
|
||||||
|
<p>{t('about_build_desc', locale)}</p>
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-h1);
|
||||||
|
line-height: var(--leading-heading);
|
||||||
|
color: var(--ink-1);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.sec-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--ink-3);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: var(--space-5) 0 var(--space-3);
|
||||||
|
}
|
||||||
|
.tracks ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.track-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
.track-icon {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.track-row strong {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ink-1);
|
||||||
|
}
|
||||||
|
.track-row p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.build p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
---
|
||||||
|
import { Image, getImage } from 'astro:assets';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { t } from '../../i18n/ui';
|
||||||
|
import type { Locale } from '../../lib/locale';
|
||||||
|
import type { PhotoExif } from '../../lib/exif';
|
||||||
|
|
||||||
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
|
interface Props {
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
const { locale = 'zh' } = Astro.props;
|
||||||
|
|
||||||
|
const photos = await getCollection('photos');
|
||||||
|
|
||||||
|
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
|
||||||
|
function formatExif(exif: PhotoExif | undefined): string {
|
||||||
|
if (!exif) return '';
|
||||||
|
const shutter =
|
||||||
|
exif.exposureTime === undefined
|
||||||
|
? undefined
|
||||||
|
: exif.exposureTime >= 1
|
||||||
|
? `${exif.exposureTime}s`
|
||||||
|
: `1/${Math.round(1 / exif.exposureTime)}s`;
|
||||||
|
return [
|
||||||
|
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
|
||||||
|
exif.lens,
|
||||||
|
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
|
||||||
|
shutter,
|
||||||
|
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
|
||||||
|
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
|
||||||
|
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
|
||||||
|
const ROW = 0.0092;
|
||||||
|
const GAP = 0.0162;
|
||||||
|
const spanFor = (w: number, h: number, cols: number) =>
|
||||||
|
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
|
||||||
|
|
||||||
|
const cards = await Promise.all(
|
||||||
|
photos.map(async (photo) => {
|
||||||
|
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
|
||||||
|
const w = photo.data.image.width ?? 4;
|
||||||
|
const h = photo.data.image.height ?? 3;
|
||||||
|
const full = await getImage({ src: photo.data.image, width: 1440 });
|
||||||
|
return {
|
||||||
|
group: photo.data.group,
|
||||||
|
title: photo.data.title,
|
||||||
|
alt: photo.data.alt ?? photo.data.title,
|
||||||
|
image: photo.data.image,
|
||||||
|
exif: formatExif(photo.data.exif),
|
||||||
|
fullSrc: full.src,
|
||||||
|
// 原图走仓外 store(构建时独立 rsync 到 /photos-originals/),git 不背 196M
|
||||||
|
original: `/photos-originals/${photo.id}.jpg`,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
s3: spanFor(w, h, 3),
|
||||||
|
s2: spanFor(w, h, 2),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const first = cards[0];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('gallery_title', locale)} · wait4x`}>
|
||||||
|
<h1 class="page-title">{t('gallery_title', locale)}</h1>
|
||||||
|
<div class="g-main">
|
||||||
|
<fieldset class="filters">
|
||||||
|
<legend class="sr-only">{t('gallery_filter_legend', locale)}</legend>
|
||||||
|
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
|
||||||
|
<label class="tap chip" for="f-all">{t('gallery_filter_all', locale)}</label>
|
||||||
|
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
|
||||||
|
<label class="tap chip" for="f-street">{t('gallery_filter_street', locale)}</label>
|
||||||
|
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
|
||||||
|
<label class="tap chip" for="f-landscape">{t('gallery_filter_landscape', locale)}</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<ul class="wall">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<li
|
||||||
|
class="shot"
|
||||||
|
data-group={card.group}
|
||||||
|
style={`aspect-ratio:${card.w}/${card.h}; --s3:${card.s3}; --s2:${card.s2}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shot-btn"
|
||||||
|
data-title={card.title}
|
||||||
|
data-exif={card.exif}
|
||||||
|
data-full={card.fullSrc}
|
||||||
|
data-original={card.original}
|
||||||
|
aria-label={t('gallery_view_aria', locale).replace('{title}', card.title) +
|
||||||
|
(card.exif ? t('gallery_view_aria_exif', locale).replace('{exif}', card.exif) : '')}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={card.image}
|
||||||
|
alt={card.alt}
|
||||||
|
widths={[320, 560, 720]}
|
||||||
|
sizes="(min-width: 820px) 30vw, 46vw"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="g-note">{t('gallery_count', locale).replace('{n}', String(cards.length))}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="lb" aria-label={t('gallery_lightbox_label', locale)}>
|
||||||
|
<button id="lb-close" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_close', locale)}>
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="m5 5 14 14M19 5 5 19"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="lb-figure">
|
||||||
|
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="lb-bottom">
|
||||||
|
<button id="lb-prev" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_prev', locale)}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="lb-meta">
|
||||||
|
<strong id="lb-title">{first?.title}</strong>
|
||||||
|
<span id="lb-exif">{first?.exif}</span>
|
||||||
|
<span class="lb-orig">
|
||||||
|
<a id="lb-original" href={first?.original} target="_blank" rel="noopener">{t('gallery_lightbox_original', locale)}</a>
|
||||||
|
<a id="lb-download" href={first?.original} download>{t('gallery_lightbox_download', locale)}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button id="lb-next" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_next', locale)}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 灯箱:原生 dialog,←→ 切换、Esc 原生关闭;触屏快滑翻页是键盘的等价物。
|
||||||
|
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
const lb = document.getElementById('lb') as HTMLDialogElement | null;
|
||||||
|
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
|
||||||
|
if (!lb || shots.length === 0) return;
|
||||||
|
|
||||||
|
const img = document.getElementById('lb-img') as HTMLImageElement;
|
||||||
|
const titleEl = document.getElementById('lb-title') as HTMLElement;
|
||||||
|
const exifEl = document.getElementById('lb-exif') as HTMLElement;
|
||||||
|
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
const show = (i: number) => {
|
||||||
|
idx = (i + shots.length) % shots.length;
|
||||||
|
const btn = shots[idx]!;
|
||||||
|
img.src = btn.dataset.full ?? '';
|
||||||
|
img.alt = btn.ariaLabel ?? '';
|
||||||
|
titleEl.textContent = btn.dataset.title ?? '';
|
||||||
|
exifEl.textContent = btn.dataset.exif ?? '';
|
||||||
|
const orig = btn.dataset.original ?? '';
|
||||||
|
(document.getElementById('lb-original') as HTMLAnchorElement).href = orig;
|
||||||
|
const dl = document.getElementById('lb-download') as HTMLAnchorElement;
|
||||||
|
dl.href = orig;
|
||||||
|
dl.setAttribute('download', btn.dataset.title ?? 'photo');
|
||||||
|
if (!reduced.matches) {
|
||||||
|
img.style.opacity = '0';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
img.style.opacity = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!lb.open) lb.showModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
|
||||||
|
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
|
||||||
|
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
|
||||||
|
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
|
||||||
|
lb.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
show(idx - 1);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
show(idx + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let x0: number | null = null;
|
||||||
|
lb.addEventListener('touchstart', (e) => {
|
||||||
|
x0 = e.touches[0]!.clientX;
|
||||||
|
}, { passive: true });
|
||||||
|
lb.addEventListener('touchend', (e) => {
|
||||||
|
if (x0 === null) return;
|
||||||
|
const dx = e.changedTouches[0]!.clientX - x0;
|
||||||
|
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
|
||||||
|
x0 = null;
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
|
||||||
|
:global(main#main:has(.g-main)) {
|
||||||
|
max-width: calc(var(--measure) + 176px);
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-h1);
|
||||||
|
line-height: var(--leading-heading);
|
||||||
|
color: var(--ink-1);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
|
||||||
|
.filters {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: var(--space-3) 0 var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.sr-only,
|
||||||
|
.f-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding-inline: 13px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
|
||||||
|
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
|
||||||
|
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
|
||||||
|
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
|
||||||
|
|
||||||
|
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
|
||||||
|
.stage { container-type: inline-size; }
|
||||||
|
.wall {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
|
||||||
|
grid-auto-flow: dense;
|
||||||
|
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
|
||||||
|
}
|
||||||
|
@media (min-width: 820px) {
|
||||||
|
.wall { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.shot {
|
||||||
|
grid-row-end: span var(--s2, 10);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (min-width: 820px) {
|
||||||
|
.shot { grid-row-end: span var(--s3, 12); }
|
||||||
|
}
|
||||||
|
.shot-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.shot-btn :global(img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.g-note {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
|
||||||
|
#lb {
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
|
||||||
|
.lb-figure {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-5) var(--space-4) var(--space-2);
|
||||||
|
}
|
||||||
|
#lb-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: opacity 160ms var(--ease);
|
||||||
|
}
|
||||||
|
.lb-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||||
|
color: var(--code-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.lb-orig {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
.lb-orig a {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
.lb-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.lb-meta strong {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.lb-meta span {
|
||||||
|
opacity: 0.82;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lbnav {
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #e6e9ee;
|
||||||
|
}
|
||||||
|
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
|
||||||
|
#lb-close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-3);
|
||||||
|
right: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.lb-bottom { flex-wrap: wrap; }
|
||||||
|
.lb-orig {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
.lb-orig a {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
.lb-meta { order: 3; flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
---
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { t } from '../../i18n/ui';
|
||||||
|
import type { Locale } from '../../lib/locale';
|
||||||
|
|
||||||
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
|
interface Props {
|
||||||
|
locale?: Locale;
|
||||||
|
}
|
||||||
|
const { locale = 'zh' } = Astro.props;
|
||||||
|
|
||||||
|
const moments = (await getCollection('moments')).sort(
|
||||||
|
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||||
|
);
|
||||||
|
const iso = (d: Date) => d.toISOString();
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
|
||||||
|
<h1 class="page-title">{t('moments_title', locale)}</h1>
|
||||||
|
<p class="page-note">{t('moments_note', locale)}</p>
|
||||||
|
<ol class="tl">
|
||||||
|
{moments.map((m) => (
|
||||||
|
<li class="tl-item">
|
||||||
|
<time class="tl-date" datetime={iso(m.data.pubDate)}>
|
||||||
|
{iso(m.data.pubDate).slice(0, 10)}
|
||||||
|
</time>
|
||||||
|
<div class="mom-card">
|
||||||
|
{m.data.image && (
|
||||||
|
<figure
|
||||||
|
class:list={[
|
||||||
|
'mom-fig',
|
||||||
|
(m.data.image.width ?? 0) < (m.data.image.height ?? 0) && 'vt',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={m.data.image}
|
||||||
|
alt={m.data.text ?? m.data.tags.join(' ')}
|
||||||
|
widths={[480, 840]}
|
||||||
|
sizes="(min-width: 760px) 420px, calc(100vw - 140px)"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
{m.data.text && <p>{m.data.text}</p>}
|
||||||
|
{m.data.tags.length > 0 && (
|
||||||
|
<p class="mom-tags">
|
||||||
|
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-h1);
|
||||||
|
line-height: var(--leading-heading);
|
||||||
|
color: var(--ink-1);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.page-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
margin-block: var(--space-1) var(--space-4);
|
||||||
|
}
|
||||||
|
.tl {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
|
||||||
|
.tl-item {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.tl-item:last-child { margin-bottom: 0; }
|
||||||
|
.tl-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -5px;
|
||||||
|
top: 7px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--bg);
|
||||||
|
}
|
||||||
|
.tl-date {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(-1 * (var(--space-5) + var(--space-4)));
|
||||||
|
top: 4px;
|
||||||
|
width: var(--space-5);
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mom-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.mom-card > p { font-size: 15px; color: var(--ink-2); }
|
||||||
|
.mom-fig {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.mom-fig.vt { max-width: 260px; }
|
||||||
|
.mom-fig :global(img) { width: 100%; height: auto; }
|
||||||
|
.mom-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 窄屏:日期卡到卡片上方,竖线左移收窄 */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.tl-item { margin-left: var(--space-3); }
|
||||||
|
.tl-date {
|
||||||
|
position: static;
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.mom-card { padding: var(--space-2) var(--space-3); }
|
||||||
|
.mom-fig { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -62,6 +62,10 @@ export const collections = {
|
|||||||
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
|
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
|
||||||
schema: postsSchema,
|
schema: postsSchema,
|
||||||
}),
|
}),
|
||||||
|
postsEn: defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.md', base: './content/posts-en' }),
|
||||||
|
schema: postsSchema.extend({ locked: z.boolean().optional() }),
|
||||||
|
}),
|
||||||
// image 字段必须走函数式 schema 的 image() helper:zod/v4 的 z 上没有 .image(),
|
// image 字段必须走函数式 schema 的 image() helper:zod/v4 的 z 上没有 .image(),
|
||||||
// 相对路径由 Astro 传入的 helper 按 entry 文件位置解析
|
// 相对路径由 Astro 传入的 helper 按 entry 文件位置解析
|
||||||
moments: defineCollection({
|
moments: defineCollection({
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* UI 双语字典:组件静态文案的单一事实源。
|
||||||
|
* 约定:key 稳定小写蛇形;zh 是源语言(先有),en 跟随;
|
||||||
|
* 车道协作:op-gitea 扩展本表并串接五组件(Footer/SearchDialog/gallery/moments/about)。
|
||||||
|
*/
|
||||||
|
export type Locale = 'zh' | 'en';
|
||||||
|
|
||||||
|
const dict = {
|
||||||
|
skip_to_content: { zh: '跳到正文', en: 'Skip to content' },
|
||||||
|
nav_posts: { zh: '文章', en: 'Posts' },
|
||||||
|
nav_gallery: { zh: '图库', en: 'Gallery' },
|
||||||
|
nav_about: { zh: '关于', en: 'About' },
|
||||||
|
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' },
|
||||||
|
footer_copyright: { zh: '©', en: '©' },
|
||||||
|
reading_time_min: { zh: '分钟', en: 'min' },
|
||||||
|
category_reading: { zh: '阅读', en: 'Reading' },
|
||||||
|
category_tech: { zh: '技术', en: 'Tech' },
|
||||||
|
category_photo: { zh: '照片', en: 'Photo' },
|
||||||
|
latest_photos: { zh: '最新影像', en: 'Latest photos' },
|
||||||
|
enter_gallery: { zh: '进入图库 →', en: 'Gallery →' },
|
||||||
|
open_source_projects: { zh: '开源 · Projects', en: 'Open Source · Projects' },
|
||||||
|
self_hosted: { zh: '自托管', en: 'self-hosted' },
|
||||||
|
prev_post: { zh: '上一篇', en: 'Previous' },
|
||||||
|
next_post: { zh: '下一篇', en: 'Next' },
|
||||||
|
ai_translated_note: {
|
||||||
|
zh: '本文由机器翻译自中文原文,欢迎指正。',
|
||||||
|
en: 'Machine-translated from the Chinese original. Corrections welcome.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== T2 车道(op-gitea):Footer/SearchDialog/gallery/moments/about 静态文案 =====
|
||||||
|
// footer_icp 是法定备案标识,英文页照抄不译
|
||||||
|
search_no_results: { zh: '无结果', en: 'No results' },
|
||||||
|
footer_icp: { zh: '陕ICP备2026014860号-1', en: '陕ICP备2026014860号-1' },
|
||||||
|
gallery_title: { zh: '图库', en: 'Gallery' },
|
||||||
|
gallery_filter_legend: { zh: '分组筛选', en: 'Filter by group' },
|
||||||
|
gallery_filter_all: { zh: '全部', en: 'All' },
|
||||||
|
gallery_filter_street: { zh: '街拍', en: 'Street' },
|
||||||
|
gallery_filter_landscape: { zh: '风景', en: 'Landscape' },
|
||||||
|
// {n}/{title}/{exif} 为占位符,调用点 .replace() 填值
|
||||||
|
gallery_count: { zh: '共 {n} 张 · EXIF 构建期自动提取', en: '{n} photos · EXIF extracted at build time' },
|
||||||
|
gallery_view_aria: { zh: '查看大图:{title}', en: 'View photo: {title}' },
|
||||||
|
gallery_view_aria_exif: { zh: '({exif})', en: ' ({exif})' },
|
||||||
|
gallery_lightbox_label: { zh: '照片查看', en: 'Photo viewer' },
|
||||||
|
gallery_lightbox_close: { zh: '关闭(Esc)', en: 'Close (Esc)' },
|
||||||
|
gallery_lightbox_prev: { zh: '上一张(←)', en: 'Previous (←)' },
|
||||||
|
gallery_lightbox_next: { zh: '下一张(→)', en: 'Next (→)' },
|
||||||
|
gallery_lightbox_original: { zh: '原图', en: 'Original' },
|
||||||
|
gallery_lightbox_download: { zh: '下载', en: 'Download' },
|
||||||
|
moments_title: { zh: '瞬间', en: 'Moments' },
|
||||||
|
moments_note: { zh: '零碎的记录:随手拍、一句话感想、开源动态。', en: 'Fragmented notes: casual shots, one-liners, and open-source updates.' },
|
||||||
|
about_title: { zh: '关于', en: 'About' },
|
||||||
|
about_tracks_title: { zh: '兴趣版图', en: 'Interests' },
|
||||||
|
about_track_reading_name: { zh: '阅读', en: 'Reading' },
|
||||||
|
about_track_reading_desc: { zh: '文学与戏剧为主,读到哪写到哪。', en: 'Mostly literature and drama; I write as I read.' },
|
||||||
|
about_track_tech_name: { zh: '技术', en: 'Tech' },
|
||||||
|
about_track_tech_desc: { zh: 'Linux、容器与自托管,把家里的小机器折腾成顺手的工具。', en: 'Linux, containers, and self-hosting — turning the little machines at home into handy tools.' },
|
||||||
|
about_track_photo_name: { zh: '摄影', en: 'Photography' },
|
||||||
|
about_track_photo_desc: { zh: '街拍与山野,随身一台无反,在等光的间隙按快门。', en: 'Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.' },
|
||||||
|
about_build_aria: { zh: '本站构建说明', en: 'Site build notes' },
|
||||||
|
about_build_title: { zh: '本站如何构建', en: 'How this site is built' },
|
||||||
|
about_build_desc: {
|
||||||
|
zh: '页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管 Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。',
|
||||||
|
en: 'Pages are statically generated by Astro with no runtime framework JS. Content lives on a self-hosted gitea — my local editor and the gitea web UI write to the same git repository, and each push is built and published automatically by the homelab. Comments run on self-hosted Artalk. The built-from line in the footer links to the commit behind this build, keeping the process transparent and traceable.',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UiKey = keyof typeof dict;
|
||||||
|
|
||||||
|
/** 取串:locale 缺省 zh;未知 key 原样返回(编译期 tsc 已挡)。 */
|
||||||
|
export function t(key: UiKey, locale: Locale = 'zh'): string {
|
||||||
|
return dict[key][locale] ?? dict[key].zh;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import Header from '../components/Header.astro';
|
|||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import ThemeScript from '../components/ThemeScript.astro';
|
import ThemeScript from '../components/ThemeScript.astro';
|
||||||
import Keyboard from '../components/geek/Keyboard.astro';
|
import Keyboard from '../components/geek/Keyboard.astro';
|
||||||
|
import { localeOf, localePath } from '../lib/locale';
|
||||||
import SearchDialog from '../components/SearchDialog.astro';
|
import SearchDialog from '../components/SearchDialog.astro';
|
||||||
import BuildInfo from '../components/geek/BuildInfo.astro';
|
import BuildInfo from '../components/geek/BuildInfo.astro';
|
||||||
import { ClientRouter } from 'astro:transitions';
|
import { ClientRouter } from 'astro:transitions';
|
||||||
@@ -23,6 +24,12 @@ interface Props {
|
|||||||
track?: 'reading' | 'tech';
|
track?: 'reading' | 'tech';
|
||||||
}
|
}
|
||||||
const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', track = 'tech' } = Astro.props;
|
const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', track = 'tech' } = Astro.props;
|
||||||
|
|
||||||
|
// hreflang 双向:zh 无前缀 ↔ /en/;x-default 指向 zh(中文是源语言)
|
||||||
|
const rawPath = Astro.url.pathname.replace(/\/index\.html$/, '');
|
||||||
|
const zhPath = localeOf(rawPath) === 'en' ? localePath(rawPath, 'zh') : rawPath;
|
||||||
|
const enPath = localePath(rawPath, 'en');
|
||||||
|
const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -37,6 +44,9 @@ const { title, description = 'wait4x 的博客:阅读 · 技术 · 摄影', tr
|
|||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<ThemeScript />
|
<ThemeScript />
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
<link rel="alternate" hreflang="zh" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||||
|
<link rel="alternate" hreflang="en" href={new URL(withSlash(enPath), Astro.site)} />
|
||||||
|
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main">跳到正文</a>
|
<a class="skip-link" href="#main">跳到正文</a>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 双语路径工具:zh 无前缀(历史 URL 1:1),en 加 /en/ 前缀。
|
||||||
|
* 语言切换 = 同路径跨前缀跳转(预构建双语,毫秒级)。
|
||||||
|
*/
|
||||||
|
export type Locale = 'zh' | 'en';
|
||||||
|
|
||||||
|
export const localeOf = (pathname: string): Locale =>
|
||||||
|
pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'zh';
|
||||||
|
|
||||||
|
/** 当前路径在另一语言下的对应路径(zh → /en/...,en → 去 /en 前缀)。 */
|
||||||
|
export function localePath(pathname: string, to: Locale): string {
|
||||||
|
const clean = pathname.replace(/\/index\.html$/, '').replace(/\/$/, '') || '/';
|
||||||
|
if (to === 'en') return clean === '/' ? '/en/' : `/en${clean}`;
|
||||||
|
return clean.replace(/^\/en(?=\/|$)/, '') || '/';
|
||||||
|
}
|
||||||
+2
-116
@@ -1,118 +1,4 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import AboutPage from '../components/pages/AboutPage.astro';
|
||||||
import IdCard from '../components/IdCard.astro';
|
|
||||||
---
|
---
|
||||||
|
<AboutPage locale="zh" />
|
||||||
<BaseLayout title="关于 · wait4x">
|
|
||||||
<h1 class="page-title">关于</h1>
|
|
||||||
|
|
||||||
<IdCard />
|
|
||||||
|
|
||||||
<section class="tracks" aria-label="兴趣版图">
|
|
||||||
<h2 class="sec-title">兴趣版图</h2>
|
|
||||||
<ul>
|
|
||||||
<li class="track-row">
|
|
||||||
<span class="track-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
|
|
||||||
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>阅读</strong>
|
|
||||||
<p>文学与戏剧为主,读到哪写到哪。</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="track-row">
|
|
||||||
<span class="track-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
|
|
||||||
<path d="M12.5 18.5H20"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>技术</strong>
|
|
||||||
<p>Linux、容器与自托管,把家里的小机器折腾成顺手的工具。</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="track-row">
|
|
||||||
<span class="track-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
|
|
||||||
<circle cx="12" cy="13" r="4"></circle>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<strong>摄影</strong>
|
|
||||||
<p>街拍与山野,随身一台无反,在等光的间隙按快门。</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="build" aria-label="本站构建说明">
|
|
||||||
<h2 class="sec-title">本站如何构建</h2>
|
|
||||||
<p>
|
|
||||||
页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea
|
|
||||||
网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管
|
|
||||||
Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-h1);
|
|
||||||
line-height: var(--leading-heading);
|
|
||||||
color: var(--ink-1);
|
|
||||||
margin-top: var(--space-5);
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
.sec-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--ink-3);
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: var(--space-5) 0 var(--space-3);
|
|
||||||
}
|
|
||||||
.tracks ul {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.track-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-3);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
}
|
|
||||||
.track-icon {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-soft);
|
|
||||||
color: var(--ink-3);
|
|
||||||
}
|
|
||||||
.track-row strong {
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--ink-1);
|
|
||||||
}
|
|
||||||
.track-row p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ink-3);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.build p {
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--ink-2);
|
|
||||||
max-width: var(--measure);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import AboutPage from '../../components/pages/AboutPage.astro';
|
||||||
|
---
|
||||||
|
<AboutPage locale="en" />
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
|
||||||
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { getCollection, render } from 'astro:content';
|
||||||
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
|
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
||||||
|
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
|
||||||
|
import { t } from '../../../i18n/ui';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const zh = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||||
|
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||||
|
);
|
||||||
|
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
|
||||||
|
return zh.map((entry, i) => ({
|
||||||
|
params: { slug: entry.id },
|
||||||
|
props: { entry, enEntry: en.get(entry.id) ?? null, prev: zh[i + 1] ?? null, next: zh[i - 1] ?? null },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: CollectionEntry<'posts'>;
|
||||||
|
enEntry: CollectionEntry<'postsEn'> | null;
|
||||||
|
prev: CollectionEntry<'posts'> | null;
|
||||||
|
next: CollectionEntry<'posts'> | null;
|
||||||
|
}
|
||||||
|
const { entry, enEntry, prev, next } = Astro.props;
|
||||||
|
// 译文存在则渲染译文;否则回退 zh 正文(语言切换的降级承诺:单篇未译不破站)
|
||||||
|
const { Content } = await render(enEntry ?? entry);
|
||||||
|
const isReading = entry.data.category === 'reading';
|
||||||
|
const date = entry.data.pubDate.toISOString().slice(0, 10);
|
||||||
|
const words = (enEntry?.body ?? entry.body ?? '').split(/\s+/).length;
|
||||||
|
const minutes = Math.max(1, Math.ceil(words / 220));
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${enEntry?.data.title ?? entry.data.title} · wait4x`} track={isReading ? 'reading' : 'tech'}>
|
||||||
|
<article class={`post ${isReading ? 'post-reading' : 'post-tech'}`}>
|
||||||
|
<header class="post-head">
|
||||||
|
<p class="post-cat">
|
||||||
|
{isReading ? t('category_reading', 'en') : entry.data.category === 'tech' ? t('category_tech', 'en') : t('category_photo', 'en')}
|
||||||
|
</p>
|
||||||
|
<h1 class="post-title">{enEntry?.data.title ?? entry.data.title}</h1>
|
||||||
|
<p class="post-meta">
|
||||||
|
<i class="emo" style={`background:${entry.data.mood ?? 'var(--mood)'}`} aria-hidden="true" />
|
||||||
|
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>{minutes} {t('reading_time_min', 'en')}</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<AiTranslatedBadge />
|
||||||
|
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
<nav class="pn">
|
||||||
|
{prev && <a class="pn-item" href={`/en/archives/${prev.id}/`}>← {t('prev_post', 'en')} · {prev.data.title}</a>}
|
||||||
|
{next && <a class="pn-item" href={`/en/archives/${next.id}/`}>{t('next_post', 'en')} · {next.data.title} →</a>}
|
||||||
|
</nav>
|
||||||
|
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
||||||
|
</article>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-head { margin-bottom: var(--space-4); }
|
||||||
|
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
|
||||||
|
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
|
||||||
|
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
|
||||||
|
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
|
||||||
|
.pn-item { font-size: 14px; color: var(--ink-2); transition: color var(--dur) var(--ease); }
|
||||||
|
.pn-item:hover { color: var(--accent); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import GalleryPage from '../../components/pages/GalleryPage.astro';
|
||||||
|
---
|
||||||
|
<GalleryPage locale="en" />
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
// /en/ 首页:英文 UI + 双语内容(postsEn 优先,缺则中文回退)
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import AiTranslatedBadge from '../../components/AiTranslatedBadge.astro';
|
||||||
|
import { t } from '../../i18n/ui';
|
||||||
|
import { POST_TRACKS, trackOf } from '../../content.config';
|
||||||
|
|
||||||
|
const en = await getCollection('postsEn');
|
||||||
|
const zh = await getCollection('posts');
|
||||||
|
const bySlug = new Map(en.map((e) => [e.id, e]));
|
||||||
|
const posts = zh
|
||||||
|
.filter(({ data }) => !data.draft)
|
||||||
|
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
|
||||||
|
.map((p) => ({ zh: p, en: bySlug.get(p.id) }));
|
||||||
|
|
||||||
|
const years = [...new Set(posts.map((p) => p.zh.data.pubDate.getFullYear()))].sort((a, b) => b - a);
|
||||||
|
const t9 = (cat: keyof typeof POST_TRACKS) => trackOf(cat);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="wait4x" description="wait4x's blog: reading · code · photography" track="tech">
|
||||||
|
<AiTranslatedBadge />
|
||||||
|
<h1 class="visually-hidden">wait4x</h1>
|
||||||
|
{years.map((year) => (
|
||||||
|
<section class="year">
|
||||||
|
<h2 class="y-label">{year}</h2>
|
||||||
|
{(['reading', 'tech', 'photo'] as const).map((cat) => {
|
||||||
|
const list = posts.filter((p) => p.zh.data.category === cat && p.zh.data.pubDate.getFullYear() === year);
|
||||||
|
return list.length ? (
|
||||||
|
<>
|
||||||
|
<h3 class="track">{t(cat === 'reading' ? 'category_reading' : cat === 'tech' ? 'category_tech' : 'category_photo', 'en')}</h3>
|
||||||
|
<ul class="plist">
|
||||||
|
{list.map(({ zh: p, en: e }) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/en/archives/${p.id}/`} class="plink">
|
||||||
|
<span class="ptitle" style={p.data.mood ? `--mood:${p.data.mood}` : undefined}>
|
||||||
|
{p.data.mood && <i class="mdot" aria-hidden="true" />}
|
||||||
|
{e?.data.title ?? p.data.title}
|
||||||
|
</span>
|
||||||
|
{!e && <i class="fall">zh</i>}
|
||||||
|
<time class="pdate">{p.data.pubDate.toISOString().slice(5, 10)}</time>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.year { margin-top: var(--space-5); }
|
||||||
|
.y-label { font-family: var(--font-mono); font-size: 20px; color: var(--ink-1); }
|
||||||
|
.track { font-size: 13px; color: var(--ink-3); margin: var(--space-3) 0 var(--space-2); letter-spacing: 0.06em; }
|
||||||
|
.plist { list-style: none; padding: 0; display: grid; gap: var(--space-1); }
|
||||||
|
.plink { display: flex; align-items: baseline; gap: var(--space-2); padding-block: var(--space-1); }
|
||||||
|
.plink:hover .ptitle { color: var(--accent); }
|
||||||
|
.ptitle { color: var(--ink-1); transition: color var(--dur) var(--ease); }
|
||||||
|
.mdot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--mood, var(--ink-4)); margin-right: 6px; }
|
||||||
|
.fall { font-family: var(--font-mono); font-size: 10px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 3px; padding: 0 3px; font-style: normal; }
|
||||||
|
.pdate { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
import MomentsPage from '../../components/pages/MomentsPage.astro';
|
||||||
|
---
|
||||||
|
<MomentsPage locale="en" />
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import rss from '@astrojs/rss';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import type { APIContext } from 'astro';
|
||||||
|
|
||||||
|
// 英文 RSS:postsEn 全量;标题缺译时回退 zh 标题(与 /en/ 页同策略)
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
|
||||||
|
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||||
|
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
|
||||||
|
);
|
||||||
|
return rss({
|
||||||
|
title: 'wait4x (English)',
|
||||||
|
description: 'Reading · code · photography — machine-translated edition',
|
||||||
|
site: context.site ?? 'https://wait4x.online',
|
||||||
|
items: posts.map((p) => ({
|
||||||
|
title: en.get(p.id)?.data.title ?? p.data.title,
|
||||||
|
pubDate: p.data.pubDate,
|
||||||
|
link: `/en/archives/${p.id}/`,
|
||||||
|
})),
|
||||||
|
trailingSlash: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,393 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { Image, getImage } from 'astro:assets';
|
import GalleryPage from '../../components/pages/GalleryPage.astro';
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
||||||
import type { PhotoExif } from '../../lib/exif';
|
|
||||||
|
|
||||||
const photos = await getCollection('photos');
|
|
||||||
|
|
||||||
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
|
|
||||||
function formatExif(exif: PhotoExif | undefined): string {
|
|
||||||
if (!exif) return '';
|
|
||||||
const shutter =
|
|
||||||
exif.exposureTime === undefined
|
|
||||||
? undefined
|
|
||||||
: exif.exposureTime >= 1
|
|
||||||
? `${exif.exposureTime}s`
|
|
||||||
: `1/${Math.round(1 / exif.exposureTime)}s`;
|
|
||||||
return [
|
|
||||||
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
|
|
||||||
exif.lens,
|
|
||||||
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
|
|
||||||
shutter,
|
|
||||||
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
|
|
||||||
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
|
|
||||||
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
|
|
||||||
const ROW = 0.0092;
|
|
||||||
const GAP = 0.0162;
|
|
||||||
const spanFor = (w: number, h: number, cols: number) =>
|
|
||||||
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
|
|
||||||
|
|
||||||
const cards = await Promise.all(
|
|
||||||
photos.map(async (photo) => {
|
|
||||||
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
|
|
||||||
const w = photo.data.image.width ?? 4;
|
|
||||||
const h = photo.data.image.height ?? 3;
|
|
||||||
const full = await getImage({ src: photo.data.image, width: 1440 });
|
|
||||||
return {
|
|
||||||
group: photo.data.group,
|
|
||||||
title: photo.data.title,
|
|
||||||
alt: photo.data.alt ?? photo.data.title,
|
|
||||||
image: photo.data.image,
|
|
||||||
exif: formatExif(photo.data.exif),
|
|
||||||
fullSrc: full.src,
|
|
||||||
// 原图走仓外 store(构建时独立 rsync 到 /photos-originals/),git 不背 196M
|
|
||||||
original: `/photos-originals/${photo.id}.jpg`,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
s3: spanFor(w, h, 3),
|
|
||||||
s2: spanFor(w, h, 2),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const first = cards[0];
|
|
||||||
---
|
---
|
||||||
|
<GalleryPage locale="zh" />
|
||||||
<BaseLayout title="图库 · wait4x">
|
|
||||||
<h1 class="page-title">图库</h1>
|
|
||||||
<div class="g-main">
|
|
||||||
<fieldset class="filters">
|
|
||||||
<legend class="sr-only">分组筛选</legend>
|
|
||||||
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
|
|
||||||
<label class="tap chip" for="f-all">全部</label>
|
|
||||||
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
|
|
||||||
<label class="tap chip" for="f-street">街拍</label>
|
|
||||||
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
|
|
||||||
<label class="tap chip" for="f-landscape">风景</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div class="stage">
|
|
||||||
<ul class="wall">
|
|
||||||
{cards.map((card) => (
|
|
||||||
<li
|
|
||||||
class="shot"
|
|
||||||
data-group={card.group}
|
|
||||||
style={`aspect-ratio:${card.w}/${card.h}; --s3:${card.s3}; --s2:${card.s2}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="shot-btn"
|
|
||||||
data-title={card.title}
|
|
||||||
data-exif={card.exif}
|
|
||||||
data-full={card.fullSrc}
|
|
||||||
data-original={card.original}
|
|
||||||
aria-label={`查看大图:${card.title}${card.exif ? `(${card.exif})` : ''}`}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={card.image}
|
|
||||||
alt={card.alt}
|
|
||||||
widths={[320, 560, 720]}
|
|
||||||
sizes="(min-width: 820px) 30vw, 46vw"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<p class="g-note">共 {cards.length} 张 · EXIF 构建期自动提取</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dialog id="lb" aria-label="照片查看">
|
|
||||||
<button id="lb-close" class="tap lbnav" type="button" aria-label="关闭(Esc)">
|
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<path d="m5 5 14 14M19 5 5 19"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="lb-figure">
|
|
||||||
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="lb-bottom">
|
|
||||||
<button id="lb-prev" class="tap lbnav" type="button" aria-label="上一张(←)">
|
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="lb-meta">
|
|
||||||
<strong id="lb-title">{first?.title}</strong>
|
|
||||||
<span id="lb-exif">{first?.exif}</span>
|
|
||||||
<span class="lb-orig">
|
|
||||||
<a id="lb-original" href={first?.original} target="_blank" rel="noopener">原图</a>
|
|
||||||
<a id="lb-download" href={first?.original} download>下载</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button id="lb-next" class="tap lbnav" type="button" aria-label="下一张(→)">
|
|
||||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 灯箱:原生 dialog,←→ 切换、Esc 原生关闭;触屏快滑翻页是键盘的等价物。
|
|
||||||
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
const lb = document.getElementById('lb') as HTMLDialogElement | null;
|
|
||||||
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
|
|
||||||
if (!lb || shots.length === 0) return;
|
|
||||||
|
|
||||||
const img = document.getElementById('lb-img') as HTMLImageElement;
|
|
||||||
const titleEl = document.getElementById('lb-title') as HTMLElement;
|
|
||||||
const exifEl = document.getElementById('lb-exif') as HTMLElement;
|
|
||||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
let idx = 0;
|
|
||||||
|
|
||||||
const show = (i: number) => {
|
|
||||||
idx = (i + shots.length) % shots.length;
|
|
||||||
const btn = shots[idx]!;
|
|
||||||
img.src = btn.dataset.full ?? '';
|
|
||||||
img.alt = btn.ariaLabel ?? '';
|
|
||||||
titleEl.textContent = btn.dataset.title ?? '';
|
|
||||||
exifEl.textContent = btn.dataset.exif ?? '';
|
|
||||||
const orig = btn.dataset.original ?? '';
|
|
||||||
(document.getElementById('lb-original') as HTMLAnchorElement).href = orig;
|
|
||||||
const dl = document.getElementById('lb-download') as HTMLAnchorElement;
|
|
||||||
dl.href = orig;
|
|
||||||
dl.setAttribute('download', btn.dataset.title ?? 'photo');
|
|
||||||
if (!reduced.matches) {
|
|
||||||
img.style.opacity = '0';
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
img.style.opacity = '1';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!lb.open) lb.showModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
|
|
||||||
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
|
|
||||||
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
|
|
||||||
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
|
|
||||||
lb.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
|
||||||
show(idx - 1);
|
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault();
|
|
||||||
show(idx + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let x0: number | null = null;
|
|
||||||
lb.addEventListener('touchstart', (e) => {
|
|
||||||
x0 = e.touches[0]!.clientX;
|
|
||||||
}, { passive: true });
|
|
||||||
lb.addEventListener('touchend', (e) => {
|
|
||||||
if (x0 === null) return;
|
|
||||||
const dx = e.changedTouches[0]!.clientX - x0;
|
|
||||||
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
|
|
||||||
x0 = null;
|
|
||||||
}, { passive: true });
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
|
|
||||||
:global(main#main:has(.g-main)) {
|
|
||||||
max-width: calc(var(--measure) + 176px);
|
|
||||||
}
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-h1);
|
|
||||||
line-height: var(--leading-heading);
|
|
||||||
color: var(--ink-1);
|
|
||||||
margin-top: var(--space-5);
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
|
|
||||||
.filters {
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: var(--space-3) 0 var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.sr-only,
|
|
||||||
.f-input {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.chip {
|
|
||||||
padding-inline: 13px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ink-3);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
|
||||||
}
|
|
||||||
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
|
|
||||||
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
|
|
||||||
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
|
|
||||||
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
|
|
||||||
|
|
||||||
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
|
|
||||||
.stage { container-type: inline-size; }
|
|
||||||
.wall {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
|
|
||||||
grid-auto-flow: dense;
|
|
||||||
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
|
|
||||||
}
|
|
||||||
@media (min-width: 820px) {
|
|
||||||
.wall { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
.shot {
|
|
||||||
grid-row-end: span var(--s2, 10);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
@media (min-width: 820px) {
|
|
||||||
.shot { grid-row-end: span var(--s3, 12); }
|
|
||||||
}
|
|
||||||
.shot-btn {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: zoom-in;
|
|
||||||
}
|
|
||||||
.shot-btn :global(img) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.g-note {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--ink-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
|
|
||||||
#lb {
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100dvh;
|
|
||||||
max-width: none;
|
|
||||||
max-height: none;
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
background: transparent;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
|
|
||||||
.lb-figure {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--space-5) var(--space-4) var(--space-2);
|
|
||||||
}
|
|
||||||
#lb-img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: opacity 160ms var(--ease);
|
|
||||||
}
|
|
||||||
.lb-bottom {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
|
||||||
color: var(--code-ink);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
.lb-orig {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-left: var(--space-2);
|
|
||||||
}
|
|
||||||
.lb-orig a {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 0.2em;
|
|
||||||
}
|
|
||||||
.lb-meta {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.lb-meta strong {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.lb-meta span {
|
|
||||||
opacity: 0.82;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.lbnav {
|
|
||||||
flex: none;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: #e6e9ee;
|
|
||||||
}
|
|
||||||
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
|
|
||||||
#lb-close {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-3);
|
|
||||||
right: var(--space-3);
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.lb-bottom { flex-wrap: wrap; }
|
|
||||||
.lb-orig {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-left: var(--space-2);
|
|
||||||
}
|
|
||||||
.lb-orig a {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 0.2em;
|
|
||||||
}
|
|
||||||
.lb-meta { order: 3; flex-basis: 100%; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,142 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { Image } from 'astro:assets';
|
import MomentsPage from '../../components/pages/MomentsPage.astro';
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
||||||
|
|
||||||
const moments = (await getCollection('moments')).sort(
|
|
||||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
|
||||||
);
|
|
||||||
const iso = (d: Date) => d.toISOString();
|
|
||||||
---
|
---
|
||||||
|
<MomentsPage locale="zh" />
|
||||||
<BaseLayout title="瞬间 · wait4x">
|
|
||||||
<h1 class="page-title">瞬间</h1>
|
|
||||||
<p class="page-note">零碎的记录:随手拍、一句话感想、开源动态。</p>
|
|
||||||
<ol class="tl">
|
|
||||||
{moments.map((m) => (
|
|
||||||
<li class="tl-item">
|
|
||||||
<time class="tl-date" datetime={iso(m.data.pubDate)}>
|
|
||||||
{iso(m.data.pubDate).slice(0, 10)}
|
|
||||||
</time>
|
|
||||||
<div class="mom-card">
|
|
||||||
{m.data.image && (
|
|
||||||
<figure
|
|
||||||
class:list={[
|
|
||||||
'mom-fig',
|
|
||||||
(m.data.image.width ?? 0) < (m.data.image.height ?? 0) && 'vt',
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={m.data.image}
|
|
||||||
alt={m.data.text ?? m.data.tags.join(' ')}
|
|
||||||
widths={[480, 840]}
|
|
||||||
sizes="(min-width: 760px) 420px, calc(100vw - 140px)"
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
)}
|
|
||||||
{m.data.text && <p>{m.data.text}</p>}
|
|
||||||
{m.data.tags.length > 0 && (
|
|
||||||
<p class="mom-tags">
|
|
||||||
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page-title {
|
|
||||||
font-size: var(--text-h1);
|
|
||||||
line-height: var(--leading-heading);
|
|
||||||
color: var(--ink-1);
|
|
||||||
margin-top: var(--space-5);
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
.page-note {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ink-4);
|
|
||||||
margin-block: var(--space-1) var(--space-4);
|
|
||||||
}
|
|
||||||
.tl {
|
|
||||||
list-style: none;
|
|
||||||
margin: var(--space-4) 0 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
|
|
||||||
.tl-item {
|
|
||||||
position: relative;
|
|
||||||
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
|
|
||||||
padding-left: var(--space-4);
|
|
||||||
border-left: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.tl-item:last-child { margin-bottom: 0; }
|
|
||||||
.tl-item::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -5px;
|
|
||||||
top: 7px;
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--bg);
|
|
||||||
}
|
|
||||||
.tl-date {
|
|
||||||
position: absolute;
|
|
||||||
left: calc(-1 * (var(--space-5) + var(--space-4)));
|
|
||||||
top: 4px;
|
|
||||||
width: var(--space-5);
|
|
||||||
text-align: right;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--ink-4);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.mom-card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: var(--bg-soft);
|
|
||||||
}
|
|
||||||
.mom-card > p { font-size: 15px; color: var(--ink-2); }
|
|
||||||
.mom-fig {
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 0 0 var(--space-2);
|
|
||||||
max-width: 420px;
|
|
||||||
}
|
|
||||||
.mom-fig.vt { max-width: 260px; }
|
|
||||||
.mom-fig :global(img) { width: 100%; height: auto; }
|
|
||||||
.mom-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ink-3);
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
/* 窄屏:日期卡到卡片上方,竖线左移收窄 */
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.tl-item { margin-left: var(--space-3); }
|
|
||||||
.tl-date {
|
|
||||||
position: static;
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
text-align: left;
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.mom-card { padding: var(--space-2) var(--space-3); }
|
|
||||||
.mom-fig { max-width: 100%; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in new issue
Block a user