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 前缀。
This commit is contained in:
xfy 2026-07-15 10:47:31 +08:00
parent 50e64cd34c
commit ffa0a72e51

View File

@ -435,40 +435,84 @@ fn PostRow(
} }
} }
/// tab 组 id 自增计数器:给每处 PostsTabs 实例一个唯一前缀,用于 DOM 测量滑块位置。
/// ui.rs 的 FilterTabs 有同款 TAB_GROUP_ID此处为避免跨模块可见性污染本地自建。
static POSTS_TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// 文章管理 tab 栏:「全部文章」与「回收站」。 /// 文章管理 tab 栏:「全部文章」与「回收站」。
/// ///
/// tab 状态由父组件传入的 `active` signal 驱动(非路由),点击即调用 `on_change` /// tab 状态由父组件传入的 `active` signal 驱动(非路由),点击即调用 `on_change`
/// 切换。回收站 tab 带 `trash_count` 数量角标,便于发现待清理文章。 /// 切换。回收站 tab 带 `trash_count` 数量角标,便于发现待清理文章。
/// ///
/// 对齐修复:两个 tab 均用 `inline-flex items-center` 同盒模型,外层容器加 /// 底部带**平滑滑动指示器**(绝对定位的滑块 + transition切换 tab 时滑块从
/// `items-center`,根除原先「全部文章」(inline 文本) 与「回收站」(inline-flex /// 一个 tab 平滑滑到另一个,与 FilterTabssystem/comments 页)视觉一致。滑块
/// 带角标) 盒模型不一致导致的垂直错位。 /// 位置通过 WASM 端测量目标 button 的 offsetLeft/offsetWidth 动态计算。
///
/// 两个 tab 均用 `inline-flex items-center` 同盒模型,外层容器加 `items-center`
/// 根除原先「全部文章」(inline 文本) 与「回收站」(inline-flex 带角标) 盒模型
/// 不一致导致的垂直错位。
#[component] #[component]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub(super) fn PostsTabs( pub(super) fn PostsTabs(
active: Signal<PostsTab>, active: Signal<PostsTab>,
trash_count: Signal<Option<i64>>, trash_count: Signal<Option<i64>>,
on_change: EventHandler<PostsTab>, on_change: EventHandler<PostsTab>,
) -> Element { ) -> Element {
let is_trash = active() == PostsTab::Trash; let is_trash = active() == PostsTab::Trash;
// 滑块样式left/width/opacityWASM 端测量目标 button 定位后写入。
let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
let id_prefix =
use_hook(|| POSTS_TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
// 测量目标 tab 的 offsetLeft/offsetWidth更新滑块定位。WASM 端异步等待 DOM
// 更新后读取server 端空操作SSR 不渲染动画)。
let update_indicator = move |active_key: &str| {
let active_key = active_key.to_string();
spawn(async move {
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::JsCast;
crate::utils::time::sleep_ms(50).await;
if let Some(el) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}")))
{
if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
indicator_style.set(format!(
"left: {}px; width: {}px; opacity: 1;",
html_el.offset_left(),
html_el.offset_width()
));
}
}
}
});
};
// active 变化时(含首次挂载)触发滑块定位。
use_effect(move || {
update_indicator(active().as_str());
});
rsx! { rsx! {
// items-center 让两个 tab 垂直居中对齐;两个 button 同为 inline-flex items-center // relative 容器承载绝对定位滑块items-center 让两个 tab 垂直居中对齐。
// 盒模型一致,根除基线/盒高错位。 div { class: "relative flex items-center gap-4 border-b border-paper-border",
div { class: "flex items-center gap-4 border-b border-paper-border",
button { button {
id: "posts-tab-{id_prefix}-all",
class: if !is_trash { class: if !is_trash {
"inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer border-b-2 border-paper-primary -mb-px" "inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer"
} else { } else {
"inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer border-b-2 border-transparent -mb-px" "inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer"
}, },
onclick: move |_| on_change.call(PostsTab::All), onclick: move |_| on_change.call(PostsTab::All),
"全部文章" "全部文章"
} }
button { button {
id: "posts-tab-{id_prefix}-trash",
class: if is_trash { class: if is_trash {
"inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer border-b-2 border-paper-primary -mb-px" "inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer"
} else { } else {
"inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer border-b-2 border-transparent -mb-px" "inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer"
}, },
onclick: move |_| on_change.call(PostsTab::Trash), onclick: move |_| on_change.call(PostsTab::Trash),
"回收站" "回收站"
@ -484,6 +528,11 @@ pub(super) fn PostsTabs(
} }
} }
} }
// 绝对定位的滑动指示器:贴底边(-1px 盖住外层 border-btransition 驱动滑动动画。
div {
class: "absolute bottom-[-1px] h-[2px] bg-paper-primary transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
style: "{indicator_style}",
}
} }
} }
} }