Merge branch 'spec/2026-11-05_blog-5n-admin-deep-optimization'
Phase 5n 交付:后台深度优化 14 项全量——Sveltia 0.252→0.259 升级; TMT 翻译进构建链(中文保存→英文自动上线,locked 人工润色不覆盖); 表单四优化(标签建议/模板起手指引/SEO 字数/中英说明);部署状态条 +后台 PWA+速通坞五入口+回滚三层指引;首页设置折叠分组(规避 Sveltia 嵌套雷区的压平 B 案);构建期内容仪表盘;预览暗色跟随;媒体落点地图。 Spec: 2026-11-05_blog-5n-admin-deep-optimization
This commit is contained in:
commit
783d74966e
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`
|
||||||
@@ -6,7 +6,7 @@ tags:
|
|||||||
- 写作
|
- 写作
|
||||||
- 模板
|
- 模板
|
||||||
pinned: true
|
pinned: true
|
||||||
description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。
|
description: 「复制起手」母版(后台可见、前台隐藏):演示全站排版能力——标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。新文章从它 Duplicate 开始。
|
||||||
hidden: true
|
hidden: true
|
||||||
draft: false
|
draft: false
|
||||||
hiddenTag: true
|
hiddenTag: true
|
||||||
|
|||||||
+14
-12
@@ -1,15 +1,17 @@
|
|||||||
tagline:
|
# 5n C1 后台折叠分组:平铺字段收进 identity/copy/content 三个可选组(组内压平叶子),socials 顶层原位。
|
||||||
zh: 拥抱开源,热爱开源
|
# 旧→新映射见 src/content.config.ts 的 siteHome schema 注释;本次迁移值逐字保留,零功能变化。
|
||||||
en: Embracing open source, loving open source.
|
identity:
|
||||||
latestPhotos: 4
|
tagline_zh: 拥抱开源,热爱开源
|
||||||
perTrackLimit: 6
|
tagline_en: Embracing open source, loving open source.
|
||||||
sections:
|
avatar: ./assets/118a04cd0986b9c5.jpg
|
||||||
tracks: true
|
favicon: assets/118a04cd0986b9c5.jpg
|
||||||
photos: true
|
content:
|
||||||
heatmap: true
|
latestPhotos: 4
|
||||||
projects: true
|
perTrackLimit: 6
|
||||||
avatar: ./assets/118a04cd0986b9c5.jpg
|
sections_tracks: true
|
||||||
favicon: assets/118a04cd0986b9c5.jpg
|
sections_photos: true
|
||||||
|
sections_heatmap: true
|
||||||
|
sections_projects: true
|
||||||
socials:
|
socials:
|
||||||
- label: GitHub
|
- label: GitHub
|
||||||
url: https://github.com/wait4xx
|
url: https://github.com/wait4xx
|
||||||
|
|||||||
@@ -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
@@ -240,3 +240,18 @@ token 位在 `~/docker_apps/blog/github-token.txt`(本机,0600)。
|
|||||||
|
|
||||||
- 启动时探测 /api/forgejo 报 404 属良性回落(日志噪音,无影响)
|
- 启动时探测 /api/forgejo 报 404 属良性回落(日志噪音,无影响)
|
||||||
- 大视频仍走仓外 store + /media/ 绝对路径(见写作指南体积纪律表)
|
- 大视频仍走仓外 store + /media/ 绝对路径(见写作指南体积纪律表)
|
||||||
|
|
||||||
|
|
||||||
|
## 媒体文件落点地图(5n 归置)
|
||||||
|
|
||||||
|
上传的文件各归各位,不用记——表里的「落点」由后台自动处理:
|
||||||
|
|
||||||
|
| 在哪上传 | 落到仓库哪 | 网页上从哪读 |
|
||||||
|
|---|---|---|
|
||||||
|
| 文章封面/文内图 | content/posts/assets/ | 构建期自动优化为 WebP |
|
||||||
|
| 瞬间配图 | 同上(按集合相对路径) | 同上 |
|
||||||
|
| 主页头像/关于页图 | content/site/assets/ | 构建期优化 |
|
||||||
|
| 站点图标 favicon | public/icons/ | 原样分发 |
|
||||||
|
| 图库照片 | content/photos/(摄取管线) | 灯箱走原图、列表走优化图 |
|
||||||
|
|
||||||
|
「媒体库」页面(后台左侧 Media)列出的是图库照片目录。要找某篇文章的配图,直接进那篇文章的编辑页点开图片字段即可。
|
||||||
+20
-1
@@ -27,7 +27,20 @@ git clean -fd --exclude node_modules
|
|||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
echo "[build] 构建源: origin/main @ $SHA"
|
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
|
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
|
pnpm astro build
|
||||||
|
|
||||||
# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链;
|
# 按页字体 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/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 脚本未就绪,跳过"
|
[ -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 快照已删——连续贡献改为
|
# README 动效资产(5i 引入、5j T2 退役):贡献蛇与 streak 快照已删——连续贡献改为
|
||||||
# 热力图同源本地计算(见 src/lib/heatmap.ts 的 streakStats),界面上标注"数据更新于"。
|
# 热力图同源本地计算(见 src/lib/heatmap.ts 的 streakStats),界面上标注"数据更新于"。
|
||||||
# fetch-readme-assets.mjs 保留骨架但 ASSETS 已清空,如将来要加新的自托管资产再启用。
|
# fetch-readme-assets.mjs 保留骨架但 ASSETS 已清空,如将来要加新的自托管资产再启用。
|
||||||
@@ -52,7 +68,10 @@ rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/"
|
|||||||
ORIG="$HOME/docker_apps/blog/photos-originals"
|
ORIG="$HOME/docker_apps/blog/photos-originals"
|
||||||
if [ -d "$ORIG" ]; then rsync -a "$ORIG/" "$STAGING/photos-originals/"; fi
|
if [ -d "$ORIG" ]; then rsync -a "$ORIG/" "$STAGING/photos-originals/"; fi
|
||||||
printf 'built %s at %s\n' "$SHA" "$(date -Is)" > "$STAGING/.build-stamp"
|
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 属预期),
|
# 生产冒烟(T3):staging 三断言。首版容忍失败(robots 待 T1 落地,smoke FAIL 属预期),
|
||||||
# T1 收编后改硬门:去掉 `|| echo` 让断言失败直接炸构建
|
# T1 收编后改硬门:去掉 `|| echo` 让断言失败直接炸构建
|
||||||
|
|||||||
+48
-60
@@ -24,6 +24,7 @@ collections:
|
|||||||
label_singular: 文章
|
label_singular: 文章
|
||||||
folder: content/posts
|
folder: content/posts
|
||||||
create: true
|
create: true
|
||||||
|
hint: 新文章可从置顶的《写作模板》复制起手(列表打开模板 → ⋯ 菜单 → Duplicate);英文版构建时自动机翻上线,无需在此维护
|
||||||
# 中文主稿;英文为 TMT 预翻译入仓的 .en.md(posts-en 集合不在后台,防误改译文)
|
# 中文主稿;英文为 TMT 预翻译入仓的 .en.md(posts-en 集合不在后台,防误改译文)
|
||||||
slug: '{{slug}}'
|
slug: '{{slug}}'
|
||||||
summary: '{{pubDate}} · {{title}}'
|
summary: '{{pubDate}} · {{title}}'
|
||||||
@@ -34,14 +35,14 @@ collections:
|
|||||||
media_folder: assets
|
media_folder: assets
|
||||||
public_folder: ./assets
|
public_folder: ./assets
|
||||||
fields:
|
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: pubDate, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, format: 'YYYY-MM-DD' }
|
||||||
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
|
- { label: 分轨, name: category, widget: select, options: [{label: 阅读, value: reading}, {label: 技术, value: tech}, {label: 照片, value: photo}] }
|
||||||
- { label: 标签, name: tags, widget: list, required: 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: pinned, widget: boolean, required: false, default: false, hint: '置顶文章排在首页/归档/书架最前;只影响顺序,不改日期、URL 与 RSS 时间语义' }
|
||||||
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
|
- { label: 情绪色(书评色脊), name: mood, widget: color, required: false, hint: '#RRGGBB,仅阅读轨使用' }
|
||||||
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
|
- { label: 原书作者, name: bookAuthor, widget: string, required: false }
|
||||||
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false }
|
- { label: 一句话描述(书架策展语), name: description, widget: text, required: false, hint: '60–120 字最佳(搜索摘要与列表导语共用)' }
|
||||||
- { label: 书封图, name: cover, widget: image, required: false, hint: '上传后自动生成,无需手填路径;仅阅读轨展示' }
|
- { label: 书封图, name: cover, widget: image, required: false, hint: '上传后自动生成,无需手填路径;仅阅读轨展示' }
|
||||||
- label: 附视频(可选)
|
- label: 附视频(可选)
|
||||||
name: video
|
name: video
|
||||||
@@ -108,7 +109,7 @@ collections:
|
|||||||
widget: markdown
|
widget: markdown
|
||||||
required: false
|
required: false
|
||||||
hint: '与图片至少填一项。支持 markdown 与排版类 HTML,例如 <span class="c-blue font-kai">彩色+楷体</span>;选中文字 → Insert ▸ 排版 可视化选字体/颜色(预览窗格直接显色显字体)。装饰色仍守“外壳灰阶、彩色只作强调”的纪律'
|
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: 标签
|
- label: 标签
|
||||||
name: tags
|
name: tags
|
||||||
widget: list
|
widget: list
|
||||||
@@ -185,57 +186,58 @@ collections:
|
|||||||
media_folder: assets
|
media_folder: assets
|
||||||
public_folder: ./assets
|
public_folder: ./assets
|
||||||
fields:
|
fields:
|
||||||
- label: 身份语
|
- label: 身份名片
|
||||||
name: tagline
|
name: identity
|
||||||
widget: object
|
widget: object
|
||||||
required: false
|
required: false
|
||||||
hint: 首页大字身份语(双语,markdown 富文本);可写 <span class="font-kai c-blue">…</span> 做局部强调
|
collapsed: true
|
||||||
|
hint: 头像、身份语、副标题与站点图标(首页名片区 + 浏览器标签图标)
|
||||||
fields:
|
fields:
|
||||||
- { label: 中文, name: zh, widget: string, required: false }
|
- { label: 身份语(中文), name: tagline_zh, widget: string, required: false, hint: '首页大字身份语(markdown 富文本);可写 <span class="font-kai c-blue">…</span> 做局部强调' }
|
||||||
- { label: 英文, name: en, widget: string, required: false }
|
- { label: 身份语(英文), name: tagline_en, widget: string, required: false }
|
||||||
- label: 页面排版覆盖(可选)
|
- { label: 副标题(中文,markdown 短文本), name: subtitle_zh, widget: string, required: false, hint: '渲染在身份语下方;留空不渲染' }
|
||||||
name: typography
|
- { 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
|
widget: object
|
||||||
required: false
|
required: false
|
||||||
hint: 本页正文默认字体 / 强调色 / 字号缩放(0.8–1.25);不填用全站默认,只影响本页
|
collapsed: true
|
||||||
|
hint: 公告(渲染在名片区下方,留空不显示)+ 页面排版覆盖(本页正文默认字体/强调色/字号缩放,不填用全站默认,只影响主页)
|
||||||
fields:
|
fields:
|
||||||
|
- { label: 公告(中文,markdown), name: announcement_zh, widget: markdown, required: false, hint: '渲染在名片区下方;留空则不显示公告块' }
|
||||||
|
- { label: 公告(英文), name: announcement_en, widget: markdown, required: false }
|
||||||
- label: 正文字体类
|
- label: 正文字体类
|
||||||
name: font
|
name: typography_font
|
||||||
widget: select
|
widget: select
|
||||||
required: false
|
required: false
|
||||||
hint: 整页正文换字族;艺术/展示类字族体积大。5i 新增六款(小薇/庆科黄油/马善政/柳建毛草/得意黑/新晰黑)均按站内用字切片,单页增量 27–46KB;站外用字(生僻字)会回落系统字族,需按 docs 里的命令重切
|
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]
|
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: 强调色类
|
- label: 强调色类
|
||||||
name: accent
|
name: typography_accent
|
||||||
widget: select
|
widget: select
|
||||||
required: false
|
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]
|
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: 字号缩放, 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: 公告(可选,markdown)
|
- label: 内容与开关
|
||||||
name: announcement
|
name: content
|
||||||
widget: object
|
widget: object
|
||||||
required: false
|
required: false
|
||||||
hint: 渲染在名片区下方;留空则不显示公告块
|
collapsed: true
|
||||||
|
hint: 各区块条数与显隐开关;关掉的区块不渲染,越界数值自动夹取(默认 = 现状)
|
||||||
fields:
|
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: 最新影像张数
|
- label: 最新影像张数
|
||||||
name: latestPhotos
|
name: latestPhotos
|
||||||
widget: number
|
widget: number
|
||||||
@@ -252,29 +254,14 @@ collections:
|
|||||||
min: 4
|
min: 4
|
||||||
max: 8
|
max: 8
|
||||||
hint: 阅读轨/技术轨各显示条数,留空默认 6
|
hint: 阅读轨/技术轨各显示条数,留空默认 6
|
||||||
- label: 区块开关
|
- { label: 项目墙条数, name: projectsLimit, widget: number, required: false, value_type: int, min: 1, max: 99, hint: '首页项目墙显示几条(1–99,缺省 6)' }
|
||||||
name: sections
|
- { label: 阅读轨, name: postsTracks_reading, widget: boolean, required: false, default: true, hint: '关掉则阅读轨文章不在首页列出(归档页仍可见);三轨全开 = 现状' }
|
||||||
widget: object
|
- { label: 技术轨, name: postsTracks_tech, widget: boolean, required: false, default: true }
|
||||||
required: false
|
- { label: 照片轨, name: postsTracks_photo, widget: boolean, required: false, default: true }
|
||||||
hint: 关掉的区块不渲染(默认全开)
|
- { label: 显示文章列表, name: sections_tracks, widget: boolean, required: false, default: true }
|
||||||
fields:
|
- { label: 显示最新影像, name: sections_photos, widget: boolean, required: false, default: true }
|
||||||
- { label: 文章列表, name: tracks, widget: boolean, required: false, default: true }
|
- { label: 显示贡献热力图, name: sections_heatmap, widget: boolean, required: false, default: true }
|
||||||
- { label: 最新影像, name: photos, widget: boolean, required: false, default: true }
|
- { label: 显示项目墙, name: sections_projects, 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: 社交链接(顺序即展示顺序)
|
- label: 社交链接(顺序即展示顺序)
|
||||||
name: socials
|
name: socials
|
||||||
widget: list
|
widget: list
|
||||||
@@ -289,6 +276,7 @@ collections:
|
|||||||
required: false
|
required: false
|
||||||
options: [github, rss, email, bilibili, x]
|
options: [github, rss, email, bilibili, x]
|
||||||
hint: '不在列表里的留空 → 显示纯文字按钮'
|
hint: '不在列表里的留空 → 显示纯文字按钮'
|
||||||
|
|
||||||
- name: about
|
- name: about
|
||||||
label: 关于页
|
label: 关于页
|
||||||
file: content/site/about.yaml
|
file: content/site/about.yaml
|
||||||
|
|||||||
@@ -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
@@ -3,10 +3,30 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="robots" content="noindex" />
|
<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>
|
</head>
|
||||||
<body>
|
<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>
|
<script src="./sveltia-cms.js"></script>
|
||||||
<!-- 艺术字体切片(unicode-range 按需取片):选择面板的示例字形用真实字体渲染 -->
|
<!-- 艺术字体切片(unicode-range 按需取片):选择面板的示例字形用真实字体渲染 -->
|
||||||
<link rel="stylesheet" href="/fonts/subset/hwmc/fonts.css" />
|
<link rel="stylesheet" href="/fonts/subset/hwmc/fonts.css" />
|
||||||
@@ -20,5 +40,7 @@
|
|||||||
<script src="./typography-classes.js"></script>
|
<script src="./typography-classes.js"></script>
|
||||||
<script src="./typography-editor.js"></script>
|
<script src="./typography-editor.js"></script>
|
||||||
<script src="./preview-style.js"></script>
|
<script src="./preview-style.js"></script>
|
||||||
|
<!-- 部署状态条(5n B1):轮询 /admin/build-status.json -->
|
||||||
|
<script src="./deploy-status.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 后台预览一致性(5l T1):把站点排版效果注入 Sveltia 预览窗格。
|
* 后台预览一致性(5l T1 / 5n T7):把站点排版效果注入 Sveltia 预览窗格。
|
||||||
*
|
*
|
||||||
* 现状问题:markdown 里的排版类(font- 字体、c-/ink- 颜色)在预览里是素文本——
|
* 现状问题:markdown 里的排版类(font- 字体、c-/ink- 颜色)在预览里是素文本——
|
||||||
* 类名随 DOM 保留(typography-editor 的 toPreview 输出带 class),但预览
|
* 类名随 DOM 保留(typography-editor 的 toPreview 输出带 class),但预览
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
* 2. 自托管艺术字体挂切片 CSS(public/fonts/<family>/fonts.css,
|
* 2. 自托管艺术字体挂切片 CSS(public/fonts/<family>/fonts.css,
|
||||||
* unicode-range 按需取片——预览里只拉用到的字所落在的切片,不整包)。
|
* unicode-range 按需取片——预览里只拉用到的字所落在的切片,不整包)。
|
||||||
*
|
*
|
||||||
|
* 暗色(5n T7):所有色值走 CSS 自定义属性,变量缺省值 = 亮态(与改造前同一批
|
||||||
|
* 字面量,无暗色命中时逐字节等价),末尾一段 @media (prefers-color-scheme: dark)
|
||||||
|
* 统一换档——后台切暗色时预览不再用亮态色值配暗底。
|
||||||
|
*
|
||||||
* serif/kai/kuaile 的 CSS 经 Vite ?url 打包成哈希名,后台引不到稳定 URL,
|
* serif/kai/kuaile 的 CSS 经 Vite ?url 打包成哈希名,后台引不到稳定 URL,
|
||||||
* 预览走字体栈里的系统回退(Kaiti SC/Songti)——近似可见,文档已注明。
|
* 预览走字体栈里的系统回退(Kaiti SC/Songti)——近似可见,文档已注明。
|
||||||
* 加载顺序:须在 typography-classes.js 之后、CMS init 之前(index.html 固定序)。
|
* 加载顺序:须在 typography-classes.js 之后、CMS init 之前(index.html 固定序)。
|
||||||
@@ -20,30 +24,96 @@
|
|||||||
const CLASSES = window.TYPOGRAPHY_CLASSES;
|
const CLASSES = window.TYPOGRAPHY_CLASSES;
|
||||||
if (!CLASSES || !window.CMS || !window.CMS.registerPreviewStyle) return;
|
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 的通用排版(目标是类效果可见,非像素级还原) */
|
/** 预览基调:近似站点 .rich 的通用排版(目标是类效果可见,非像素级还原) */
|
||||||
const baseCss = [
|
const baseCss = [
|
||||||
'p{margin:.6em 0;line-height:1.75}',
|
'p{margin:.6em 0;line-height:1.75}',
|
||||||
'h1,h2,h3,h4{font-weight:600;line-height:1.4;margin:1.2em 0 .5em}',
|
'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}',
|
'h2{font-size:1.3em}h3{font-size:1.15em}',
|
||||||
'ul,ol{padding-left:1.5em;margin:.6em 0}li{margin:.25em 0}',
|
'ul,ol{padding-left:1.5em;margin:.6em 0}li{margin:.25em 0}',
|
||||||
'a{color:#3454d1;text-decoration:underline}',
|
'a{color:var(--pv-accent,#3454d1);text-decoration:underline}',
|
||||||
'blockquote{margin:.8em 0;padding:.2em 1em;border-left:3px solid #e6e7eb;color:#6b6e78}',
|
'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:#f2f3f5;padding:.1em .35em;border-radius:4px}",
|
"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%}',
|
'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}',
|
'strong{font-weight:700}',
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
/** 排版类规则:清单驱动(swatch/ family 即预览所用值,亮态口径) */
|
/** 排版类规则:清单驱动(swatch/family 亮态值作 var() 回落 = 面板所见) */
|
||||||
const rules = [];
|
const rules = [];
|
||||||
|
const darkDecls = [];
|
||||||
for (const f of CLASSES.font || []) {
|
for (const f of CLASSES.font || []) {
|
||||||
if (f.family) rules.push(`.${f.cls}{font-family:${f.family}}`);
|
if (f.family) rules.push(`.${f.cls}{font-family:${f.family}}`);
|
||||||
}
|
}
|
||||||
for (const c of CLASSES.color || []) {
|
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;去重后逐个挂
|
// 字体切片(URL 模式):预览文档自己的 @font-face;去重后逐个挂
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|||||||
+390
-361
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||||
Executable
+41
@@ -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
|
||||||
Binary file not shown.
+49
-31
@@ -181,49 +181,67 @@ export const collections = {
|
|||||||
// 双语文案用 {zh,en} 对象字段(各自可选):任一语言空/缺字段 → src/lib/site-content.ts 回落默认
|
// 双语文案用 {zh,en} 对象字段(各自可选):任一语言空/缺字段 → src/lib/site-content.ts 回落默认
|
||||||
siteHome: defineCollection({
|
siteHome: defineCollection({
|
||||||
loader: glob({ pattern: 'home.yaml', base: './content/site' }),
|
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 图片管线
|
// avatar 走 image():yaml 写 ./assets/xxx.jpg(相对 yaml 文件),构建期入 Astro 图片管线
|
||||||
schema: ({ image }) =>
|
schema: ({ image }) =>
|
||||||
z.object({
|
z.object({
|
||||||
tagline: bilingual.optional(),
|
/** 身份名片组(后台折叠):身份语/副标题(双语压平叶子)+ 头像/站点图标 */
|
||||||
avatar: image().optional(),
|
identity: z
|
||||||
/** 页面级排版覆盖(层 2):正文默认字体类 / 强调色类 / 字号缩放;不配置即全站默认 */
|
|
||||||
typography: z
|
|
||||||
.object({
|
.object({
|
||||||
font: z.string().optional(),
|
tagline_zh: z.string().optional(),
|
||||||
accent: z.string().optional(),
|
tagline_en: z.string().optional(),
|
||||||
scale: z.number().optional(),
|
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
|
||||||
})
|
subtitle_zh: z.string().optional(),
|
||||||
.optional(),
|
subtitle_en: z.string().optional(),
|
||||||
|
avatar: image().optional(),
|
||||||
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
|
/** 站点图标:public 下的路径(.svg/.ico 直出,不经图片管线);缺省回退 /favicon.svg */
|
||||||
favicon: z.string().optional(),
|
favicon: z.string().optional(),
|
||||||
/** 副标题(markdown 短文本,渲染在身份语下);空 = 不渲染 */
|
})
|
||||||
subtitle: bilingual.optional(),
|
.optional(),
|
||||||
/** 最新影像张数:0 隐藏区块,1-6 有效(越界由访问器夹取) */
|
/** 文案与排版组(后台折叠):公告(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(),
|
latestPhotos: z.number().int().optional(),
|
||||||
/** 首页每轨条数:4-8(越界由访问器夹取,缺省 6) */
|
/** 首页每轨条数:4-8(缺省 6) */
|
||||||
perTrackLimit: z.number().int().optional(),
|
perTrackLimit: z.number().int().optional(),
|
||||||
/** 首页项目墙条数:1-99(缺省 6;越界夹取) */
|
/** 首页项目墙条数:1-99(缺省 6) */
|
||||||
projectsLimit: z.number().int().optional(),
|
projectsLimit: z.number().int().optional(),
|
||||||
/** 首页公告(markdown 富文本,走富文本管线);空 = 不渲染 */
|
/** 首页轨细分:分开关阅读/技术/照片三轨(缺省全开) */
|
||||||
announcement: bilingual.optional(),
|
postsTracks_reading: z.boolean().optional(),
|
||||||
/** 首页轨细分开关:分开关阅读/技术/照片三轨(缺省全开) */
|
postsTracks_tech: z.boolean().optional(),
|
||||||
postsTracks: z
|
postsTracks_photo: z.boolean().optional(),
|
||||||
.object({
|
|
||||||
reading: z.boolean().optional(),
|
|
||||||
tech: z.boolean().optional(),
|
|
||||||
photo: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
/** 区块开关:缺省全开 */
|
/** 区块开关:缺省全开 */
|
||||||
sections: z
|
sections_tracks: z.boolean().optional(),
|
||||||
.object({
|
sections_photos: z.boolean().optional(),
|
||||||
tracks: z.boolean().optional(),
|
sections_heatmap: z.boolean().optional(),
|
||||||
photos: z.boolean().optional(),
|
sections_projects: z.boolean().optional(),
|
||||||
heatmap: z.boolean().optional(),
|
|
||||||
projects: z.boolean().optional(),
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
/** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...) */
|
/** 社交链接,顺序即展示顺序;icon 为内置图标集键(github/rss/email/...);顶层不进组(雷区纪律见上) */
|
||||||
socials: z
|
socials: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
+29
-17
@@ -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 =>
|
export const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
|
||||||
pick(value?.[locale], fallback[locale]);
|
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> {
|
export async function homeTagline(locale: Locale): Promise<string> {
|
||||||
const entry = await getEntry('siteHome', 'home');
|
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) */
|
/** 关于页身份陈述段(zh/en) */
|
||||||
@@ -109,13 +110,13 @@ export async function homeSocials(): Promise<SocialLink[]> {
|
|||||||
/** 头像(Astro 图片管线的 ImageMetadata);未配置 → null,调用方回退渐变占位 */
|
/** 头像(Astro 图片管线的 ImageMetadata);未配置 → null,调用方回退渐变占位 */
|
||||||
export async function homeAvatar(): Promise<ImageMetadata | null> {
|
export async function homeAvatar(): Promise<ImageMetadata | null> {
|
||||||
const entry = await getEntry('siteHome', 'home');
|
const entry = await getEntry('siteHome', 'home');
|
||||||
return entry?.data.avatar ?? null;
|
return entry?.data.identity?.avatar ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站点图标路径:yaml 值非空即采信,否则 /favicon.svg;值不带前导 / 时自动补 */
|
/** 站点图标路径:yaml 值非空即采信,否则 /favicon.svg;值不带前导 / 时自动补 */
|
||||||
export async function siteFavicon(): Promise<{ href: string; type: string }> {
|
export async function siteFavicon(): Promise<{ href: string; type: string }> {
|
||||||
const entry = await getEntry('siteHome', 'home');
|
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 href = raw.startsWith('/') || raw.startsWith('http') ? raw : `/${raw}`;
|
||||||
const type = href.endsWith('.svg')
|
const type = href.endsWith('.svg')
|
||||||
? 'image/svg+xml'
|
? 'image/svg+xml'
|
||||||
@@ -237,7 +238,15 @@ export async function pageTypography(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<PageTypography> {
|
): Promise<PageTypography> {
|
||||||
const entry = await getEntry(collection, id);
|
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 font = t?.font && ALLOWED_PAGE_FONTS.has(t.font) ? t.font : '';
|
||||||
const accent = t?.accent && ALLOWED_PAGE_ACCENTS.has(t.accent) ? t.accent : '';
|
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;
|
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> {
|
export async function homeConfig(locale: Locale): Promise<HomeConfig> {
|
||||||
const entry = await getEntry('siteHome', 'home');
|
const entry = await getEntry('siteHome', 'home');
|
||||||
const d = entry?.data;
|
const d = entry?.data;
|
||||||
|
const id = d?.identity;
|
||||||
|
const copy = d?.copy;
|
||||||
|
const c = d?.content;
|
||||||
return {
|
return {
|
||||||
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
|
subtitle: pickBi({ zh: id?.subtitle_zh, en: id?.subtitle_en }, { zh: '', en: '' }, locale),
|
||||||
announcement: pickBi(d?.announcement, SITE_DEFAULTS.homeAnnouncement, locale),
|
announcement: pickBi({ zh: copy?.announcement_zh, en: copy?.announcement_en }, SITE_DEFAULTS.homeAnnouncement, locale),
|
||||||
projectsLimit: clampInt(d?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
|
projectsLimit: clampInt(c?.projectsLimit, 1, 99, SITE_DEFAULTS.homeProjectsLimit),
|
||||||
postsTracks: {
|
postsTracks: {
|
||||||
reading: d?.postsTracks?.reading ?? SITE_DEFAULTS.homePostsTracks.reading,
|
reading: c?.postsTracks_reading ?? SITE_DEFAULTS.homePostsTracks.reading,
|
||||||
tech: d?.postsTracks?.tech ?? SITE_DEFAULTS.homePostsTracks.tech,
|
tech: c?.postsTracks_tech ?? SITE_DEFAULTS.homePostsTracks.tech,
|
||||||
photo: d?.postsTracks?.photo ?? SITE_DEFAULTS.homePostsTracks.photo,
|
photo: c?.postsTracks_photo ?? SITE_DEFAULTS.homePostsTracks.photo,
|
||||||
},
|
},
|
||||||
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
|
latestPhotos: clampInt(c?.latestPhotos, 0, 6, 4),
|
||||||
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
|
perTrackLimit: clampInt(c?.perTrackLimit, 4, 8, 6),
|
||||||
sections: {
|
sections: {
|
||||||
tracks: d?.sections?.tracks ?? true,
|
tracks: c?.sections_tracks ?? true,
|
||||||
photos: d?.sections?.photos ?? true,
|
photos: c?.sections_photos ?? true,
|
||||||
heatmap: d?.sections?.heatmap ?? true,
|
heatmap: c?.sections_heatmap ?? true,
|
||||||
projects: d?.sections?.projects ?? true,
|
projects: c?.sections_projects ?? true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,18 @@ await d.addInitScript(() => localStorage.setItem('theme', 'dark'));
|
|||||||
await d.goto(BASE + '/gallery/', { waitUntil: 'networkidle' });
|
await d.goto(BASE + '/gallery/', { waitUntil: 'networkidle' });
|
||||||
await d.screenshot({ path: `${SHOTS}/12-gallery-dark.png`, fullPage: true });
|
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();
|
await browser.close();
|
||||||
const fails = results.filter((r) => !r.pass).length;
|
const fails = results.filter((r) => !r.pass).length;
|
||||||
console.log(`\nE2E 汇总: ${results.length - fails}/${results.length} PASS${fails ? ',FAIL ' + fails : ''}`);
|
console.log(`\nE2E 汇总: ${results.length - fails}/${results.length} PASS${fails ? ',FAIL ' + fails : ''}`);
|
||||||
|
|||||||
Reference in new issue
Block a user