1005 Commits

Author SHA1 Message Date
xfy
22d05a98b3 chore(lint): 修复 Biome 告警(schema 版本/optional chain/CSS !important 抑制)
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
- biome.json: $schema 2.5.1 → 2.5.3,对齐已安装的 CLI 版本(消除 info)
- code-block-view.ts:157: `a && a.b()` → `a?.b()`(等价 optional chain)
- style.css:56: 保留刻意的 `transform: none !important`(删除会重新引入
  animation-fill-mode: both 导致的堆叠上下文 bug),仅加 scoped biome-ignore
  抑制 noImportantStyles,不做全局禁用
2026-07-15 18:12:04 +08:00
xfy
27ed605223 fix(comments): 修复 section.rs / dashboard.rs 括号不匹配导致编译失败
两处同一类错误:match arm 内 `if {} else {}` 表达式后多了一个 `}`,
导致 arm 被提前关闭,触发 "unexpected closing delimiter" 编译错误。

- section.rs:125 — `if !has_any {…}` 块后多余的 `}`,删除使 if-else 合一
- dashboard.rs:119 — 同类问题,另需补 `;` 终止 `let` 语句
2026-07-15 18:12:00 +08:00
xfy
bdc9f0f711 chore(make): fix 目标新增 cargo fmt 与 dx fmt
自动修复流程此前只跑 biome format + cargo fix,缺少格式化收尾:
cargo fix 应用编译器建议会重写代码,dx fmt 会改写 rsx!,
两者都可能留下不符合 rustfmt 风格的输出。

现在按 Biome → cargo fix → cargo fmt → dx fmt 顺序执行,
两道格式化收尾保证最终文件状态整洁。
2026-07-15 17:50:00 +08:00
xfy
886eeca915 style: 全项目 rsx! 格式规范化(cargo fmt)
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
2026-07-15 17:49:03 +08:00
xfy
cbb8be7441 feat(make): 新增 docker-amd64 与 docker-apple 目标构建 x86_64 镜像
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
docker target 只出当前宿主架构(Apple Silicon 上为 arm64),无法直接得到
x86 镜像;docker-multiarch 虽支持 amd64 但强制 --push 到 registry,无法本地
验证。补两个本地可用的 x86_64 构建 target:

- docker-amd64:docker buildx --platform linux/amd64 --load,走 QEMU 交叉仿真,
  产物进 Docker 本地 daemon,可直接 docker run / docker save。
- docker-apple:Apple Container CLI(macos 26 Tahoe)原生构建,无需 Docker Desktop,
  用 --arch(非 --platform)。

Dockerfile 本身按 dpkg --print-architecture 自适应选 musl target,无需修改。
2026-07-15 17:36:57 +08:00
xfy
832b7c626d fix(infra): Docker daemon 不可用时不再 panic
DOCKER_CLIENT 是 LazyLock<Docker>,socket 缺失时 .expect() 直接 panic;
而 release profile 是 panic="abort",会让整个服务进程挂掉——但博客本身并不依赖 Docker。

改为 LazyLock<Option<Docker>>:连接失败记 error 日志后存 None。
新增 get_docker() 在 None 时返回 IOError(NotFound),run_in_container /
run_in_container_stream 通过 ? 向上冒泡,最终在 execute.rs 的错误脱敏层映射为
「系统暂时不可用」,其余功能不受影响。
2026-07-15 17:23:56 +08:00
xfy
65a7b1226f style: 全项目格式规范化(cargo fmt)+ Docker daemon 断连容错
- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序)
- infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None
  (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃)
- infra/mod: 去除多余空行
- database/mod: 模块声明按字母序重新排序
2026-07-15 17:22:06 +08:00
xfy
71f3351c99 feat(server): 通过响应头主动暴露版本与 git 描述信息
新增 version_headers_middleware,为所有响应附加三个头:
- Server: yggdrasil/<version>(遵循 product/version 习惯)
- X-Yggdrasil-Version: Cargo 版本号
- X-Yggdrasil-Git: git describe(版本+提交数+短hash+脏标记)

数据源复用 build_info::BUILD_INFO,与启动日志 log_build_info() 同源,
零新依赖。

关键设计:中间件挂在最终合并 router 的最外层(Ok(router) 前),因此
/healthz、/uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应
都会带头,探测价值最大。此前 app_routes 的中间件栈只覆盖 Dioxus 路由,
无法覆盖这些端点。

受 EXPOSE_VERSION_HEADERS 控制,默认 true;设 0/false/no 关闭。
bool 解析沿用 COOKIE_SECURE 的 matches!("1"/"true"/"yes") 约定,
此处取反为"非 false 值即开"使默认行为对应「暴露」。启动时记录一条
info 日志便于确认生效值。
2026-07-15 17:12:55 +08:00
xfy
a3ca08f3f6 chore(docker): 完善 .dockerignore 规则
原 .dockerignore 仅覆盖 target/.git/node_modules 与部分 public 产物,
遗漏多个会导致上下文膨胀或信息泄露的路径:

- 修复信息泄露: .env(含真实 DATABASE_URL)此前会进入构建上下文
- 新增未忽略的构建产物: public/xterm/、public/doc/
- 排除机器本地/无关目录: others/(55M 引用博客)、.freebsd-sysroot/、
  docker/(runner 镜像源,单独构建)、docs/、tests/、scripts/
- 排除 agent/IDE/CI 工具: .zcode/、.superpowers/、.agents/、.gitea/、
  .vscode/、.idea/、rust-analyzer.toml 等
- 补充常见本地文件: .DS_Store、*.swp、profile.json.gz、compose*.yaml

保留所有真实构建输入(Cargo.toml、build.rs、Dioxus.toml、input.css、
.cargo/config.toml、src/、migrations/、libs/ 工作区、themes/、syntaxes/
及 public 运行时资源 favicon.ico/icons/images/),已逐一核对。
2026-07-15 15:18:13 +08:00
xfy
62d06fb35d chore(skill): 清理已卸载的第三方 skills 及其 lock 注册 2026-07-15 14:32:04 +08:00
xfy
875dc39b61 chore(skill): 补全 skills-lock.json 注册并格式化 SKILL.md 表格 2026-07-15 14:21:20 +08:00
xfy
2d5c7f3d2a style(main): 修复缩进格式与模块/导入排序
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
2026-07-15 13:56:43 +08:00
xfy
acfdfb03f5 fix(image): 为 ImageFmt 别名补上 #[cfg(feature = "server")] 门控
type ImageFmt = image::ImageFormat 未加 server feature 门控,
而 image crate 是 server-only 可选依赖。WASM 构建 (web feature)
解析到该别名时找不到 image crate,报 E0433。

detect_format (唯一使用方) 已有 #[cfg(feature = "server")],
测试也在 #[cfg(all(test, feature = "server"))] 下,别名只需对齐门控。
2026-07-15 13:30:38 +08:00
xfy
8efd8c8021 perf(image): cache_key 单次拼接 + detect_format 零分配后缀匹配
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
cache_key:旧实现 vec![path.to_string()] + 最多 6 个 format! + join,
最坏 9 次堆分配(path clone + 6 个参数 String + Vec 指针数组 + join String)。
改用 with_capacity + write! 直写,1 次分配。每次未命中缓存的图片请求都走。

detect_format:旧实现对整条路径 to_lowercase() 分配 String,只为查后缀。
改用 rsplit('.').next() 取后缀 + eq_ignore_ascii_case 零分配匹配。
每次图片请求调用 1-2 次。

注:rotate 在 resize 之前是刻意设计(让 w/h 作用于旋转后的最终方向),
不动以保持 URL 语义与缓存一致性。
2026-07-15 11:48:59 +08:00
xfy
b269fcf205 perf(posts): list/search 零 capacity Vec 改 collect 预分配 + helpers retain
list.rs 5 处、search.rs 1 处用 Vec::new()+循环 push,初始 capacity 为 0,
循环触发多次指数 realloc。rows.iter().map().collect() 用 slice 迭代器的
精确 size_hint 一次性预分配。首页/列表/搜索/标签分页路径。

helpers.rs row_to_post_list_item / row_to_post_full:tags 从
into_iter().filter().collect() 改 retain 原地过滤。unwrap_or_default()
已返回 owned Vec,旧写法重建第二个 Vec。每行文章记录省一次 Vec 分配。

顺带修 slugify 一个 dead store(prev_dash=false 后被 true 无条件覆盖)。
2026-07-15 11:46:56 +08:00
xfy
a41a1d3ae1 perf(markdown): 消除双解析 + format! 改 write! 直写
双解析:旧实现对同一份 md 调两次 Parser::new_ext(一遍收集标题、
一遍生成 HTML),等于两倍的 tokenize + 解析 CPU。现 collect 成
Vec<Event> 一次,两遍遍历复用(Event 内含 CowStr 借用 md 切片,
collect 后仍可重复借用)。解析 CPU 减半。

format! → write!:标题开始/结束、TOC li、runnable pre、language class
共 5 处在循环内用 format! 拼接 HTML 片段,每次先分配临时 String 再
push_str 复制进去、临时串立即丢弃。改用 write!(html, ...) 直写目标
String,零中间分配。N 个标题省约 3N 次临时分配。

顺带:html 与 toc_html 用 with_capacity 预分配(旧 String::new 多次 realloc)。

全部 68 个 markdown 测试通过。
2026-07-15 11:41:55 +08:00
xfy
d4f8b463d2 perf(slug): slugify 单遍状态机重写,分配 4→1
旧实现 4 次堆分配、3 遍扫描:
1. title.to_lowercase() 分配整串 String(中文/符号根本不受影响却全量拷)
2. split('-').filter().collect::<Vec<&str>>() 分配 Vec
3. parts.join('-') 再分配 String
4. slug.chars().take(100).collect() 截断又分配

新实现用 prev_dash 状态机在单遍内完成:拼音成词、ASCII 小写化、
分隔符合并去首尾、按 char 截断到 100。1 次预分配、1 遍扫描。

与同仓库 markdown.rs::slugify_heading 的单遍写法对齐。
全部 27 个 slug 测试通过(含拼音边界、连续 dash、首尾 dash、下划线保留)。
2026-07-15 11:38:54 +08:00
xfy
b34f44d3e7 perf(html): escape_html 链式 5 次 replace 改单遍扫描
旧实现链式 5 次 str::replace,每次全量扫描 + 分配新 String,
5 个特殊字符 = 5 次堆分配 + 5 遍全文扫描,中间 4 个 String 立即丢弃。

新实现:先按字节快速检测是否含特殊字符(纯文本直接 clone 返回),
否则单遍 match 扫描 + 单次 with_capacity 分配。

分配 5→1(-80%),扫描 5→1。该函数被 markdown 渲染管线密集调用
(每个标题文本、每个 runnable 代码块的 overrides/source 转义)。
2026-07-15 11:32:36 +08:00
xfy
604febde98 perf(upload): 消除 data.to_vec() 多次全文件深拷贝
data 是引用计数的 Bytes,clone 廉价(原子 +1)。
- validate 路径:data.to_vec() → data.clone(),省一次全文件拷贝
- 转码闭包:original_data = data.to_vec() → data.clone(),
  仅在「保留原格式」回退分支才 to_vec() 出落盘所需 Vec<u8>
- 路径拼接:用 chrono DelayedFormat 内联,省 year/month/day 三个中间 String

5MB 图片上传:转码成功路径从 10MB 堆分配降到 0(仅 Bytes 引用计数);
回退路径从 10MB 降到 5MB。
2026-07-15 11:28:21 +08:00
xfy
0835811487 style(header): 调整 nav 高度 80px → 64px (h-16) 2026-07-15 11:03:27 +08:00
xfy
ffa0a72e51 feat(admin/posts): tab 栏加平滑滑动指示器动画
PostsTabs 此前用 border-b-2 边框瞬切表示选中态,缺少 tab 切换的滑动动画。
移植 FilterTabs(system/comments 页)的滑动指示器机制:相对定位容器 + 绝对
定位滑块(贴底边盖住外层 border-b),WASM 端测量目标 button 的
offsetLeft/offsetWidth 动态定位,transition-all duration-300 驱动滑块从一个
tab 平滑滑到另一个。

选中态从 border-b-2 边框改为文字颜色区分(text-paper-primary),滑块负责
视觉指示。回收站数量角标保留不变。

与 FilterTabs 视觉一致,但 PostsTabs 因需支持角标故未直接复用 FilterTabs
(后者 items 是纯文本元组)。本地自建 POSTS_TAB_GROUP_ID 计数器给每处实例
唯一 DOM id 前缀。
2026-07-15 10:47:31 +08:00
xfy
50e64cd34c fix(code_runner): use_resolved_theme 从 use_effect 闭包提到 cfg 块顶层
dx check 报 4 处 'hook called in a closure: use_resolved_theme':
use_resolved_theme() 是 hook(内部 use_context),被错误地放在 use_effect 的
move || { ... } 闭包内调用。Dioxus 规则要求 hook 只能在组件渲染路径顶层调用。

修复:在两个 #[cfg(target_arch="wasm32")] 块顶层各调用一次 use_resolved_theme()
拿到 Memo<ResolvedTheme>,move 进下方 effect 闭包;闭包内改为调用 memo
(resolved_theme())读值。调用 memo 本身不是 hook(Memo 是 Copy + Fn),

读值的同时建立主题订阅——主题切换时 effect 仍会重跑,行为不变。
范式对齐 ThemeToggle(src/theme.rs:341)的正确用法。

影响 4 处:CodeMirror 挂载/主题同步 effect、xterm 挂载/主题同步 effect。
2026-07-15 10:29:01 +08:00
xfy
0b40105341 feat(skill): 新增 optimizing-rust-performance 性能优化 skill
TDD-for-skills 流程提炼:先跑 baseline(两个 Rust 子代理无 skill 时
一致地「提及但未应用」Cow/SmallVec,且不量化收益、不提 profiling),
再写最小 skill 堵住这些缺口,GREEN 测试确认子代理带上 skill 后实际
应用所有模式并量化收益。

核心内容:
- 固定优先级心智模型(算法→内存→布局→并发)
- 5 个触发→动作模式:swap_remove / retain / take / SmallVec / Cow
- 热点路径额外模式(切片取词素、借用切片匹配、ASCII 谓词)
- 量化收益表 + profiling 提醒规范
- 过度优化防护:新增依赖代价、Cow 返回类型的 API 传染、决策速查表
2026-07-15 10:24:20 +08:00
xfy
25299d266f refactor(admin): 合并 /admin/posts 与 /admin/posts/trash 为单路由 + tab 切换
将「全部文章」与「回收站」从两个独立路由(及各自的分页变体 PostsPage /
PostsTrashPage)合并为单一 /admin/posts 路由,用顶部 tab 在二者间切换。
tab 状态与翻页均改为客户端 signal 驱动(不走路由、不深链),与 system.rs 的
tab 模式一致:admin 内部页,刷新回到「全部文章」第 1 页。

路由瘦身:删除 PostsTrash / PostsTrashPage / PostsPage 三个变体,Router 不再
需要「静态段 trash 优先于 :page」的排序技巧。

主要改动:
- posts.rs: Posts 改为 tab 容器(PostsTab 枚举 + active_tab signal + key 化
  match 渲染),header 文案随 tab 切换;PostsPage 主体下沉为 AllPostsList,
  翻页改 current_page signal + use_paginated 回调式 Pagination。
- posts_trash.rs: PostsTrashPage → PostsTrashPanel(无参,signal 翻页),
  删除入口组件与 PostsTabs import(header/tab 由容器统一提供)。
- PostsTabs: 从路由驱动(use_route)改为 signal 驱动(on_change 回调)。
- 修复 tab 水平对齐 bug:两个 tab 统一 inline-flex items-center 同盒模型 +
  外层容器加 items-center,根除原先「全部文章」(inline 文本) 与「回收站」
  (inline-flex 带角标) 盒模型不一致导致的垂直错位。
- admin_layout.rs: is_posts_route 简化为只匹配 Route::Posts{}。
- RebuildCacheBar 改为完全自持 state(rebuilding + result),结果消息行随组件
  内联渲染,与父组件零耦合。
2026-07-15 10:21:50 +08:00
xfy
51e06cf118 refactor(ui): Pagination 支持可选 on_prev/on_next 回调翻页
翻页要本地化(走客户端 signal 而非路由)时,传入 on_prev/on_next 即渲染
button onclick;不传则保持原路由式 Link,前台/其他分页页面零影响。

为合并 /admin/posts 与 /admin/posts/trash 为单路由 tab 做准备:两个 tab
的列表翻页将改为 signal 驱动。prev_route/next_route 改为带默认值
(Route::Home {} 占位),回调存在时不被读取。
2026-07-15 10:00:40 +08:00
xfy
e18d2bfb2c fix(docker): 用 tmpfs mode=1777 替换 uid/gid 选项以兼容 Podman 2026-07-14 16:42:04 +08:00
xfy
af3cd4b525 chore: gitignore public/xterm 构建产物
新增 xterm feature 时漏了同步 .gitignore,导致 xterm-terminal 的
vite 构建产物(terminal.{js,css,js.map})被提交进仓库——与其余
4 个 lib(tiptap/codemirror/lightbox/yggdrasil-core)一致,这些
IIFE 产物本就该忽略,源码在 libs/xterm-terminal/src/。

git rm --cached 三个产物文件(磁盘保留),.gitignore 补 public/xterm。
2026-07-14 16:36:51 +08:00
xfy
9bc8b5c2ee refactor(libs): 抽取 @yggdrasil/shared 消除跨 IIFE 库的类型/常量重复
新增 libs/shared 内部包,作为跨 IIFE 库的单一真相源:
- ThemeName 类型(原 codemirror/themes.ts + xterm/themes.ts 各一份)
- THEME_CHANGE_EVENT 常量(原 yggdrasil-core 导出 + codemirror/xterm
  各硬编码同名 string literal,靠注释维系一致)
- prefersReducedMotion 函数(原 lightbox + yggdrasil-core 各一份)

codemirror-editor / xterm-terminal / lightbox / yggdrasil-core 四个库
改为 workspace 依赖 @yggdrasil/shared,Vite 构建时 inline 进各自 IIFE,
不破坏 IIFE 隔离。消除「改一处忘改另一处导致静默失效」的风险。

附:image_reader_limits 已在上一提交共享;本提交是 libs 侧的对称改动。
2026-07-14 15:39:41 +08:00
xfy
6837b4cfa2 refactor(image): 合并维度读取函数,共享 image_reader_limits
提取 read_webp_dimensions / read_image_dimensions 两个核心函数,
read_dimensions_by_mime 与 read_dimensions_from_bytes 各自只负责
格式映射(MIME→format / 扩展名→format),消除 zenwebp 调用与
into_dimensions 逻辑的双份实现。

附带收紧:read_dimensions_from_bytes 原用 with_guessed_format() 猜测
格式,现改为按扩展名显式匹配,避免猜测错误。

image_reader_limits() 改为 pub(crate),upload.rs 的内联 limits 构造
(4 行逐字重复)改为调用共享函数。
2026-07-14 15:13:21 +08:00
xfy
fade4e180f refactor: 删除死代码 CommentActions 组件
components/comments/actions.rs (CommentActions) 零引用——评论管理
操作(通过/垃圾/删除)已在 pages/admin/comments.rs 用 BTN_SOLID_*
常量实现,actions.rs 是遗留的重复实现。

原计划将它的内联按钮样式提取为 BTN_PILL_* 常量统一,但确认零引用后
直接删除整个文件更干净。trash_comment re-export 补 allow(unused_imports)
(wasm-only import 在 server 构建触发的已知模式)。
2026-07-14 15:09:41 +08:00
xfy
9e57247a2d refactor: 统一 WASM sleep 到 utils::time::sleep_ms
删除 system.rs 的私有 wasm_sleep(与 utils::time::sleep_ms 逐字相同),
6 处调用改用 crate::utils::time::sleep_ms。
ui.rs 的内联 Promise+setTimeout(50ms) 同样替换为 sleep_ms(50)。

消除 3 份 sleep 实现中的 2 份冗余副本,utils::time::sleep_ms 成为全项目
唯一的 sleep 入口(已有 native tokio + WASM 双实现)。
2026-07-14 14:55:34 +08:00
xfy
804bcfb7b7 refactor(upload): 抽取 upload_error() 消除 15 处 JSON 错误响应样板
upload.rs 中 14 处 return Err((StatusCode::X, Json(json!({"success":
false, "error": MSG})))) + 1 处 map_err 同款 tuple,全部替换为
upload_error(status, msg) 单行调用。helper 接受 impl Serialize,
兼容 &str / String / 字面量等调用形式。
2026-07-14 14:53:18 +08:00
xfy
8da678b3b4 refactor(cache): 抽取 invalidate_post_metadata() 封装 4 行失效组合
文章写操作后总要一起失效 lists/tags/stats/search 四项元数据缓存。
新增 invalidate_post_metadata() 封装这个固定组合,替换 create/delete/
update/trash 中的 8 处重复 4 行序列。

保留定向失效(invalidate_post_by_slug / invalidate_tag_posts_for)
和批量回退路径(invalidate_all_post_caches + invalidate_search_results)
的显式调用——它们是按实际涉及数据细粒度控制的,不应被封装吞掉。
2026-07-14 14:44:00 +08:00
xfy
22750ef89e refactor(api): 为 Response 类型添加构造器,消除 51 处样板
CreatePostResponse 新增 err()/ok()/ok_msg() 构造器,消除 posts/
 下 34 处 { success, message, post_id, slug } 字面量构造。

CommentResponse 新增 error()/ok()/created() 构造器,消除 comments/
 下 17 处 { success, message, error_code, comment_id, ... } 字面量构造。

构造器只在 server function body 内调用,WASM 端因 cfg gate 剥离了
调用点,故加 #[cfg_attr(not(feature="server"), allow(dead_code))]。

行为完全不变(505 测试全过),代码量净减约 300 行样板。
2026-07-14 14:36:56 +08:00
xfy
16caa7dd9e fix(comments): 统一 escape_html,修复代码块单引号未转义
comments/markdown.rs 的本地 html_escape 只转义 4 个字符(漏单引号),
与规范实现 utils::html::escape_html(5 字符,'→&#x27;)不一致。

删除本地副本,改用 crate::utils::html::escape_html。评论代码块里的
单引号现在会被正确转义,与其他上下文的转义行为统一。
2026-07-14 14:29:53 +08:00
xfy
b39afbb616 refactor: 删除死代码 (delayed_loading.rs / ui.rs EmptyState / 未用 re-export)
- 删除 src/hooks/delayed_loading.rs:未在 hooks/mod.rs 注册,已被
  DelayedSkeleton 组件取代(mod.rs 注释已说明)
- 删除 src/components/ui.rs 的 EmptyState:零调用者,所有实际用法
  都走功能更全的 components/empty_state.rs::EmptyState
- 删除 api/posts/mod.rs 末尾的 render_markdown_enhanced / slug 工具
  re-export:调用方均使用完整路径 crate::api::{markdown,slug}::*,
  这两个跨模块短路径无引用
- 删除 api/comments/mod.rs 的 render_comment_markdown re-export:
  调用方用完整路径 crate::api::comments::markdown::render_comment_markdown

保留 auth.rs::invalidate_user_sessions:有完整文档说明的预留基础设施
(角色变更/封禁场景),非遗留垃圾。
2026-07-14 14:27:31 +08:00
xfy
0e4109358f docs(system): 修复 BackupRowProps 文档里 Popover 的 broken intra-doc link
Some checks failed
CI / check (push) Failing after 11m58s
CI / build (push) Has been skipped
[`Popover`] 简写形式解析失败——Popover 的 use 只在 fn BackupRow 函数体里,
对 BackupRowProps struct 的 doc comment 不可见。补全为全路径
[`Popover`](crate::components::ui::Popover),保留可点击跳转。
2026-07-14 13:39:34 +08:00
xfy
eccaa9f2a9 chore: release v0.4.0
Code Runner + /admin/system + UI 重新设计
v0.4.0
2026-07-13 19:59:22 +08:00
xfy
c3ae0aa975 refactor(skills): deploy-to-xun → deploy-to-linux 通用化
将部署 skill 从 xun 专属改为适用于任意 Linux 服务器:
- 新增「第 0 步:探测目标服务器」:一条 ssh 命令拿全 OS/运行时/shell/
  socket/磁盘/端口/容器列表,配套决策表
- 新增「Docker vs Podman 关键差异」表(socket 路径/镜像短名/tmpfs 兼容)
- 前置反代从「复用 nginx-proxy」单一选项扩展为三方案决策:
  A 复用现有 nginx-proxy / B 自建 nginx-proxy / C 自建 nginx + 手动证书
- compose 模板参数化:<host>/<域名>,socket 挂载按 Docker/Podman 二选一
- 保留项目专属部分(LANGUAGES 注册表硬编码、runner 沙箱、镜像名约定)
- fish shell 陷阱、Rosetta 要求、buildx 命名 bug、scratch 健康检查等
  实战经验全部保留
2026-07-13 19:47:56 +08:00
xfy
7584e0ca1c docs(skills): 新增 deploy-to-xun 部署流程 skill
将本次实战验证的部署流程提取为可复用 skill,涵盖:
- 本地 arm64 用 Docker Rosetta 构建 x86 镜像(绕过 QEMU 跑 rustc 的 SIGSEGV)
- runner 镜像 localhost/ 前缀与 LANGUAGES 注册表硬编码名的 tag 映射
- xun 服务器 fish shell 陷阱(变量赋值/循环/退出码语法差异)
- 复用 nginx-proxy + acme-companion 自动签证的 compose 配置
- podman socket 路径映射与 scratch 镜像健康检查方式
- Code Runner 在 podman 上的 tmpfs 兼容性已知限制

每一步均在本次会话中实战验证通过(14 迁移成功、HTTPS 200、证书签发)。
2026-07-13 19:40:41 +08:00
xfy
067075df8a fix(post): 直接访问 #hash 时标题被 sticky header 遮住
点击锚点正常,但直接访问带 hashtag 的链接时滚动后标题被 80px sticky
header 遮住。根因:浏览器原生 fragment-scroll(直接访问触发)不读 CSS
scroll-margin-top,在我们 scrollToHash 的平滑滚动之后触发,把标题顶到
视口最顶端(header 下方)。

抽出共享 scrollToHeading:用 getBoundingClientRect 手动计算绝对滚动位置,
减去动态测量的 header 高度(+16px 呼吸空间)后 window.scrollTo,不再依赖
scroll-margin-top。hash-scroll(直接访问)与 anchor-click(点击)统一调用它。

scrollToHash 额外用双 requestAnimationFrame 延后做一次即时校正,覆盖原生
fragment-scroll 的触发窗口,确保落点在原生 scroll 之后生效。

scroll-margin-top: 6rem 保留在 input.css 作其它原生 scroll 场景的兜底。
2026-07-13 18:31:31 +08:00
xfy
5ad6ce60cd fix(post): hydration 后点击标题锚点触发整页刷新
WASM 下载完成后点击 TOC/标题 # 锚点会整页刷新且不滚动,而 SSR 阶段(WASM
未就绪)点击正常——这是 Dioxus 0.7 事件委托的副作用:

项目任意位置用 onclick(post_nav_links.rs)即触发 Dioxus 全局 click 委托。
点击 dangerous_inner_html 注入的 <a href="#id"> 时,事件冒泡到根监听器,
handleEvent 发现 <a> 无 Dioxus onclick → 走兜底 handleClickNavigate:
preventDefault 拦掉原生 fragment-scroll,再 browser_open IPC 把 hash 当
外部 URL 整页加载(History::external → location.set_href)。

修复:yggdrasil-core 新增 anchor-click.ts,在 window capture 阶段(早于
Dioxus 的 bubble 委托)拦截同页 hash 锚点点击,stopPropagation 阻止事件
到达 Dioxus,自行 scrollIntoView + replaceState 更新地址栏。中键/修饰键、
外链、目标不存在等情况放行原行为。

81682eb 的 scrollToHash 只在 PostContent 挂载时跑一次,不覆盖点击场景;
本修复与之正交,共同覆盖刷新与点击两条路径。initAnchorClick 幂等。
2026-07-13 18:22:53 +08:00
xfy
56f599574f feat(slug): 中文标题自动转拼音生成 URL slug
文章 URL slug 与 markdown 标题锚点此前会吃掉中文字符:纯中文标题
退化成时间戳,混入 ascii 时只剩 ascii 片段。

引入 pinyin crate(纯 Rust、零依赖,安全交叉编译到 musl/FreeBSD),
slugify 与 slugify_heading 在字符过滤前先尝试 to_pinyin():
- 汉字 → 无声调拼音,每字成词用 - 分隔(你好 → ni-hao)
- ASCII 字母数字与 -/_ 保留
- 多音字取默认读音,博客场景足够

示例:你好世界 → ni-hao-shi-jie;Rust 入门指南 → rust-ru-men-zhi-nan

is_valid_slug 不变(原本就允许 Unicode),手动输入中文 slug 仍可用。
pinyin 为 server-only optional 依赖,不进 WASM bundle。
2026-07-13 18:04:35 +08:00
xfy
cc4c551aaa fix(code-runner): 容器清理失败重试+日志告警,避免静默泄漏
ContainerGuard::drop 之前用 let _ = 吞掉 remove_container 的错误,
Docker daemon 瞬时不可用会让容器泄漏且无人知晓。

改为最多重试 3 次(指数退避 200/400/800ms),仍失败则记录 error 级日志
(带 container_id 便于 docker rm -f 兜底)。容器清理是 fire-and-forget,
无法把错误回传业务层,所以用日志暴露故障而非 panic。
2026-07-13 17:56:42 +08:00
xfy
ee9d2ecd6e style(ui): 组件圆角类对齐三档梯度(32/16/8)
配合 input.css 的 token 化,把散落在 RSX 组件里的孤悬圆角档位
统一到内容档 16px (rounded-2xl):

消除 24px (rounded-3xl, 4处无规范依据的中间档):
- ADMIN_CARD_CLASS / ADMIN_TABLE_CLASS (ui.rs)
- write.rs 编辑器容器 / write_skeleton.rs 编辑器骨架

消除 12px (rounded-xl, 10处):
- INPUT_CLASS (forms.rs) 与 write 页输入框统一到 16px
- write 页 3 处输入框 + 4 处错误提示框
- admin_layout 退出按钮
- system/admin_skeleton/posts_trash 卡片容器
- write_skeleton 骨架条(模拟输入框形状,跟随真实圆角)

改造后全站只剩 4 种圆角语义: 32px容器 / 16px内容 / 8px微元素 /
胶囊按钮,孤悬档位彻底清除。
2026-07-13 16:21:10 +08:00
xfy
248dcb761c refactor(ui): 圆角统一为三档梯度(32/16/8)+token化
将全站圆角收敛为语义化三档梯度:
- --radius-card  32px  容器(卡片/封面/admin主面板)
- --radius-paper 16px  内容(代码块/正文图片/iframe/表格)
- --radius-mini   8px  微元素(标签/复制按钮/toc/paginav)

此前内容区代码块、正文图片圆角仅 8px,与卡片 32px 设计语言断层
4 倍;表格 16px 是硬编码字面量散落 5 处。本次:
- --radius-paper 从 8px 提到 16px,代码块/图片/iframe 随之提档
- 表格 5 处 16px 字面量改用 var(--radius-paper)(值不变,语义化)
- toc/复制按钮/标签/paginav/blur-img 拆出到新 --radius-mini:8px
- 封面 2rem 改用 var(--radius-card)(值不变,语义化)

按钮/头像/徽章另用胶囊(rounded-full),独立于本梯度,不动。
2026-07-13 16:21:02 +08:00
xfy
2310ed9abe fix(system): 备份恢复实际不写入数据 + 假成功
三层叠加 bug 导致「恢复完成却零数据变更」:

1. 备份用 pg_dump 生成但无 --clean,脚本不含 DROP。恢复到已有数据的库时,
   CREATE TABLE 全部 'already exists',COPY public.posts 在第一行就因主键
   冲突中止(COPY 0),被软删的文章(deleted_at 非空)永远回不来。

2. psql 默认不带 ON_ERROR_STOP,即使满屏 ERROR 退出码仍是 0。run_restore
   只判断 status.success(),于是 tasks::update 误报 TaskStatus::Done——
   用户看到「恢复完成」但实际零写入。

3. 即使数据写进去,前端 moka 缓存(posts/tags/stats/search)和 SSR 世代号
   也不会失效,恢复后仍读旧数据。

修复:
- run_pg_dump_backup 加 --clean --if-exists,备份脚本自带 DROP IF EXISTS,
  恢复变为幂等的先删后建。
- run_restore 给 psql 加 -v ON_ERROR_STOP=1,任何 SQL 错误立即退出(码 3),
  status.success() 不再误判。
- 恢复成功后调用 invalidate_all_post_caches + invalidate_search_results
  + bump_global_generation,前端立即看到恢复的数据。

验证:建干净测试库跑完整反馈回路(3 篇 → 软删 2 篇 → 恢复),修复前 alive=1,
修复后 alive=3,exit 0。旧备份文件(无 DROP)在 ON_ERROR_STOP 下会正确报失败
而非假成功,需重新创建备份才能恢复。

根因假设(已通过最小反馈回路证实):pg_dump 无 --clean + psql 无 ON_ERROR_STOP +
缓存未失效三层叠加。hypothesis 来自 backup.rs 源码 + backups/*.sql 实际结构 +
psql 退出码语义实测。
2026-07-13 15:57:34 +08:00
xfy
5888f1fc41 feat(system): 备份恢复删除/恢复改用 Popover 确认框替代浏览器 confirm
新增通用 Popover 组件(src/components/ui.rs,与 Tooltip 对称),定位用
position:fixed + MouseEvent::client_coordinates() 视口坐标锚定,逃出表格
overflow-hidden;透明遮罩 z-40 兜底点击外部关闭,组件内 use_effect 注册
全局 keydown 监听 Esc 关闭(use_drop 清理),150ms 淡入缩放动画
(input.css 新增 popover-enter keyframes)。

BackupRow 删除/恢复按钮 onclick 读点击坐标打开对应确认 popover,确认
按钮回调父组件。顺带简化恢复链路:原生 confirm 是阻塞式才需要
pending_restore signal + 确认 use_future 的间接机制,popover 非阻塞后
on_restore 直接 busy + restore_backup + active_task_id,移除 pending_restore
signal 及其 use_future(进度轮询 use_future 不变,仍服务创建备份+恢复)。
2026-07-13 15:08:24 +08:00
xfy
c1530956b0 style(system): 数据导出 tab 卡片宽度与其它 tab 对齐
ExportTab 根容器带了 max-w-2xl 把卡片限宽在 672px,而 DbStatusTab /
ServerStatusTab / SqlConsoleTab / BackupTab 都不限宽填满 max-w-7xl
内容区。去掉 max-w-2xl 让数据导出卡片与其它 tab 宽度一致。内部表单
输入用 w-full,加宽后自然拉伸,无空白。
2026-07-13 14:23:14 +08:00
xfy
2e40836480 fix(system): 备份/恢复任务轮询永不启动导致按钮卡死在 loading
点击「创建备份」后接口返回 task id,但按钮一直转圈、列表不刷新。
根因是 AGENTS.md 踩坑记录里的「Reactive hooks 不追踪普通 props/snapshot」:

进度轮询 use_future 在挂载时把 active_task_id 快照进 _task_id_for_poll
(彼时为 None),use_future 只运行一次即 return;用户点击后 create_backup
返回 task id 并 active_task_id.set(Some(id)),但 future 已结束、永不重跑
→ 轮询不启动 → busy 永远 true。恢复确认 use_future 有同样的快照陷阱
(_pending_for_confirm),导致点「恢复」连确认框都不弹。

改用与 DbStatusTab 自动刷新一致的长生命周期 loop 模式:use_future 只跑
一次,内部 loop 每轮迭代读 active_task_id() / pending_restore() 信号,
空闲时 200ms yield 呼吸。这样信号更新在下一轮迭代即被感知,无需依赖
ReactiveContext 对挂载期快照的订阅。
2026-07-13 13:56:22 +08:00