fix(admin/posts): 重建结果消息改为独立行,修复悬浮溢出表格的错位

RebuildCacheBar 的「已重建 X 篇文章」结果消息原用 absolute top-full mt-2
悬挂在按钮行下方,但 mt-2(8px) + text-sm(20px) = 28px 大于外层 space-y-6
的 24px 间距,消息底边向下溢出 4px 侵入表格容器上沿;横向 left-0 又使消息
落在右上角按钮组正下方、覆盖状态/日期/操作列,语义位置错位。

将 rebuilding / rebuild_result 状态上提到 PostsPage:
- 结果消息在 header 与表格之间独立成行,进入文档流,吃 space-y-6 正常间距,
  既不撑高 header 触发 items-center 重排(曾用 absolute 规避的旧问题),
  也不脱离流溢进表格(absolute 引入的新问题)。
- RebuildCacheBar 改为接收父级 Signal props,只渲染按钮行本身。
This commit is contained in:
xfy 2026-07-02 14:19:06 +08:00
parent 4a27236859
commit 62bd281684

View File

@ -43,6 +43,13 @@ pub fn PostsPage(page: i32) -> Element {
let mut total = use_signal(|| 0_i64); let mut total = use_signal(|| 0_i64);
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let mut deleting = use_signal(|| None::<i32>); let mut deleting = use_signal(|| None::<i32>);
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar结果消息也在本组件
// 渲染header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
// 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。
// 不加 mut本组件只读信号并下发.set() 都在 RebuildCacheBar 的 spawn 块里,
// 走 Signal 的内部可变性SSR target 下那些 set 不可见mut 会触发 unused_mut。
let rebuilding = use_signal(|| false);
let rebuild_result = use_signal(|| Option::<String>::None);
// 页码变化时加载分页数据WASM 前端请求接口SSR 直接结束加载。 // 页码变化时加载分页数据WASM 前端请求接口SSR 直接结束加载。
use_effect(move || { use_effect(move || {
@ -81,7 +88,10 @@ pub fn PostsPage(page: i32) -> Element {
h1 { class: "text-2xl font-bold text-paper-primary", "文章管理" } h1 { class: "text-2xl font-bold text-paper-primary", "文章管理" }
div { class: "flex items-center gap-3", div { class: "flex items-center gap-3",
// 重建缓存工具条(抽取为子组件 RebuildCacheBar见文件末尾 // 重建缓存工具条(抽取为子组件 RebuildCacheBar见文件末尾
RebuildCacheBar {} RebuildCacheBar {
rebuilding: rebuilding,
rebuild_result: rebuild_result,
}
Link { Link {
class: "px-4 py-2 bg-paper-accent text-paper-theme rounded-full text-sm font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer", class: "px-4 py-2 bg-paper-accent text-paper-theme rounded-full text-sm font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
to: Route::Write {}, to: Route::Write {},
@ -90,6 +100,15 @@ pub fn PostsPage(page: i32) -> Element {
} }
} }
// 重建结果消息:独立成行,进入文档流,吃 space-y-6 的正常间距。
// 既不撑高 header不在 header 内)触发 items-center 重排,也不脱离流
// 溢进表格(曾用 absolute top-full mt-2因 28px > 24px 间距溢出 4px
if let Some(msg) = rebuild_result() {
div { class: "text-sm text-paper-secondary",
"{msg}"
}
}
if loading() && posts().is_empty() { if loading() && posts().is_empty() {
DelayedSkeleton { PostsSkeleton {} } DelayedSkeleton { PostsSkeleton {} }
} else if posts().is_empty() { } else if posts().is_empty() {
@ -168,21 +187,19 @@ pub fn PostsPage(page: i32) -> Element {
/// 重建内容缓存工具条子组件。 /// 重建内容缓存工具条子组件。
/// ///
/// 封装「重建内容 / 重建全部」两个按钮及其状态:重建中态(`rebuilding`)、 /// 封装「重建内容 / 重建全部」两个按钮及其 `do_rebuild` 异步闭包。状态
/// 结果消息(`rebuild_result`)、以及 `do_rebuild` 异步闭包。完全自洽,与父组件 /// (`rebuilding` / `rebuild_result`) 由父组件 `PostsPage` 持有并下发:
/// 无任何状态共享。 /// 结果消息在父组件渲染为 header 与表格之间的独立行(进入文档流,吃
/// /// `space-y-6` 的正常间距),既不撑高 header 触发 `items-center` 重排,
/// 布局要点:根容器 `relative`,结果消息 `absolute` 悬挂于按钮行下方,**不进入文档流**。 /// 也不脱离文档流溢进表格。
/// 这样无论消息是否出现,本组件的盒高始终等于按钮行高度,避免外层 header 的
/// `items-center` 因消息撑高而把标题与「+ 写文章」按钮重新居中、与重建按钮错位。
/// ///
/// 从 `PostsPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill /// 从 `PostsPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill
#[component] #[component]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn RebuildCacheBar() -> Element { fn RebuildCacheBar(
let mut rebuilding = use_signal(|| false); rebuilding: Signal<bool>,
let mut rebuild_result = use_signal(|| Option::<String>::None); rebuild_result: Signal<Option<String>>,
) -> Element {
// 重建文章渲染缓存rebuild_all 为 false 时仅重建 content_html 为空的文章, // 重建文章渲染缓存rebuild_all 为 false 时仅重建 content_html 为空的文章,
// 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。 // 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。
let mut do_rebuild = move |rebuild_all: bool| { let mut do_rebuild = move |rebuild_all: bool| {
@ -214,46 +231,36 @@ fn RebuildCacheBar() -> Element {
}; };
rsx! { rsx! {
// 根容器仅占「按钮行」高度:结果消息用绝对定位悬挂于按钮下方, // 仅渲染按钮行本身:结果消息已上提到 PostsPage作为独立状态行进入文档流。
// 不进入文档流。否则消息出现时会撑高本组件,导致外层 header 的 div { class: "flex items-center gap-3",
// items-center 把「文章管理」标题与「+ 写文章」按钮重新居中、 div { class: "group relative",
// 与停在容器顶部的重建按钮产生纵向错位。 button {
div { class: "relative", class: if rebuilding() { "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" },
div { class: "flex items-center gap-3", disabled: rebuilding(),
div { class: "group relative", onclick: move |_| do_rebuild(false),
button { if rebuilding() {
class: if rebuilding() { "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" }, "重建中..."
disabled: rebuilding(), } else {
onclick: move |_| do_rebuild(false), "重建内容"
if rebuilding() {
"重建中..."
} else {
"重建内容"
}
}
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50",
"重建 content_html 为空的文章渲染缓存"
} }
} }
div { class: "group relative", div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50",
button { "重建 content_html 为空的文章渲染缓存"
class: if rebuilding() { "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" },
disabled: rebuilding(),
onclick: move |_| do_rebuild(true),
if rebuilding() {
"重建中..."
} else {
"重建全部"
}
}
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50",
"重建所有文章的渲染缓存(含已有内容)"
}
} }
} }
if let Some(msg) = rebuild_result() { div { class: "group relative",
div { class: "absolute top-full left-0 mt-2 text-sm text-paper-secondary whitespace-nowrap", button {
"{msg}" class: if rebuilding() { "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" },
disabled: rebuilding(),
onclick: move |_| do_rebuild(true),
if rebuilding() {
"重建中..."
} else {
"重建全部"
}
}
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50",
"重建所有文章的渲染缓存(含已有内容)"
} }
} }
} }