700 Commits

Author SHA1 Message Date
xfy
5b6cb7d796 feat(middleware): COMPRESSION_ALGORITHMS 默认值改为 off
未设置环境变量时不再默认启用全部压缩算法,改为默认关闭。显式设为
"all" 或具体算法组合仍可按需开启。

- src/middleware.rs: fallback 由 "all" 改为 "off",同步更新文档注释
  与默认值测试(compression_default_env_is_all → _is_off)
- .env.example: 示例值与说明同步更新
2026-07-16 11:33:14 +08:00
xfy
593c0322bc fix(server): 端口被占用时优雅退出而非 panic
dioxus::server::serve() 内部对 TcpListener::bind 失败直接
unwrap(dioxus-server 0.7.9 launch.rs:143),端口占用会触发
裸 panic + SIGABRT。在迁移完成后、serve() 前先探测同一地址,
失败走统一的 exit(1) 路径并给出可操作的提示(lsof / PORT)。
2026-07-16 11:23:45 +08:00
xfy
c2263e6bf1 chore(lint): 修复 rust-1.97 clippy useless_borrows_in_formatting 告警
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
新版 clippy 在 format! 参数的冗余 & 上新增 lint(与 mimalloc 改动无关),
移除 export.rs 的 &table_name 以保持 make lint 零告警。
2026-07-16 10:53:59 +08:00
xfy
5b183007a7 perf(server): 用 mimalloc 替换系统全局分配器
多线程高频小对象分配场景下,mimalloc 吞吐显著优于系统 malloc,且对全静态
musl 链接友好(生产 Docker 镜像即 musl 目标)。选 mimalloc 而非 jemalloc:
本项目生产 server 是全静态 musl 二进制,jemalloc 在 musl 上有 pthread_getname_np
(musl<1.2.3)/unprefixed-malloc 符号冲突的已知坑。

cfg 双门控(与项目双目标编译约定一致):
- feature=server:分配器只服务端二进制需要
- not(wasm32):mimalloc_rust 在 wasm32 上无法编译(Issue #76),前端走默认分配器

mimalloc 声明为 optional + 经 server feature 启用,与 argon2/uuid 等 server-only
依赖同栏位。验证:server native + WASM 双目标编译通过 + clippy 零告警。
musl 目标受本机无 musl-gcc 限制,需 Docker 内构建验证(同 zenwebp 等任何 C 依赖)。
2026-07-16 10:53:51 +08:00
xfy
c6cb401522 refactor(admin): 拆分 system.rs 为 system/ 目录(按 tab 分文件)
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
pages/admin/system.rs 单文件 1792 行装 5 个 tab(数据库状态/服务器状态/SQL控制台/导出/备份恢复),按已有 api/database/ 的对称模式拆成 system/ 目录。

关键前提(已验证):5 个 tab 状态完全独立、互不共享,切换时父组件用 key 强制卸载,零跨文件状态耦合。

拆分结构(与 api/database/mod.rs 全路径风格对称):
- system/mod.rs: SystemTab enum + System 入口 + format_bytes(pub(super),3 tab 共用) + tests
- system/db_status.rs: DbStatusTab
- system/server_status.rs: ServerStatusTab + format_uptime(仅它用)
- system/sql_console.rs: SqlConsoleTab(codemirror_bridge 依赖)
- system/export.rs: ExportTab + urlencode(wasm32)
- system/backup.rs: BackupTab + BackupRow + urlencode_dl(自包含,不再跨文件依赖 export.rs)

路由侧零改动: admin/mod.rs 的 pub mod system + pub use system::System 不变(Rust 自动认 system/mod.rs)。双目标编译 + clippy 严格 + dx check + SystemTab 测试全部通过。
2026-07-15 18:28:39 +08:00
xfy
110be35c54 refactor: 抽 main.rs 中间件到 src/middleware.rs
main.rs 混合了配置校验、压缩层、cache-control、admin guard、迁移 runtime、后台任务、路由注册,其中 5 个 server-only 中间件/纯函数可独立测试。迁出到新建 src/middleware.rs(#![cfg(feature=server)]):CompressionAlgorithms + parse_compression_algorithms + compression_layer_from_env + cache_control_for_path + add_cache_control + admin_guard,连同 12 个单元测试。main.rs 调用点改为全路径 crate::middleware::xxx。无新 crate、无循环依赖。main.rs 756 -> 382 行,中间件现已可独立测试。
2026-07-15 18:20:56 +08:00
xfy
5a88531543 refactor(api): 收敛 mod.rs 逐行 allow 为文件级 #![allow]
api/posts/mod.rs(7处) 与 api/comments/mod.rs(3处) 在 pub use 行上方逐行加 #[allow(unused_imports)],而文件顶部已有 #![allow(clippy::unused_unit, deprecated)]。这些 allow 经核实真实必要(消费侧 server fn 多仅在 WASM 路径,SSR 编译时 unused),故收敛为 #![allow(..., unused_imports)] 等价替代,删掉 10 处逐行 attribute。clippy 严格模式 -D warnings 通过。
2026-07-15 18:16:49 +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
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
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
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
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
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
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
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
xfy
cf14aae17f style(ui): 统一 admin 分页按钮为圆角胶囊样式
admin 分页的上一页/下一页按钮原先用 rounded-sm 方角实心(灰黑底
+ font-mono),与本页其它操作按钮(BTN_OUTLINE 等圆角胶囊)风格
不一致。改为与描边按钮同族的 rounded-full 胶囊,hover 走主题强调
色,禁用态同步圆角。

因三个 admin 列表页(posts / posts_trash / comments)共用
Pagination 组件的 admin variant,此改动一次性统一全部后台分页。
2026-07-13 13:38:50 +08:00
xfy
093940a0d7 fix(theme): skip editor set_theme during VT animation to prevent direct jump
主题切换时,可运行代码块(CodeMirror + xterm)整体瞬切,不受 VT 圆形展开动画
控制——圆形还没展开到代码块,代码块就整体变色了。

根因(真实页面逐帧像素采样 + 事件追踪确认):
ThemeToggle::onclick 先调 __startThemeTransition(同步截 OLD 快照,VT 回调异步),
再调 theme.set(next)。theme.set 触发 Dioxus use_effect → set_theme,这个调用
在 OLD 快照截取后、VT 回调执行前的时间窗内,直接改了实时 DOM 的编辑器背景。
VT 动画播的是伪元素快照(::view-transition-old/new),但实时 DOM 的改动会穿透
伪元素,表现为代码块在圆形展开到达前就整体瞬切。

上一个提交(theme-change 事件)已在 VT 回调内同步 setTheme(进入 NEW 快照),
但 use_effect 的冗余 set_theme 仍在动画期间改实时 DOM,抵消了快照内的正确状态。

修复:CodeMirror + xterm 的 use_effect 在 is-theme-transitioning 期间跳过
set_theme 调用。VT 事件已在快照前同步换肤;动画结束后 is-theme-transitioning
被移除,use_effect 会因 resolved 信号变化重跑,做幂等兜底同步(覆盖非 VT 场景:
系统偏好变化、初始挂载)。

验证(scripts/vt-editor-sweep.mjs,真实 Dioxus 点击路径):
- 修复前:编辑器整体瞬切(t=111ms 全部变 dark,无渐变)
- 修复后:编辑器从右向左被圆形逐步揭示(t=228ms 右侧先 dark,t=313ms 左侧 dark,
  中间点显示圆形边缘抗锯齿色),与页面其他部分同步
2026-07-13 13:33:33 +08:00
xfy
e50941ef24 fix(runner): propagate duration_ms through SSE done event
The SSE streaming path hardcoded '耗时: -' because duration_ms was
never threaded through:

- docker.rs: OutputChunk::Done now carries duration_ms, computed from
  start_container to wait completion via Instant::elapsed
- sse.rs: DonePayload gains duration_ms, serialized into the done event
- runner.rs: WASM done callback formats the real duration instead of '-'

The polling fallback path already had correct duration via ExecResult;
only the SSE path was missing it.
2026-07-13 10:09:15 +08:00
xfy
aa2d7f3f2d fix(runner): use python -u for unbuffered stdout streaming
Python defaults to 4KB block buffering when stdout is a pipe (not a
TTY). Docker attach uses pipes, so print() output accumulated in the
buffer and only flushed when the process exited — the 3-second
sleep(1) loop appeared as a single dump at the end.

python -u (equivalent to PYTHONUNBUFFERED=1) forces line-level
flushing, so each print() is immediately written to the pipe and
picked up by the concurrent log reader → SSE → xterm.js.

Node/Go/Rust already line-buffer to pipes, so only Python needed the
flag. stdout/stderr separation is preserved (no TTY merge).
2026-07-13 10:03:58 +08:00
xfy
2059a55e11 fix(runner): make wait_container concurrent with log reading
run_in_container_stream was sequentially awaiting wait_container before
reading the log stream. wait_container blocks until the container
exits, so by the time the log loop ran, all output was already buffered
in the attach stream and got drained near-instantly — appearing as a
one-shot dump instead of streaming.

Now uses tokio::select! to run log_reader and wait_with_timeout
concurrently:

- log_reader: drains the attach stream, pushing each chunk via tx
  immediately as it arrives
- wait_with_timeout: waits for container exit (with timeout, kills on
  timeout)

Whichever finishes first wins; the survivor is drained/handled. When
wait completes, any tail bytes still in the stream are flushed before
sending the Done chunk.

This is the root cause of the streaming not working — a sleep(1) loop
now streams line-by-line instead of dumping all at once after exit.
2026-07-13 09:59:03 +08:00
xfy
9191d07200 fix(ui): hide output area until first run; show skeleton while waiting
The output area was always visible (empty terminal container shown on
page load). Now it only appears after the user clicks Run, with three
states:

- Not run: output area hidden entirely (show_output = false)
- Running, no output yet: skeleton screen placeholder
  (running && !has_output)
- Output received: skeleton gone, xterm renders streamed content

Two new signals drive this:
- show_output: set true on Run click, controls output area visibility
- has_output: set true on first stdout/stderr chunk (SSE) or writeAll
  (polling fallback), dismisses the skeleton

xterm mount effect now reads show_output, so it retries after the
container div enters the DOM (when show_output flips true).
2026-07-13 09:49:57 +08:00
xfy
7d153e50f4 feat(ui): CodeRunner switches to SSE + xterm.js streaming output
WASM path: start_exec_stream → EventSource SSE → xterm.js terminal.
stdout/stderr events write to the terminal in real time; done event
sets exit status and closes the connection. Falls back to polling
get_exec_result (writeAll) if EventSource creation fails.

Server path (placeholder, component runs client-side): retains the
original polling logic so both targets compile.

- xterm.js terminal mounted via xterm_bridge (mirrors CodeMirror mount
  pattern: use_effect + TerminalHandle + use_drop)
- run_code split into #[cfg(wasm32)] (SSE) and #[cfg(not)] (polling)
- output area: <pre> replaced with xterm container div
- poll_result / start_sse helpers in WASM-only sse_consumer module
- term_handle declared at component scope (cfg-gated) so the WASM
  run_code closure can capture it

Note: dx check reports use_resolved_theme-in-closure for the new xterm
effects, same as the pre-existing CodeMirror effects (lines 145, 177).
This is an existing project-wide pattern; cargo build on both targets
and cargo clippy pass clean.
2026-07-10 11:58:25 +08:00