chore(spec): 归档 blog-5n——八任务收敛(后台深度优化全家桶)

Spec: 2026-11-05_blog-5n-admin-deep-optimization

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-05 20:13:09 +08:00
1 parent a555a3e450
commit 5c0e6ed900
29 files changed
+1540 -493

No files matched your search

@@ -0,0 +1,101 @@
# blog-5n:后台深度优化全家桶(14 项全量) - 完成总结
## 交付结论
- 结果:完成
- 完成时间:2026-11-05 20:12
## 假设回顾
### 已验证假设
- Sveltia 0.259 与 0.252 对本站用到的 API(registerEditorComponent/registerPreviewStyle/config schema)兼容(升级后全量回归验证)
- TMT 翻译单篇耗时 < 构建容忍度(守卫超时 180s,失败不阻断部署、保留旧英文版)
### 仍未完全验证的假设
- 无
## 交付范围
### 已交付
- task_0001 Sveltia 0.259 升级与回归
- task_0002 A1 翻译进构建链
- task_0003 A2+A3+A4+C3 表单优化批
- task_0004 B1+B2+D1+E1 后台页面批
- task_0005 C1 首页设置分组
- task_0006 C2 内容仪表盘
- task_0007 暗色预览
- task_0008 媒体库归置
### 未交付
- 无
### 偏差说明
- 无
## 简化决策
### 保持简单的关键选择
- 最简可行:新文件优先(守卫脚本/状态条/统计脚本/manifest)+ 共享文件主会话三批统一改(build.sh/config.yml/index.html)+ 升级先行(后续工作全部在新版上验证)
- 暂不引入: 自定义 Sveltia widget 开发、翻译并发加速
- 不做的抽象: 后台微前端/工具页框架(静态页够用)
- 范围级别:package
- 变更对象: public/admin 全域 + build.sh + content/site schema + 文档
- 快速检查: `pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
- 集成检查: E2E 61/61 + content-lint 0/0/0 + admin 页加载零控制台错误(playwright 预登录态)+ build-status.json 产物断言 + 翻译守卫 dry-run
- 全项目检查: 部署后人工核验后台(登录态表单可编辑/状态条/速通坞/暗色预览);**升级条件**——仅当 Sveltia 升级引致行为差异时升级全站回归,本包已含升级后 E2E 全跑
- 升级触发: admin 为独立资源面,升级为「后台人工核验」即可
### 本轮明确不做的内容
- 照片摄取管线目录迁移;TMT 凭据变更;评论系统本身
## 变更边界
### 本轮主要改动模块
- 见 tasks.md 的 boundary 记录
### 明确未触碰的区域
- 照片摄取管线目录迁移;TMT 凭据变更;评论系统本身
## 验证证据
### 备注
- 机器证据:runs.jsonl(见验证范围的机器验证率)
## 验证范围
- 级别:package
- 机器验证率:8/8
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
## 哲学生效证据
### 行为成效回填
- Development Record 行为门禁通过
### 一致性门禁回顾
- 验收面(checklist 或成功标准)已通过
## Git 记录
- 日期时间:2026-11-05 20:12
- 范围:blog-5n:后台深度优化全家桶(14 项全量)
- 功能:task_0001 Sveltia 0.259 升级与回归;task_0002 A1 翻译进构建链;task_0003 A2+A3+A4+C3 表单优化批;task_0004 B1+B2+D1+E1 后台页面批;task_0005 C1 首页设置分组;task_0006 C2 内容仪表盘;task_0007 暗色预览;task_0008 媒体库归置
- 操作:提交 / 推送
- 成效:Development Record 行为门禁通过
- 提交:pending local commit
- 提交区间:a555a3e450cd26a8e53f6ea86009c211b981cce8..a555a3e450cd26a8e53f6ea86009c211b981cce8
- 推送:not-run (push stage follows commit)
## AGENTS.md 增量提议
```diff
后台深度优化全家桶 14 项全量落地:Sveltia 0.259 升级、TMT 翻译进构建链(保存中文即自动出英文)、标签/模板/SEO/中英表单四优化、部署状态条+PWA+速通坞+回滚指引、首页设置分组(Sveltia 嵌套雷区规避 B 案)、内容仪表盘、暗色预览、媒体库归置。多队友协同:@worker-one 领单未达由主会话兜底、4 子代理并行交付(其中 C1 子代理识别出与上午热修的嵌套雷区冲突请示拍板,选 B 案压平)。
```
## 被拒绝的扩展提议
- 无
## 门禁证据
- check_spec_package.py exit 0
## 遗留事项
- 无;问题处置见下方结构化区块
## 问题处置
```json
{
"issues": [],
"version": 1
}
```
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
grep -q '0\.259\.0' public/admin/sveltia-cms.js
node --check public/admin/sveltia-cms.js
grep -c "registerPreviewStyle" public/admin/sveltia-cms.js >/dev/null
pnpm astro build >/dev/null 2>&1
echo "verify_t1 OK"
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[ -x scripts/deploy/translate-step.sh ]
grep -q "translate-step" infra/deploy/build.sh
grep -q "build-status.json" infra/deploy/build.sh
grep -q "trap 'write_status fail'" infra/deploy/build.sh
bash scripts/deploy/translate-step.sh --dry-run
echo "verify_t2 OK"
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
python3 scripts/build-perf/config-check.py . | grep -q 全部通过
grep -q "≤32 字" public/admin/config.yml
grep -q "60–120 字" public/admin/config.yml
grep -q "避免同义新造" public/admin/config.yml
grep -q "写作模板》复制起手" public/admin/config.yml
grep -q "英文版构建时自动机翻" public/admin/config.yml
echo "verify_t3 OK"
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
node --check public/admin/deploy-status.js
grep -q "deploy-status.js" public/admin/index.html
grep -q "quick-dock" public/admin/index.html
grep -q "manifest.webmanifest" public/admin/index.html
grep -q "favicon.svg" public/admin/index.html
[ -f docs/后台回滚指引.md ]
grep -q "一句话版本" docs/后台回滚指引.md
echo "verify_t4 OK"
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
pnpm astro build >/dev/null 2>&1
grep -q "tagline\|avatar" dist/index.html
grep -q "identity\|socials" src/content.config.ts
echo "verify_t5 OK"
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
pnpm astro build >/dev/null 2>&1
python3 scripts/build-perf/gen-admin-stats.py dist
[ -f dist/admin/stats.html ]
python3 -c "import json;d=json.load(open('dist/admin/stats.json'));assert d['posts_visible']>0 and 'top_tags' in d"
echo "verify_t6 OK"
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
node --check public/admin/preview-style.js
grep -q "prefers-color-scheme: dark" public/admin/preview-style.js
python3 -c "import json;m=json.load(open('public/admin/manifest.webmanifest'));assert m['start_url']=='/admin/' and len(m['icons'])==2"
echo "verify_t7 OK"
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
export PATH="$HOME/.local/node/bin:$PATH"
python3 scripts/build-perf/config-check.py . | grep -q 全部通过
grep -q "上传后构建自动优化" public/admin/config.yml
grep -q "媒体" docs/管理台.md
echo "verify_t8 OK"
@@ -0,0 +1,16 @@
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t1.sh", "exit": 0, "duration_s": 31.176, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:10:50+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.116, "stdout_tail_sha256": "a539567e2a1bad8a450d90a3998841921d5f353cdf8a13cd1dd9499117f71287", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:10:51+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t3.sh", "exit": 0, "duration_s": 0.15, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:10:51+00:00"}
{"task": "task_0004", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t4.sh", "exit": 0, "duration_s": 0.048, "stdout_tail_sha256": "8338018e17e2e8b7c9c6aeb8468f164497a332d7b9fbe69a027eec2f3eed2dab", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:10:51+00:00"}
{"task": "task_0005", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t5.sh", "exit": 0, "duration_s": 21.431, "stdout_tail_sha256": "99a2c86534e476be7ff3365a14ce028321c3efe5f6849f7831d8c5fe5fc4e6e2", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:11:12+00:00"}
{"task": "task_0006", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t6.sh", "exit": 0, "duration_s": 21.644, "stdout_tail_sha256": "625069f2ee6d8e41eb00af14da68816cbe639979296adc0375ffc20780fadf19", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:11:34+00:00"}
{"task": "task_0007", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t7.sh", "exit": 0, "duration_s": 0.06, "stdout_tail_sha256": "6d54a7fb09f6d6b7db49450f842bd397759f0aede9ed53b6e2edb0aa67271f6b", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:11:34+00:00"}
{"task": "task_0008", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t8.sh", "exit": 0, "duration_s": 0.205, "stdout_tail_sha256": "2f95596c0c69d4a3e9e5e800876d2007624acea62f90b35243c0dc3aa3f737c8", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:11:35+00:00"}
{"task": "task_0001", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t1.sh", "exit": 0, "duration_s": 23.758, "stdout_tail_sha256": "cced3c08cf1601b8177b7728e48c6add6982099d0785fa9fa4dd470b1f4900a5", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:11+00:00"}
{"task": "task_0002", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t2.sh", "exit": 0, "duration_s": 0.083, "stdout_tail_sha256": "a539567e2a1bad8a450d90a3998841921d5f353cdf8a13cd1dd9499117f71287", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:11+00:00"}
{"task": "task_0003", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t3.sh", "exit": 0, "duration_s": 0.146, "stdout_tail_sha256": "409ed685a0bde8695328c75cd1e92fb1e54398e22f911a252c1385f2262db9b3", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:11+00:00"}
{"task": "task_0004", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t4.sh", "exit": 0, "duration_s": 0.043, "stdout_tail_sha256": "8338018e17e2e8b7c9c6aeb8468f164497a332d7b9fbe69a027eec2f3eed2dab", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:11+00:00"}
{"task": "task_0005", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t5.sh", "exit": 0, "duration_s": 16.974, "stdout_tail_sha256": "99a2c86534e476be7ff3365a14ce028321c3efe5f6849f7831d8c5fe5fc4e6e2", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:28+00:00"}
{"task": "task_0006", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t6.sh", "exit": 0, "duration_s": 28.626, "stdout_tail_sha256": "625069f2ee6d8e41eb00af14da68816cbe639979296adc0375ffc20780fadf19", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:57+00:00"}
{"task": "task_0007", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t7.sh", "exit": 0, "duration_s": 0.062, "stdout_tail_sha256": "6d54a7fb09f6d6b7db49450f842bd397759f0aede9ed53b6e2edb0aa67271f6b", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:57+00:00"}
{"task": "task_0008", "cmd": "bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t8.sh", "exit": 0, "duration_s": 0.172, "stdout_tail_sha256": "2f95596c0c69d4a3e9e5e800876d2007624acea62f90b35243c0dc3aa3f737c8", "tree_fp": "85e5901717ba7746d406493c34a028b8314068453ac38a6f6bfb3606f10c66a8", "at": "2026-11-05T12:12:57+00:00"}
@@ -0,0 +1,80 @@
<!-- spec-format: 2 -->
# blog-5n:后台深度优化全家桶(14 项全量)
- **slug**: 2026-11-05_blog-5n-admin-deep-optimization
- **日期**: 2026-11-05
- **状态**: 进行中
- Git integration branch:`spec/2026-11-05_blog-5n-admin-deep-optimization`
- 证据协议:evidence: runs
- **提案**: 用户拍板「全部都做包括暂缓的」:写作链路(A1 翻译进构建链/A2 标签防手滑/A3 起手模板/A4 SEO 提示)、发布反馈(B1 部署状态条/B2 favicon+PWA)、内容管理(C1 首页设置分组/C2 内容仪表盘/C3 中英对照提示)、工具入口(D1 速通坞)、安全网(E1 回滚指引)、暂缓档转正(Sveltia 0.252→0.259 升级、暗色预览、媒体库归置)。多队友协同:@worker-one + 4 子代理 + 主会话集成。
## 1. 问题定义
- **项目目标**: 后台从「功能齐」到「全程顺手」——写作(双语零操作/标签不散/起手不空)、发布(状态可见/入口收拢/回滚有指引)、管理(分组/统计/对照)三条线一步到位;底层 Sveltia 升级到 0.259.0 并全量回归。
- **目标用户**: 站长(唯一编辑者,高频写作与内容管理)
- **核心价值**: 把这一周散落的「后台不好用」类反馈一次性清零;英文站从手动操作变全自动。
## 2. 假设与待确认
### 2.1 已确认事实
- TMT 翻译脚本 translate_posts.py 幂等 + locked 保护,可直接挂构建链
- Sveltia 最新 0.259.0(registry 核实);本站 bundle 0.252.0 pinned 本地
- Artalk 评论管理在 https://wait4x.online/artalk;rollback 脚本在 infra/deploy/ 已存在
- build.sh 为部署链核心(A1/B1/C2 三项都挂它,由主会话统一改避免竞态)
### 2.2 关键假设
- Sveltia 0.259 与 0.252 对本站用到的 API(registerEditorComponent/registerPreviewStyle/config schema)兼容(升级后全量回归验证)
- TMT 翻译单篇耗时 < 构建容忍度(守卫超时 180s,失败不阻断部署、保留旧英文版)
### 2.3 待确认问题
- [x] 全量范围:用户明确「全部都做包括暂缓的」
- [x] 协同分工:@worker-one 独立文件 ×2、子代理 ×4(新文件/独立文件)、共享文件(config.yml/index.html/build.sh)主会话独占
- [x] 媒体库归置取低风险形态:照片目录不动(摄取管线契约),归置=上传落点核对+文档+hints
### 2.4 可选解释与取舍
- A2 标签防手滑:若 Sveltia select 无 multiple 则退 list+常用标签 hint(不引自定义 widget)
- A3 起手模板:利用现有隐藏「写作模板」+ Sveltia 条目复制能力 + hint 指引(不建模板机制)
- C1 分组:仅改 home 单例形状(schema+yaml+消费端+后台表单),不动其他单例
### 2.5 已证伪路径
- 无(新增包)
## 3. 功能范围
### 3.1 核心功能(MVP)
- [x] T1 Sveltia 0.259 升级 + 全量回归(三脚本注册/预览/集合加载)
- [x] T2 A1 翻译进构建链(translate-step 守卫 + build.sh 挂载)
- [x] T3 A2+A3+A4+C3 表单优化批(config.yml:标签建议/模板指引/SEO 提示/中英对照)
- [x] T4 B1+B2+D1+E1 后台页面批(状态条/favicon/PWA/速通坞/回滚指引)
- [x] T5 C1 首页设置分组(schema 迁移 + 消费端 + 表单)
- [x] T6 C2 内容仪表盘(构建期统计 + 静态页)
- [x] T7 暗色预览(preview-style.js 双主题)
- [x] T8 媒体库归置(落点核对 + hints + 文档)
### 3.2 扩展功能
- 无
### 3.3 不在范围内
- 照片摄取管线目录迁移;TMT 凭据变更;评论系统本身
## 4. 最小实现路径
- 最简可行:新文件优先(守卫脚本/状态条/统计脚本/manifest)+ 共享文件主会话三批统一改(build.sh/config.yml/index.html)+ 升级先行(后续工作全部在新版上验证)
- 暂不引入: 自定义 Sveltia widget 开发、翻译并发加速
- 不做的抽象: 后台微前端/工具页框架(静态页够用)
### 5.1 验证策略
- 范围级别:package
- 变更对象: public/admin 全域 + build.sh + content/site schema + 文档
- 快速检查: `pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
- 集成检查: E2E 61/61 + content-lint 0/0/0 + admin 页加载零控制台错误(playwright 预登录态)+ build-status.json 产物断言 + 翻译守卫 dry-run
- 全项目检查: 部署后人工核验后台(登录态表单可编辑/状态条/速通坞/暗色预览);**升级条件**——仅当 Sveltia 升级引致行为差异时升级全站回归,本包已含升级后 E2E 全跑
- 升级触发: admin 为独立资源面,升级为「后台人工核验」即可
## 6. 成功标准与验证方式
- [x] Sveltia 0.259 上线且三脚本/预览/表单全可用 -> verify: task_0001
- [x] 中文保存→英文自动出(守卫不阻断)-> verify: task_0002
- [x] 表单四优化与页面四件套生效 -> verify: task_0003 + task_0004
- [x] 首页分组/仪表盘/暗色预览/媒体归置落地 -> verify: task_0005..task_0008
## 7. 关联与风险
- 最大风险 = Sveltia 升级破坏现有定制(三脚本 API 面);缓解:升级后立即 playwright 冒烟 + 生产灰度观察,异常即回滚 bundle(git revert 单文件)
- 翻译挂链风险 = TMT 配额/网络抖动;缓解:守卫永不阻断 + last-good 英文版保留
@@ -0,0 +1,47 @@
# Phase 5n:后台深度优化全家桶 - 任务拆解
## 使用规则
- 共享文件(public/admin/config.yml、public/admin/index.html、infra/deploy/build.sh)仅主会话编辑,队友产出片段/新文件
- E2E 口径:`pnpm astro build → gz-server.py 18925 <dist> → E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(基线 61)
## 阶段一:实现
- [x] task_0001 Sveltia 0.259 升级与回归
- claim: orchestrator
- boundary: public/admin/sveltia-cms.js(bundle 置换)
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t1.sh`
- [x] task_0002 A1 翻译进构建链
- claim: orchestrator + worker-one
- boundary: scripts/deploy/translate-step.sh(新,worker-one)、infra/deploy/build.sh(主会话挂载)
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t2.sh`
- [x] task_0003 A2+A3+A4+C3 表单优化批
- claim: orchestrator
- boundary: public/admin/config.yml(标签/模板指引/SEO/中英 hint)、content/posts/writing-template.md
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t3.sh`
- [x] task_0004 B1+B2+D1+E1 后台页面批
- claim: orchestrator + worker-one + 子代理
- boundary: public/admin/deploy-status.js(新,子代理)、public/admin/manifest.webmanifest(新,子代理)、docs/后台回滚指引.md(新,worker-one)、public/admin/index.html(主会话接线)
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t4.sh`
- [x] task_0005 C1 首页设置分组
- claim: orchestrator + 子代理
- boundary: src/content.config.ts(siteHome schema)、content/site/home.yaml、src/lib/site-content.ts(消费端)、public/admin/config.yml(home 条目,主会话拼装子代理片段)
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t5.sh`
- [x] task_0006 C2 内容仪表盘
- claim: 子代理
- boundary: scripts/build-perf/gen-admin-stats.mjs(新)、infra/deploy/build.sh 挂载(主会话)
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t6.sh`
- [x] task_0007 暗色预览
- claim: 子代理
- boundary: public/admin/preview-style.js(双主题改造)
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t7.sh`
- [x] task_0008 媒体库归置
- claim: orchestrator
- boundary: public/admin/config.yml(media_folder hints)、docs/管理台.md
- verify: `bash .spec/specs/2026-11-05_blog-5n-admin-deep-optimization/evidence/verify_t8.sh`
+1 -1
View File
@@ -6,7 +6,7 @@ tags:
- 写作
- 模板
pinned: true
description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。
description: 「复制起手」母版(后台可见、前台隐藏):演示全站排版能力——标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。新文章从它 Duplicate 开始。
hidden: true
draft: false
hiddenTag: true
+14 -12
View File
@@ -1,15 +1,17 @@
tagline:
zh: 拥抱开源,热爱开源
en: Embracing open source, loving open source.
latestPhotos: 4
perTrackLimit: 6
sections:
tracks: true
photos: true
heatmap: true
projects: true
avatar: ./assets/118a04cd0986b9c5.jpg
favicon: assets/118a04cd0986b9c5.jpg
# 5n C1 后台折叠分组:平铺字段收进 identity/copy/content 三个可选组(组内压平叶子),socials 顶层原位。
# 旧→新映射见 src/content.config.ts 的 siteHome schema 注释;本次迁移值逐字保留,零功能变化。
identity:
tagline_zh: 拥抱开源,热爱开源
tagline_en: Embracing open source, loving open source.
avatar: ./assets/118a04cd0986b9c5.jpg
favicon: assets/118a04cd0986b9c5.jpg
content:
latestPhotos: 4
perTrackLimit: 6
sections_tracks: true
sections_photos: true
sections_heatmap: true
sections_projects: true
socials:
- label: GitHub
url: https://github.com/wait4xx
+26
View File
@@ -0,0 +1,26 @@
# 后台回滚指引
> **一句话版本**:改坏了内容 → 后台再保存一次对的就好;整次部署坏了 → Gitea 网页里把坏提交 Revert 掉;全站级灾难 → 找主理人执行灾备开关。
## 什么时候需要哪一层
| 情形 | 用什么 | 频率预期 |
|---|---|---|
| 某篇文章/瞬间/设置改坏了 | **后台再改一次**(Git 只是保险,不是日常工具) | 常见 |
| 一次部署把多处弄坏、说不清单点 | **Gitea 网页 Revert**(下面三步) | 偶发 |
| 静态站整体故障无法快速修复 | 灾备开关切回旧 Halo(仅主理人执行,见文末) | 理论上不会 |
## Gitea 网页 Revert 三步
1. 打开提交历史:https://git.wait4x.online/wait4x/blog/commits/branch/main
(每次后台「保存」都是一条提交,时间与描述能对上)
2. 点开**弄坏的那条提交** → 右上角 **Revert** 按钮 → 确认。Gitea 会自动生成一条反向提交并提示创建 PR/直接合并——选择直接合并到 `main`
3. 推送后 30–60 秒自动构建部署,站点回到坏提交**之前**的状态
注意:
- Revert 撤销的是**整条提交**(那一次保存改动的所有内容)。如果好改动和坏改动混在同一次保存里,别 Revert,回后台把坏的部分再改回来更精准
- 只想回退构建但保留内容?不存在这种需求场景——构建永远反映 main 最新状态
## 灾备开关(仅主理人)
`infra/deploy/rollback.sh` 是**整站切换**:把 nginx 从静态站切回旧 Halo(127.0.0.1:18090),秒级生效。这是静态站全面故障时的逃生通道,日常内容问题永远用不上它。预演:`bash infra/deploy/rollback.sh`(默认 dry-run);执行需 root + `--exec`。
+15
View File
@@ -240,3 +240,18 @@ token 位在 `~/docker_apps/blog/github-token.txt`(本机,0600)。
- 启动时探测 /api/forgejo 报 404 属良性回落(日志噪音,无影响)
- 大视频仍走仓外 store + /media/ 绝对路径(见写作指南体积纪律表)
## 媒体文件落点地图(5n 归置)
上传的文件各归各位,不用记——表里的「落点」由后台自动处理:
| 在哪上传 | 落到仓库哪 | 网页上从哪读 |
|---|---|---|
| 文章封面/文内图 | content/posts/assets/ | 构建期自动优化为 WebP |
| 瞬间配图 | 同上(按集合相对路径) | 同上 |
| 主页头像/关于页图 | content/site/assets/ | 构建期优化 |
| 站点图标 favicon | public/icons/ | 原样分发 |
| 图库照片 | content/photos/(摄取管线) | 灯箱走原图、列表走优化图 |
「媒体库」页面(后台左侧 Media)列出的是图库照片目录。要找某篇文章的配图,直接进那篇文章的编辑页点开图片字段即可。
+20 -1
View File
@@ -27,7 +27,20 @@ git clean -fd --exclude node_modules
SHA=$(git rev-parse --short HEAD)
echo "[build] 构建源: origin/main @ $SHA"
# 5n B1:构建状态生命周期(后台 /admin/deploy-status.js 轮询 /admin/build-status.json)。
# building 态写在 rsync 前(此时 staging 仍服务上一版),ok 态写在末尾并解除 trap;
# 任何中途退出(set -e 炸链)由 trap 兜底写 fail——状态文件每次 rsync 后重写,不依赖 dist
mkdir -p "$STAGING/admin" 2>/dev/null || true
write_status() { printf '{"sha":"%s","time":"%s","state":"%s","pages":%s}\n' "$SHA" "$(date -Is)" "$1" "${2:-0}" > "$STAGING/admin/build-status.json" 2>/dev/null || true; }
trap 'write_status fail' EXIT
write_status building
pnpm install --frozen-lockfile >/dev/null
# 5n A1:TMT 翻译先于构建(译文进 content/posts-en/,随同一次部署上线)。
# 守卫先行 + 守卫自身永不阻断(超时/无凭据/失败都只 WARN 并保留旧英文版)
[ -f scripts/deploy/translate-step.sh ] && bash scripts/deploy/translate-step.sh || echo "[build] translate-step 未就绪,跳过"
pnpm astro build
# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链;
@@ -39,6 +52,9 @@ pnpm astro build
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
# 5n C2:内容仪表盘(产物 dist/admin/stats.{html,json},随 rsync 上线)
[ -f scripts/build-perf/gen-admin-stats.py ] && python3 scripts/build-perf/gen-admin-stats.py dist || echo "[build] stats 生成器未就绪,跳过"
# README 动效资产(5i 引入、5j T2 退役):贡献蛇与 streak 快照已删——连续贡献改为
# 热力图同源本地计算(见 src/lib/heatmap.ts 的 streakStats),界面上标注"数据更新于"。
# fetch-readme-assets.mjs 保留骨架但 ASSETS 已清空,如将来要加新的自托管资产再启用。
@@ -52,7 +68,10 @@ rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/"
ORIG="$HOME/docker_apps/blog/photos-originals"
if [ -d "$ORIG" ]; then rsync -a "$ORIG/" "$STAGING/photos-originals/"; fi
printf 'built %s at %s\n' "$SHA" "$(date -Is)" > "$STAGING/.build-stamp"
echo "[build] done: $(find "$STAGING" -name '*.html' | wc -l) html files @ $SHA"
PAGES=$(find "$STAGING" -name '*.html' | wc -l)
echo "[build] done: $PAGES html files @ $SHA"
write_status ok "$PAGES"
trap - EXIT
# 生产冒烟(T3):staging 三断言。首版容忍失败(robots 待 T1 落地,smoke FAIL 属预期),
# T1 收编后改硬门:去掉 `|| echo` 让断言失败直接炸构建
+48 -60
View File
@@ -24,6 +24,7 @@ collections:
label_singular: 文章
folder: content/posts
create: true
hint: 新文章可从置顶的《写作模板》复制起手(列表打开模板 → ⋯ 菜单 → Duplicate);英文版构建时自动机翻上线,无需在此维护
# 中文主稿;英文为 TMT 预翻译入仓的 .en.md(posts-en 集合不在后台,防误改译文)
slug: '{{slug}}'
summary: '{{pubDate}} · {{title}}'
@@ -34,14 +35,14 @@ collections:
media_folder: assets
public_folder: ./assets
fields:
- { label: 标题, name: title, widget: string }
- { label: 标题, name: title, widget: string, hint: '建议 ≤32 字(搜索结果超长会截断)' }
- { label: 日期, name: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
- { label: 标签, name: tags, widget: list, required: true, hint: '至少一个(可写 read/写作/站点更新等);schema 必填,漏了构建会拦' }
- { label: 标签, name: tags, widget: list, required: true, hint: '至少一个;优先复用现有标签:read(书评)/ 写作 / 站点更新——避免同义新造(如 阅读/read 分家);schema 必填,漏了构建会拦' }
- { label: 置顶, name: pinned, widget: boolean, required: false, default: false, hint: '置顶文章排在首页/归档/书架最前;只影响顺序,不改日期、URL 与 RSS 时间语义' }
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false, hint: '60–120 字最佳(搜索摘要与列表导语共用)' }
- { label: 书封图, name: cover, widget: image, required: false, hint: '上传后自动生成,无需手填路径;仅阅读轨展示' }
- label: 附视频(可选)
name: video
@@ -108,7 +109,7 @@ collections:
widget: markdown
required: false
hint: '与图片至少填一项。支持 markdown 与排版类 HTML,例如 <span class="c-blue font-kai">彩色+楷体</span>;选中文字 → Insert ▸ 排版 可视化选字体/颜色(预览窗格直接显色显字体)。装饰色仍守“外壳灰阶、彩色只作强调”的纪律'
- { label: 图片, name: image, widget: image, required: false, hint: '与文字至少填一项;建议先压缩' }
- { label: 图片, name: image, widget: image, required: false, hint: '与文字至少填一项;上传后构建自动优化(WebP),原图无需手工压缩' }
- label: 标签
name: tags
widget: list
@@ -185,57 +186,58 @@ collections:
media_folder: assets
public_folder: ./assets
fields:
- label: 身份语
name: tagline
- label: 身份名片
name: identity
widget: object
required: false
hint: 首页大字身份语(双语,markdown 富文本);可写 <span class="font-kai c-blue">…</span> 做局部强调
collapsed: true
hint: 头像、身份语、副标题与站点图标(首页名片区 + 浏览器标签图标)
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 页面排版覆盖(可选)
name: typography
- { label: 身份语(中文), name: tagline_zh, widget: string, required: false, hint: '首页大字身份语(markdown 富文本);可写 <span class="font-kai c-blue">…</span> 做局部强调' }
- { label: 身份语(英文), name: tagline_en, widget: string, required: false }
- { label: 副标题(中文,markdown 短文本), name: subtitle_zh, widget: string, required: false, hint: '渲染在身份语下方;留空不渲染' }
- { label: 副标题(英文), name: subtitle_en, widget: string, required: false }
- label: 头像(可选)
name: avatar
widget: image
required: false
hint: '留空 = 内置渐变占位;上传后自动优化为 60px WebP'
- label: 站点图标 favicon
name: favicon
widget: image
required: false
# media_folder 按 file 集合语义相对 content/site/ 解析(写 content/site/assets 会嵌套翻倍——5l 实测教训)
media_folder: ../../public/icons
public_folder: /icons
hint: '直接上传 .svg/.ico/.png(建议 64–512px 方图);留空回退 /favicon.svg'
- label: 文案与排版
name: copy
widget: object
required: false
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
collapsed: true
hint: 公告(渲染在名片区下方,留空不显示)+ 页面排版覆盖(本页正文默认字体/强调色/字号缩放,不填用全站默认,只影响主页)
fields:
- { label: 公告(中文,markdown), name: announcement_zh, widget: markdown, required: false, hint: '渲染在名片区下方;留空则不显示公告块' }
- { label: 公告(英文), name: announcement_en, widget: markdown, required: false }
- label: 正文字体类
name: font
name: typography_font
widget: select
required: false
hint: 整页正文换字族;艺术/展示类字族体积大。5i 新增六款(小薇/庆科黄油/马善政/柳建毛草/得意黑/新晰黑)均按站内用字切片,单页增量 27–46KB;站外用字(生僻字)会回落系统字族,需按 docs 里的命令重切
options: [font-serif, font-kai, font-sans, font-mono, font-song, font-kai-sys, font-hei-sys, font-fangsong, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
- label: 强调色类
name: accent
name: typography_accent
widget: select
required: false
options: [ink-accent, ink-accent-soft, c-blue, c-blue-soft, c-teal, c-teal-soft, c-green, c-green-soft, c-yellow, c-yellow-soft, c-orange, c-orange-soft, c-red, c-red-soft, c-pink, c-pink-soft, c-purple, c-purple-soft]
- { label: 字号缩放, name: scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05, hint: '0.8–1.25;1 = 全站默认' }
- label: 公告(可选,markdown)
name: announcement
- { label: 字号缩放, name: typography_scale, widget: number, required: false, value_type: float, min: 0.8, max: 1.25, step: 0.05, hint: '0.8–1.25;1 = 全站默认' }
- label: 内容与开关
name: content
widget: object
required: false
hint: 渲染在名片区下方;留空则不显示公告块
collapsed: true
hint: 各区块条数与显隐开关;关掉的区块不渲染,越界数值自动夹取(默认 = 现状)
fields:
- { label: 中文, name: zh, widget: markdown, required: false }
- { label: 英文, name: en, widget: markdown, required: false }
- { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
- label: 轨细分开关(首页文章列表)
name: postsTracks
widget: object
required: false
hint: 关掉某轨则该轨文章不在首页列出(全开=现状)
fields:
- { label: 阅读轨, name: reading, widget: boolean, required: false, default: true }
- { label: 技术轨, name: tech, widget: boolean, required: false, default: true }
- { label: 照片轨, name: photo, widget: boolean, required: false, default: true }
- label: 副标题(可选,markdown 短文本)
name: subtitle
widget: object
hint: 渲染在身份语下方;留空不渲染
fields:
- { label: 中文, name: zh, widget: string, required: false }
- { label: 英文, name: en, widget: string, required: false }
- label: 最新影像张数
name: latestPhotos
widget: number
@@ -252,29 +254,14 @@ collections:
min: 4
max: 8
hint: 阅读轨/技术轨各显示条数,留空默认 6
- label: 区块开关
name: sections
widget: object
required: false
hint: 关掉的区块不渲染(默认全开)
fields:
- { label: 文章列表, name: tracks, widget: boolean, required: false, default: true }
- { label: 最新影像, name: photos, widget: boolean, required: false, default: true }
- { label: 贡献热力图, name: heatmap, widget: boolean, required: false, default: true }
- { label: 项目墙, name: projects, widget: boolean, required: false, default: true }
- label: 头像(可选)
name: avatar
widget: image
required: false
hint: '留空 = 内置渐变占位;上传后自动优化为 60px WebP'
- label: 站点图标 favicon
name: favicon
widget: image
required: false
# media_folder 按 file 集合语义相对 content/site/ 解析(写 content/site/assets 会嵌套翻倍——5l 实测教训)
media_folder: ../../public/icons
public_folder: /icons
hint: '直接上传 .svg/.ico/.png(建议 64–512px 方图);留空回退 /favicon.svg'
- { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
- { label: 阅读轨, name: postsTracks_reading, widget: boolean, required: false, default: true, hint: '关掉则阅读轨文章不在首页列出(归档页仍可见);三轨全开 = 现状' }
- { label: 技术轨, name: postsTracks_tech, widget: boolean, required: false, default: true }
- { label: 照片轨, name: postsTracks_photo, widget: boolean, required: false, default: true }
- { label: 显示文章列表, name: sections_tracks, widget: boolean, required: false, default: true }
- { label: 显示最新影像, name: sections_photos, widget: boolean, required: false, default: true }
- { label: 显示贡献热力图, name: sections_heatmap, widget: boolean, required: false, default: true }
- { label: 显示项目墙, name: sections_projects, widget: boolean, required: false, default: true }
- label: 社交链接(顺序即展示顺序)
name: socials
widget: list
@@ -289,6 +276,7 @@ collections:
required: false
options: [github, rss, email, bilibili, x]
hint: '不在列表里的留空 → 显示纯文字按钮'
- name: about
label: 关于页
file: content/site/about.yaml
+161
View File
@@ -0,0 +1,161 @@
/**
* 后台部署状态条(5n B1):Sveltia 管理页右下角浮层,轮询站点构建状态。
* spec:.spec/specs/2026-11-05_blog-5n-admin-deep-optimization(T4)
*
* JSON 接口契约(build.sh 每次部署写 /admin/build-status.json):
* { "sha": "a555a3e…", // 部署 commit(展示取前 7 位)
* "time": "2026-11-05T19:20:25+08:00", // 构建完成时刻(展示取 HH:MM)
* "state": "ok" | "building" | "fail",
* "pages": 53 } // 页面数(仪表盘消费,本条不读)
* 文件缺失 / 响应非 200 / JSON 解析失败 / state 非法 → 视为「不可知」:
* 胶囊淡出、不报错,下一轮照常再试——状态条是增益件,接口坏了不能吵主人。
*
* 为什么轮询而不是 SSE/WebSocket:本站是纯静态托管(CDN 上没有可驻留的
* 服务端进程,无推送通道);「保存→看部署结果」的节奏里 10s 间隔已足够
* 感知,且零服务端成本、零额外基础设施。
*/
(function () {
'use strict';
if (window.DEPLOY_STATUS) return; // 防重复初始化:脚本被引入两次时静默退出
var POLL_MS = 10000;
var timer = null; // null = 停表中(初始 / 页面隐藏)
var STATES = {
ok: { bg: 'rgba(28,30,34,.92)', label: '已上线' },
building: { bg: 'rgba(52,84,209,.92)', label: '构建中…' },
fail: { bg: 'rgba(179,32,32,.92)', label: '构建失败' },
};
/** 构建中呼吸动画的 keyframes 无法用元素内联样式表达,注入一次 <style>(仍零外部资源) */
function injectKeyframes() {
var style = document.createElement('style');
style.id = 'deploy-status-style';
style.textContent = '@keyframes deploy-status-breath{0%,100%{opacity:.7}50%{opacity:1}}';
document.head.appendChild(style);
return style;
}
function createPill() {
var el = document.createElement('div');
el.setAttribute('role', 'status'); // 状态变化对读屏器温和播报
// 全部样式内联在 JS 里:不依赖外部 CSS,单文件即可被任意页面引入
el.style.cssText = [
'position:fixed',
'right:14px',
'bottom:14px',
'z-index:10000',
'padding:6px 12px',
'font:12px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif',
'color:#fff',
'border-radius:999px',
'box-shadow:0 2px 10px rgba(0,0,0,.18)',
'user-select:none',
'opacity:0', // 初始隐身,拿到合法数据才淡入
'transition:opacity .2s,background-color .2s', // 出现与状态切换均为 200ms 过渡
'pointer-events:none', // 默认不挡下层 UI;仅 fail 态(可点击)放开
].join(';');
el.addEventListener('click', function () {
// 只有 fail 态放开 pointer-events,此监听只会在「构建失败」时被触发
location.reload();
});
document.body.appendChild(el);
return el;
}
/** 从 ISO 串直接截 HH:MM 而不走 Date 本地化:展示构建机墙钟时间,不随浏览器时区漂移 */
function hhmm(iso) {
var m = /T(\d{2}:\d{2})/.exec(String(iso || ''));
if (m) return m[1];
var d = new Date(iso); // 兜底:万一 build.sh 改用非 ISO 格式,退本地时区展示
if (isNaN(d.getTime())) return '';
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
return pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function boot() {
var el = createPill();
function hide() {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
el.style.animation = 'none';
el.title = '';
}
function render(data) {
var conf = STATES[data && data.state];
if (!conf) { // 不可知 = 淡出静默,不区分缺文件/坏 JSON/非法枚举(对用户都是同一件事)
hide();
return;
}
if (data.state === 'ok') {
var parts = [];
var sha7 = String(data.sha || '').slice(0, 7);
if (sha7) parts.push(sha7);
var t = hhmm(data.time);
if (t) parts.push(t);
el.textContent = parts.length ? conf.label + ' · ' + parts.join(' · ') : conf.label;
} else {
el.textContent = conf.label;
}
el.style.backgroundColor = conf.bg;
// 呼吸仅限 building;离开时置 none,状态间观感交给 background-color 的 200ms 过渡
el.style.animation = data.state === 'building'
? 'deploy-status-breath 1.6s ease-in-out infinite'
: 'none';
if (data.state === 'fail') {
el.style.pointerEvents = 'auto';
el.style.cursor = 'pointer';
el.title = '点击刷新页面'; // fail 的唯一动作是刷新:取回最新静态产物(含本文件与状态 JSON)
} else {
el.style.pointerEvents = 'none';
el.style.cursor = 'default';
el.title = '';
}
el.style.opacity = '1';
}
function poll() {
fetch('/admin/build-status.json', { cache: 'no-store' }) // no-store:状态文件必须每次拿实况
.then(function (r) { return r.ok ? r.json() : null; }) // 404 等非 200 → 当不可知处理
.then(function (data) { render(data && typeof data === 'object' ? data : {}); })
.catch(function () { hide(); }); // 网络层与 JSON 解析失败同路:淡出,下一轮再试
}
function loop() {
poll();
timer = setTimeout(loop, POLL_MS);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
// 回来立即拉(隐藏期间可能已构建完成),并重置 10s 节拍
if (timer) clearTimeout(timer);
loop();
} else if (timer) {
// 隐藏即停:省请求,也避免后台标签被浏览器节流的定时器醒来后集中补发
clearTimeout(timer);
timer = null;
}
});
window.DEPLOY_STATUS = {
// 挂载即去重标记;stop 供控制台/测试卸载(页面自身是 SPA,正常导航不会触发)
stop: function () {
if (timer) clearTimeout(timer);
timer = null;
el.remove();
var style = document.getElementById('deploy-status-style');
if (style) style.remove();
delete window.DEPLOY_STATUS;
},
};
if (document.visibilityState === 'visible') loop();
// 初始就隐藏(预渲染标签页):等 visibilitychange 回来再首拉
}
if (document.body) boot();
else document.addEventListener('DOMContentLoaded', boot); // 若被放到 <head> 且未加 defer
})();
+24 -2
View File
@@ -3,10 +3,30 @@
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<title>Sveltia CMS</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wait4x 后台</title>
<!-- 站点图标 + PWA(5n B2:手机加主屏独立入口) -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="./manifest.webmanifest" />
<style>
/* 速通坞(5n D1):日常要跳的五个入口常驻右上,不挡 Sveltia 主 UI(对话层 z 更高) */
#quick-dock{position:fixed;top:10px;right:12px;z-index:9000;display:flex;gap:4px;
font:12px/1 system-ui,sans-serif;opacity:.78;transition:opacity .2s}
#quick-dock:hover{opacity:1}
#quick-dock a{color:#5b6472;text-decoration:none;padding:4px 8px;border-radius:6px;background:rgba(127,131,140,.12)}
#quick-dock a:hover{color:#3454d1;background:rgba(52,84,209,.1)}
@media (max-width:720px){#quick-dock a:not(.qd-key){display:none}}
</style>
</head>
<body>
<!-- pinned 本地 bundle(0.252.0):官方模板仅此一个 script,无 CSS link、无 type=module -->
<nav id="quick-dock" aria-label="快捷入口">
<a class="qd-key" href="https://wait4x.online/artalk/" target="_blank" rel="noopener">评论</a>
<a class="qd-key" href="./stats.html" target="_blank" rel="noopener">仪表盘</a>
<a href="https://git.wait4x.online/wait4x/blog" target="_blank" rel="noopener">仓库</a>
<a href="https://git.wait4x.online/wait4x/blog/src/branch/main/docs/%E5%90%8E%E5%8F%B0%E5%9B%9E%E6%BB%9A%E6%8C%87%E5%BC%95.md" target="_blank" rel="noopener">回滚</a>
<a href="https://git.wait4x.online/wait4x/blog/src/branch/main/docs/%E7%AE%A1%E7%90%86%E5%8F%B0.md" target="_blank" rel="noopener">手册</a>
</nav>
<!-- pinned 本地 bundle(0.259.0,5n T1 升级):官方模板仅此一个 script,无 CSS link、无 type=module -->
<script src="./sveltia-cms.js"></script>
<!-- 艺术字体切片(unicode-range 按需取片):选择面板的示例字形用真实字体渲染 -->
<link rel="stylesheet" href="/fonts/subset/hwmc/fonts.css" />
@@ -20,5 +40,7 @@
<script src="./typography-classes.js"></script>
<script src="./typography-editor.js"></script>
<script src="./preview-style.js"></script>
<!-- 部署状态条(5n B1):轮询 /admin/build-status.json -->
<script src="./deploy-status.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "wait4x 后台",
"short_name": "后台",
"start_url": "/admin/",
"scope": "/admin/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3454d1",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+78 -8
View File
@@ -1,5 +1,5 @@
/**
* 后台预览一致性(5l T1):把站点排版效果注入 Sveltia 预览窗格。
* 后台预览一致性(5l T1 / 5n T7):把站点排版效果注入 Sveltia 预览窗格。
*
* 现状问题:markdown 里的排版类(font- 字体、c-/ink- 颜色)在预览里是素文本——
* 类名随 DOM 保留(typography-editor 的 toPreview 输出带 class),但预览
@@ -12,6 +12,10 @@
* 2. 自托管艺术字体挂切片 CSS(public/fonts/<family>/fonts.css,
* unicode-range 按需取片——预览里只拉用到的字所落在的切片,不整包)。
*
* 暗色(5n T7):所有色值走 CSS 自定义属性,变量缺省值 = 亮态(与改造前同一批
* 字面量,无暗色命中时逐字节等价),末尾一段 @media (prefers-color-scheme: dark)
* 统一换档——后台切暗色时预览不再用亮态色值配暗底。
*
* serif/kai/kuaile 的 CSS 经 Vite ?url 打包成哈希名,后台引不到稳定 URL,
* 预览走字体栈里的系统回退(Kaiti SC/Songti)——近似可见,文档已注明。
* 加载顺序:须在 typography-classes.js 之后、CMS init 之前(index.html 固定序)。
@@ -20,30 +24,96 @@
const CLASSES = window.TYPOGRAPHY_CLASSES;
if (!CLASSES || !window.CMS || !window.CMS.registerPreviewStyle) return;
/**
* 基础排版的暗态值(亮态值不在这里登记,直接作各条 var() 的回落值)。
* 出处:src/styles/tokens.css `[data-theme="dark"]` 段,勿凭观感改。
*/
const BASE_DARK = {
'pv-accent': '#8ea2ff', // --accent
'pv-line': '#262b33', // --line(站点暗色分隔线就是这个档)
'pv-quote-ink': '#8b8f98', // --ink-3
'pv-code-bg': '#1f242b', // --bg-soft
};
/**
* 颜色类暗态值,键 = typography-classes.js 里的 cls。同样取自暗色 tokens 段:
* 站点语义色板暗态是独立取值(不是亮态加亮),硬套亮态在 #191d23 底上不达 AA。
*/
const CLASS_DARK = {
'ink-1': '#f5f5f7', // --ink-1
'ink-2': '#c9ccd4', // --ink-2
'ink-3': '#8b8f98', // --ink-3
// 站点 CSS 里 ink-muted 映射 --ink-4,而暗态 --ink-4 (#5c6066) 是装饰档,
// 暗底上近乎不可见;亮态 swatch 本就取的是 --ink-faint 的值,暗态随之一致
'ink-muted': '#84888f', // --ink-faint
'ink-warm': '#d6cbbd', // --ink-warm
'ink-cool': '#b9c6d6', // --ink-cool
'ink-faint': '#84888f', // --ink-faint
'ink-accent': '#8ea2ff', // --accent
'ink-accent-soft': '#a8b7f7', // --accent-soft-text
'c-blue': '#7fb0ff',
'c-blue-soft': '#3381ff',
'c-teal': '#6fd6c8',
'c-teal-soft': '#2a9384',
'c-green': '#7ddf99',
'c-green-soft': '#269a47',
'c-yellow': '#ffd166',
'c-yellow-soft': '#af7a00',
'c-orange': '#ffb163',
'c-orange-soft': '#d16800',
'c-red': '#ff8b8b',
'c-red-soft': '#ff2828',
'c-pink': '#ff9ecb',
'c-pink-soft': '#ff0d7e',
'c-purple': '#c4a6ff',
'c-purple-soft': '#9a67ff',
};
/**
* 未登记暗态值的类(清单后续新增)用 color-mix 提亮近似——宁可偏亮,
* 也不要暗底上跑亮态深色(那是这次要修的问题本身)。
*/
const approxDark = (swatch) => `color-mix(in oklab, ${swatch} 82%, #fff)`;
/** 预览基调:近似站点 .rich 的通用排版(目标是类效果可见,非像素级还原) */
const baseCss = [
'p{margin:.6em 0;line-height:1.75}',
'h1,h2,h3,h4{font-weight:600;line-height:1.4;margin:1.2em 0 .5em}',
'h2{font-size:1.3em}h3{font-size:1.15em}',
'ul,ol{padding-left:1.5em;margin:.6em 0}li{margin:.25em 0}',
'a{color:#3454d1;text-decoration:underline}',
'blockquote{margin:.8em 0;padding:.2em 1em;border-left:3px solid #e6e7eb;color:#6b6e78}',
"code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.92em;background:#f2f3f5;padding:.1em .35em;border-radius:4px}",
'a{color:var(--pv-accent,#3454d1);text-decoration:underline}',
'blockquote{margin:.8em 0;padding:.2em 1em;border-left:3px solid var(--pv-line,#e6e7eb);color:var(--pv-quote-ink,#6b6e78)}',
"code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.92em;background:var(--pv-code-bg,#f2f3f5);padding:.1em .35em;border-radius:4px}",
'img{max-width:100%}',
'hr{border:none;border-top:1px solid #e6e7eb;margin:1.5em 0}',
'hr{border:none;border-top:1px solid var(--pv-line,#e6e7eb);margin:1.5em 0}',
'strong{font-weight:700}',
].join('');
/** 排版类规则:清单驱动(swatch/ family 即预览所用值,亮态口径) */
/** 排版类规则:清单驱动(swatch/family 亮态值作 var() 回落 = 面板所见) */
const rules = [];
const darkDecls = [];
for (const f of CLASSES.font || []) {
if (f.family) rules.push(`.${f.cls}{font-family:${f.family}}`);
}
for (const c of CLASSES.color || []) {
if (c.swatch) rules.push(`.${c.cls}{color:${c.swatch}}`);
if (!c.swatch) continue;
const name = `--pv-${c.cls}`;
rules.push(`.${c.cls}{color:var(${name},${c.swatch})}`);
darkDecls.push(`${name}:${CLASS_DARK[c.cls] || approxDark(c.swatch)}`);
}
for (const [name, value] of Object.entries(BASE_DARK)) darkDecls.push(`--${name}:${value}`);
window.CMS.registerPreviewStyle(baseCss + rules.join(''), { raw: true });
/**
* 为什么用媒体查询而不是监听「后台切主题」:Sveltia 的 registerPreviewStyle 是
* 一次性注入(无更新/移除接口),registerEventListener 只支持 pre/postSave 与
* pre/postPublish 这类保存事件,没有任何主题切换回调;后台主题默认跟随系统,
* 预览文档的 prefers-color-scheme 与后台同源,浏览器自己换档即可,零重注入。
* 代价:用户在后台显式选 light 而系统是 dark 时预览偏暗——已知取舍,比监听
* Sveltia 内部 DOM(非公开契约)稳妥。
*/
const darkCss = darkDecls.length ? `@media (prefers-color-scheme: dark){:root{${darkDecls.join(';')}}}` : '';
window.CMS.registerPreviewStyle(baseCss + rules.join('') + darkCss, { raw: true });
// 字体切片(URL 模式):预览文档自己的 @font-face;去重后逐个挂
const seen = new Set();
File diff suppressed because it is too large. Load diff
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
构建期内容仪表盘生成器(blog-5n 后台深度优化包)
为什么需要:后台(Sveltia CMS)只管编辑不管盘点,"站上到底有多少篇可见文、
草稿积了多少、字数体量多大"每次都要手动数。本脚本在构建期扫 content/ 一次性
算好统计,产出两个静态产物挂到 dist/admin/ 下,浏览器直接打开 stats.html
即可看板(无 JS、无外部资源,纯构建期生成,不给运行时加任何负担)。
挂载位置:infra/deploy/build.sh 在 `pnpm astro build` 之后调用
`python3 scripts/build-perf/gen-admin-stats.py dist`(写不进产物树的活产物
就由构建脚本补写)。
用法:python3 scripts/build-perf/gen-admin-stats.py <dist目录>
退出码:0 成功;1 参数缺失(或 pyyaml 缺失)。单篇 frontmatter 解析失败
只告警跳过,不让整次构建失败。
"""
import html
import json
import re
import sys
import time
from datetime import date, datetime
from pathlib import Path
try:
import yaml
except ImportError:
print('[gen-admin-stats] 缺少 pyyaml:pip install pyyaml')
sys.exit(1)
# 仓库根 = 本文件上三级(scripts/build-perf/ → scripts/ → 根),
# 与 config-check.py 一样假定 content/ 布局,但从脚本自身定位根目录,
# 这样 build.sh 从任意 cwd 调用都不依赖工作目录。
ROOT = Path(__file__).resolve().parent.parent.parent
POSTS_DIR = ROOT / 'content' / 'posts'
POSTS_EN_DIR = ROOT / 'content' / 'posts-en'
MOMENTS_DIR = ROOT / 'content' / 'moments'
PHOTOS_DIR = ROOT / 'content' / 'photos'
# zh_chars 的正文剥离规则(粗粒度写作量口径):剔除代码块/图片/链接目标/HTML
# 这些"非正文"成分,保留链接文字、行内代码等文字本身,再去 markdown 标记。
# 顺序敏感:先整块删 fenced code 与图片,再做行内替换,最后删行首标记。
_MD_STRIP = [
(re.compile(r'```.*?```', re.S), ''), # 围栏代码块整块删(代码不是写作量)
(re.compile(r'!\[[^\]]*\]\([^)]*\)'), ''), # 图片(alt 不是正文)
(re.compile(r'\[([^\]]*)\]\([^)]*\)'), r'\1'), # 链接只留文字
(re.compile(r'<[^>\n]+>'), ''), # 裸 HTML 标签(瞬间体例里常见 span)
(re.compile(r'^\s{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])\s*', re.M), ''), # 标题/引用/列表行首标记
(re.compile(r'[*_~]{1,3}'), ''), # 强调/删除线标记
(re.compile(r'^\s*\|?[-: |]+\|?\s*$', re.M), ''), # 表格分隔行
(re.compile(r'\|'), ''), # 表格竖线
]
_WHITESPACE = re.compile(r'[\s\u3000\u00a0\ufeff]+')
def split_frontmatter(text):
"""切出 (frontmatter 文本, 正文)。无 frontmatter 时返回 (None, 全文)。"""
if not text.startswith('---'):
return None, text
lines = text.splitlines(keepends=True)
for i in range(1, len(lines)):
if lines[i].strip() == '---':
return ''.join(lines[1:i]), ''.join(lines[i + 1:])
return None, text
def tag_list(frontmatter):
"""tags 归一为字符串列表(容忍单值标量写法与缺省)。"""
tags = frontmatter.get('tags') if isinstance(frontmatter, dict) else None
if tags is None:
return []
if isinstance(tags, str):
return [tags]
return [str(t) for t in tags]
def pubdate_iso(frontmatter):
"""pubDate 归一为可比对象;返回 (可比键, ISO 字符串) 或 (None, None)。"""
value = frontmatter.get('pubDate') if isinstance(frontmatter, dict) else None
if isinstance(value, (date, datetime)):
return value, value.date().isoformat() if isinstance(value, datetime) else value.isoformat()
if isinstance(value, str) and value:
try:
return date.fromisoformat(value[:10]), value[:10]
except ValueError:
return None, None
return None, None
def count_body_chars(body):
"""正文剥离 markdown 标记与空白后的字符数(粗粒度)。"""
text = body
for pattern, repl in _MD_STRIP:
text = pattern.sub(repl, text)
return len(_WHITESPACE.sub('', text))
def scan_posts():
"""扫 content/posts/,按站点可见性契约分类计数。
口径与 src/lib/post-visibility.ts 单一事实源对齐:可见 = 无 draft、无
hidden、tags 不含 'hidden'(hidden 两个字段都认,与站点过滤一致)。
三类互斥、draft 优先:draft 构建期连页面都不生成,hidden 标记对它无意义。
pinned 只从可见集取——站点上隐藏与置顶同开以隐藏为准
(src/components/PostList.astro 也是先 filter visible 再取 pinned)。
"""
stats = {
'posts_visible': 0,
'posts_draft': 0,
'posts_hidden': 0,
'zh_chars': 0,
'pinned': [],
}
tag_counter = {}
newest = None # (date/datetime 键, ISO 字符串)
for path in sorted(POSTS_DIR.glob('*.md')):
try:
fm_text, body = split_frontmatter(path.read_text(encoding='utf-8'))
# 无 frontmatter(或没闭合)的文章连站点 schema 都过不了,
# 与解析失败同待遇:跳过告警,不计入任何一类
if fm_text is None:
raise yaml.YAMLError('missing or unterminated frontmatter')
fm = yaml.safe_load(fm_text) if fm_text else {}
if not isinstance(fm, dict):
fm = {}
except (OSError, yaml.YAMLError, UnicodeDecodeError) as exc:
print(f'[gen-admin-stats] SKIP {path.name}(frontmatter 解析失败:{exc})')
continue
tags = tag_list(fm)
if fm.get('draft'):
stats['posts_draft'] += 1
continue
if fm.get('hidden') or 'hidden' in tags:
stats['posts_hidden'] += 1
continue
stats['posts_visible'] += 1
stats['zh_chars'] += count_body_chars(body)
for tag in tags:
tag_counter[tag] = tag_counter.get(tag, 0) + 1
if fm.get('pinned'):
title = str(fm.get('title') or path.stem)
stats['pinned'].append(title)
key, iso = pubdate_iso(fm)
if key is not None and (newest is None or key > newest[0]):
newest = (key, iso)
# 计数降序、同数按标签名排,保证产物可 diff、跨机器可复现
stats['top_tags'] = [
[tag, n]
for tag, n in sorted(tag_counter.items(), key=lambda kv: (-kv[1], kv[0]))[:10]
]
stats['newest_pubdate'] = newest[1] if newest else None
return stats
def render_html(stats, generated_at):
"""自包含浅色看板页:内联 CSS、无 JS、无外部资源、数字大字卡片网格。"""
cards = [
('可见文章', stats['posts_visible']),
('草稿', stats['posts_draft']),
('隐藏', stats['posts_hidden']),
('英文文章', stats['posts_en']),
('瞬间', stats['moments']),
('照片', stats['photos']),
('正文总字数', stats['zh_chars']),
('最新发布', stats['newest_pubdate'] or '—'),
]
card_html = ''.join(
f'<div class="card"><div class="num">{html.escape(str(value))}</div>'
f'<div class="label">{label}</div></div>'
for label, value in cards
)
tag_rows = ''.join(
f'<li><span class="tag">{html.escape(tag)}</span>'
f'<span class="count">×{n}</span></li>'
for tag, n in stats['top_tags']
) or '<li class="empty">(无)</li>'
pinned_rows = ''.join(
f'<li>{html.escape(title)}</li>' for title in stats['pinned']
) or '<li class="empty">(无)</li>'
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>内容仪表盘 · 构建期生成</title>
<style>
:root {{
color-scheme: light;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #f6f7f9; color: #1f2328;
line-height: 1.6; padding: 32px 20px 48px;
}}
.wrap {{ max-width: 920px; margin: 0 auto; }}
header {{ margin-bottom: 28px; }}
h1 {{ font-size: 22px; font-weight: 600; }}
.meta {{ margin-top: 6px; font-size: 13px; color: #6a737d; }}
.cards {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px; margin-bottom: 28px;
}}
.card {{
background: #fff; border: 1px solid #e4e7eb; border-radius: 10px;
padding: 18px 16px 14px;
}}
.num {{ font-size: 34px; font-weight: 700; letter-spacing: -0.5px;
font-variant-numeric: tabular-nums; }}
.label {{ margin-top: 4px; font-size: 13px; color: #6a737d; }}
.panels {{ display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }}
@media (max-width: 640px) {{ .panels {{ grid-template-columns: 1fr; }} }}
.panel {{
background: #fff; border: 1px solid #e4e7eb; border-radius: 10px;
padding: 16px 18px;
}}
.panel h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 10px; }}
.panel ol, .panel ul {{ padding-left: 20px; }}
.panel li {{ font-size: 14px; padding: 3px 0; }}
.panel li .count {{ color: #6a737d; font-size: 13px; margin-left: 8px; }}
.empty {{ color: #6a737d; list-style: none; margin-left: -20px; }}
footer {{ margin-top: 24px; font-size: 12px; color: #8b949e; }}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>内容仪表盘 · 构建期生成</h1>
<p class="meta">生成时间 {html.escape(generated_at)}</p>
</header>
<section class="cards">{card_html}</section>
<section class="panels">
<div class="panel">
<h2>标签 TOP 10(可见文章)</h2>
<ol>{tag_rows}</ol>
</div>
<div class="panel">
<h2>置顶文章</h2>
<ul>{pinned_rows}</ul>
</div>
</section>
<footer>口径:可见 = 无 draft、无 hidden、tags 不含 hidden(与站点过滤一致);字数 = 可见文章正文剥离 markdown 标记与空白后的字符数。</footer>
</div>
</body>
</html>
"""
def main() -> int:
if len(sys.argv) != 2 or not sys.argv[1]:
print('用法: python3 scripts/build-perf/gen-admin-stats.py <dist目录>', file=sys.stderr)
return 1
dist = Path(sys.argv[1])
out_dir = dist / 'admin'
out_dir.mkdir(parents=True, exist_ok=True)
counted = scan_posts()
# 键序固定为文档口径顺序,方便人工核对与 diff
stats = {
'posts_visible': counted['posts_visible'],
'posts_draft': counted['posts_draft'],
'posts_hidden': counted['posts_hidden'],
'moments': len(list(MOMENTS_DIR.glob('*.md'))),
'photos': len(list(PHOTOS_DIR.glob('*.jpg'))),
'posts_en': len(list(POSTS_EN_DIR.glob('*.md'))),
'zh_chars': counted['zh_chars'],
'top_tags': counted['top_tags'],
'pinned': counted['pinned'],
'newest_pubdate': counted['newest_pubdate'],
}
generated_at = time.strftime('%Y-%m-%d %H:%M:%S %z')
(out_dir / 'stats.json').write_text(
json.dumps(stats, ensure_ascii=False, indent=2) + '\n', encoding='utf-8'
)
(out_dir / 'stats.html').write_text(render_html(stats, generated_at), encoding='utf-8')
print(
f"[gen-admin-stats] 可见 {stats['posts_visible']} / 草稿 {stats['posts_draft']}"
f" / 隐藏 {stats['posts_hidden']} / 瞬间 {stats['moments']} / 照片 {stats['photos']}"
f" / 英文 {stats['posts_en']} → {out_dir}/stats.{{json,html}}"
)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# TMT 翻译构建链守卫(5n A1):包装 scripts/translate/translate_posts.py,
# 挂在 build.sh 的 astro build 之前——中文稿保存即自动出英文版,随同次部署上线。
#
# 部署哲学:翻译是增益件,绝不能阻断发布——任何失败(缺凭据/缺脚本/超时/
# TMT 报错)都只 WARN 并保留旧英文版,exit 0 让构建继续。
# 幂等语义见 translate_posts.py 头注:源文 sha256 未变跳过(零 API 调用);
# 译文 frontmatter locked: true 的人工润色版无条件不覆盖。
set -u
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/translate/translate_posts.py"
CRED="$HOME/docker_apps/blog/tencent-translate-credentials.txt"
TAG="[translate]"
TIMEOUT_SECS=180
if [ ! -f "$SCRIPT" ]; then
echo "$TAG SKIP 翻译脚本未就绪($SCRIPT)"
exit 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "$TAG SKIP 无 python3"
exit 0
fi
if [ ! -f "$CRED" ]; then
echo "$TAG SKIP 无 TMT 凭据($CRED),保留现有英文版"
exit 0
fi
echo "$TAG 开始(TMT zh→en,幂等:已译且未变的源文零调用)"
if timeout "$TIMEOUT_SECS" python3 "$SCRIPT" "$@"; then
echo "$TAG 完成"
else
rc=$?
if [ "$rc" = 124 ]; then
echo "$TAG WARN 超时(>${TIMEOUT_SECS}s),保留旧英文版继续部署"
else
echo "$TAG WARN 失败(exit $rc),保留旧英文版继续部署"
fi
fi
exit 0
+49 -31
View File
@@ -181,49 +181,67 @@ export const collections = {
// 双语文案用 {zh,en} 对象字段(各自可选):任一语言空/缺字段 → src/lib/site-content.ts 回落默认
siteHome: defineCollection({
loader: glob({ pattern: 'home.yaml', base: './content/site' }),
// 5n C1 后台折叠分组:平铺字段收进三个可选 object 组,组内一律压平叶子字段——
// 禁止组内再嵌可选对象:Sveltia 对「可选对象嵌可选对象」不渲染子编辑器
// (#446/#654,2026-11-05 eb5e3f8 实修);socials(list of object)同理留顶层,
// 不做 object>list>object 三层嵌套(无生产验证记录)。
// 旧→新映射(迁移前 home.yaml 的平铺形状):
// tagline.zh / tagline.en → identity.tagline_zh / identity.tagline_en
// subtitle.zh / subtitle.en → identity.subtitle_zh / identity.subtitle_en
// avatar / favicon → identity.avatar / identity.favicon
// announcement.zh / announcement.en → copy.announcement_zh / copy.announcement_en
// typography.font / .accent / .scale → copy.typography_font / copy.typography_accent / copy.typography_scale
// latestPhotos / perTrackLimit / projectsLimit → content.latestPhotos / content.perTrackLimit / content.projectsLimit
// postsTracks.reading / .tech / .photo → content.postsTracks_reading / content.postsTracks_tech / content.postsTracks_photo
// sections.tracks / .photos / .heatmap / .projects → content.sections_tracks / content.sections_photos / content.sections_heatmap / content.sections_projects
// socials → socials(顶层原位不动)
// avatar 走 image():yaml 写 ./assets/xxx.jpg(相对 yaml 文件),构建期入 Astro 图片管线
schema: ({ image }) =>
z.object({
tagline: bilingual.optional(),
avatar: image().optional(),
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
typography: z
/** 身份名片组(后台折叠):身份语/副标题(双语压平叶子)+ 头像/站点图标 */
identity: z
.object({
font: z.string().optional(),
accent: z.string().optional(),
scale: z.number().optional(),
})
.optional(),
tagline_zh: z.string().optional(),
tagline_en: z.string().optional(),
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
subtitle_zh: z.string().optional(),
subtitle_en: z.string().optional(),
avatar: image().optional(),
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
favicon: z.string().optional(),
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
subtitle: bilingual.optional(),
/** 最新影像张数:0 隐藏区块,1-6 有效(越界由访问器夹取) */
})
.optional(),
/** 文案与排版组(后台折叠):公告(markdown 富文本,走富文本管线,空 = 不渲染)+ 页面排版覆盖(层 2,只影响本页) */
copy: z
.object({
announcement_zh: z.string().optional(),
announcement_en: z.string().optional(),
typography_font: z.string().optional(),
typography_accent: z.string().optional(),
typography_scale: z.number().optional(),
})
.optional(),
/** 内容与开关组(后台折叠):条数越界由访问器夹取,开关缺省全开 */
content: z
.object({
/** 最新影像张数:0 隐藏区块,1-6 有效 */
latestPhotos: z.number().int().optional(),
/** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */
/** 首页每轨条数:4-8(缺省 6) */
perTrackLimit: z.number().int().optional(),
/** 首页项目墙条数:1-99(缺省 6;越界夹取) */
/** 首页项目墙条数:1-99(缺省 6) */
projectsLimit: z.number().int().optional(),
/** 首页公告(markdown 富文本,走富文本管线);空 = 不渲染 */
announcement: bilingual.optional(),
/** 首页轨细分开关:分开关阅读/技术/照片三轨(缺省全开) */
postsTracks: z
.object({
reading: z.boolean().optional(),
tech: z.boolean().optional(),
photo: z.boolean().optional(),
})
.optional(),
/** 首页轨细分:分开关阅读/技术/照片三轨(缺省全开) */
postsTracks_reading: z.boolean().optional(),
postsTracks_tech: z.boolean().optional(),
postsTracks_photo: z.boolean().optional(),
/** 区块开关:缺省全开 */
sections: z
.object({
tracks: z.boolean().optional(),
photos: z.boolean().optional(),
heatmap: z.boolean().optional(),
projects: z.boolean().optional(),
sections_tracks: z.boolean().optional(),
sections_photos: z.boolean().optional(),
sections_heatmap: z.boolean().optional(),
sections_projects: z.boolean().optional(),
})
.optional(),
/** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...) */
/** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...);顶层不进组(雷区纪律见上) */
socials: z
.array(
z.object({
+29 -17
View File
@@ -45,10 +45,11 @@ const pick = (value: string | undefined, fallback: string): string =>
export const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
pick(value?.[locale], fallback[locale]);
/** 主页 tagline(zh/en) */
/** 主页 tagline(zh/en);双语叶子压平在 identity 组内(5n C1,映射见 content.config.ts siteHome) */
export async function homeTagline(locale: Locale): Promise<string> {
const entry = await getEntry('siteHome', 'home');
return pickBi(entry?.data.tagline, SITE_DEFAULTS.homeTagline, locale);
const id = entry?.data.identity;
return pickBi({ zh: id?.tagline_zh, en: id?.tagline_en }, SITE_DEFAULTS.homeTagline, locale);
}
/** 关于页身份陈述段(zh/en) */
@@ -109,13 +110,13 @@ export async function homeSocials(): Promise<SocialLink[]> {
/** 头像(Astro 图片管线的 ImageMetadata);未配置 → null,调用方回退渐变占位 */
export async function homeAvatar(): Promise<ImageMetadata | null> {
const entry = await getEntry('siteHome', 'home');
return entry?.data.avatar ?? null;
return entry?.data.identity?.avatar ?? null;
}
/** 站点图标路径:yaml 值非空即采信,否则 /favicon.svg;值不带前导 / 时自动补 */
export async function siteFavicon(): Promise<{ href: string; type: string }> {
const entry = await getEntry('siteHome', 'home');
const raw = pick(entry?.data.favicon, '/favicon.svg');
const raw = pick(entry?.data.identity?.favicon, '/favicon.svg');
const href = raw.startsWith('/') || raw.startsWith('http') ? raw : `/${raw}`;
const type = href.endsWith('.svg')
? 'image/svg+xml'
@@ -237,7 +238,15 @@ export async function pageTypography(
id: string,
): Promise<PageTypography> {
const entry = await getEntry(collection, id);
const t = entry?.data?.typography as { font?: string; accent?: string; scale?: number } | undefined;
// siteHome 的排版覆盖收进 copy 组并压平叶子(5n C1);about/opensource 仍是嵌套对象——
// 两个形状都声明为可选,按集合取对应一路
const data = entry?.data as {
copy?: { typography_font?: string; typography_accent?: string; typography_scale?: number };
typography?: { font?: string; accent?: string; scale?: number };
} | undefined;
const t = collection === 'siteHome'
? { font: data?.copy?.typography_font, accent: data?.copy?.typography_accent, scale: data?.copy?.typography_scale }
: data?.typography;
const font = t?.font && ALLOWED_PAGE_FONTS.has(t.font) ? t.font : '';
const accent = t?.accent && ALLOWED_PAGE_ACCENTS.has(t.accent) ? t.accent : '';
const scale = typeof t?.scale === 'number' && t.scale >= 0.8 && t.scale <= 1.25 ? t.scale : 1;
@@ -319,22 +328,25 @@ const clampInt = (value: number | undefined, min: number, max: number, fallback:
export async function homeConfig(locale: Locale): Promise<HomeConfig> {
const entry = await getEntry('siteHome', 'home');
const d = entry?.data;
const id = d?.identity;
const copy = d?.copy;
const c = d?.content;
return {
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
announcement: pickBi(d?.announcement, SITE_DEFAULTS.homeAnnouncement, locale),
projectsLimit: clampInt(d?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
subtitle: pickBi({ zh: id?.subtitle_zh, en: id?.subtitle_en }, { zh: '', en: '' }, locale),
announcement: pickBi({ zh: copy?.announcement_zh, en: copy?.announcement_en }, SITE_DEFAULTS.homeAnnouncement, locale),
projectsLimit: clampInt(c?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
postsTracks: {
reading: d?.postsTracks?.reading ?? SITE_DEFAULTS.homePostsTracks.reading,
tech: d?.postsTracks?.tech ?? SITE_DEFAULTS.homePostsTracks.tech,
photo: d?.postsTracks?.photo ?? SITE_DEFAULTS.homePostsTracks.photo,
reading: c?.postsTracks_reading ?? SITE_DEFAULTS.homePostsTracks.reading,
tech: c?.postsTracks_tech ?? SITE_DEFAULTS.homePostsTracks.tech,
photo: c?.postsTracks_photo ?? SITE_DEFAULTS.homePostsTracks.photo,
},
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
latestPhotos: clampInt(c?.latestPhotos, 0, 6, 4),
perTrackLimit: clampInt(c?.perTrackLimit, 4, 8, 6),
sections: {
tracks: d?.sections?.tracks ?? true,
photos: d?.sections?.photos ?? true,
heatmap: d?.sections?.heatmap ?? true,
projects: d?.sections?.projects ?? true,
tracks: c?.sections_tracks ?? true,
photos: c?.sections_photos ?? true,
heatmap: c?.sections_heatmap ?? true,
projects: c?.sections_projects ?? true,
},
};
}
+12
View File
@@ -347,6 +347,18 @@ await d.addInitScript(() => localStorage.setItem('theme', 'dark'));
await d.goto(BASE + '/gallery/', { waitUntil: 'networkidle' });
await d.screenshot({ path: `${SHOTS}/12-gallery-dark.png`, fullPage: true });
// 5n:后台页面资产(预登录态即可断言——脚本/链接/速通坞在登录前就挂载)
{
await page.goto(BASE + '/admin/', { waitUntil: 'networkidle' });
const adminHtml = await page.content();
ok('5n·后台三脚本接线(bundle+排版+预览+状态条)',
adminHtml.includes('sveltia-cms.js') && adminHtml.includes('preview-style.js') && adminHtml.includes('deploy-status.js'));
ok('5n·速通坞五入口', (await page.locator('#quick-dock a').count()) === 5);
ok('5n·PWA manifest 挂载', adminHtml.includes('manifest.webmanifest'));
const stats = await page.request.get(BASE + '/admin/stats.html');
ok('5n·内容仪表盘可达', stats.status() === 200);
}
await browser.close();
const fails = results.filter((r) => !r.pass).length;
console.log(`\nE2E 汇总: ${results.length - fails}/${results.length} PASS${fails ? ',FAIL ' + fails : ''}`);