From ffa0a72e51992effdaf7b62476cc937a496e03a3 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 10:47:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin/posts):=20tab=20=E6=A0=8F=E5=8A=A0?= =?UTF-8?q?=E5=B9=B3=E6=BB=91=E6=BB=91=E5=8A=A8=E6=8C=87=E7=A4=BA=E5=99=A8?= =?UTF-8?q?=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 前缀。 --- src/pages/admin/posts.rs | 69 ++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index 229f643..879386b 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -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 状态由父组件传入的 `active` signal 驱动(非路由),点击即调用 `on_change` /// 切换。回收站 tab 带 `trash_count` 数量角标,便于发现待清理文章。 /// -/// 对齐修复:两个 tab 均用 `inline-flex items-center` 同盒模型,外层容器加 -/// `items-center`,根除原先「全部文章」(inline 文本) 与「回收站」(inline-flex -/// 带角标) 盒模型不一致导致的垂直错位。 +/// 底部带**平滑滑动指示器**(绝对定位的滑块 + transition),切换 tab 时滑块从 +/// 一个 tab 平滑滑到另一个,与 FilterTabs(system/comments 页)视觉一致。滑块 +/// 位置通过 WASM 端测量目标 button 的 offsetLeft/offsetWidth 动态计算。 +/// +/// 两个 tab 均用 `inline-flex items-center` 同盒模型,外层容器加 `items-center`, +/// 根除原先「全部文章」(inline 文本) 与「回收站」(inline-flex 带角标) 盒模型 +/// 不一致导致的垂直错位。 #[component] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] pub(super) fn PostsTabs( active: Signal, trash_count: Signal>, on_change: EventHandler, ) -> Element { let is_trash = active() == PostsTab::Trash; + // 滑块样式(left/width/opacity):WASM 端测量目标 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::() { + 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! { - // items-center 让两个 tab 垂直居中对齐;两个 button 同为 inline-flex items-center, - // 盒模型一致,根除基线/盒高错位。 - div { class: "flex items-center gap-4 border-b border-paper-border", + // relative 容器:承载绝对定位滑块;items-center 让两个 tab 垂直居中对齐。 + div { class: "relative flex items-center gap-4 border-b border-paper-border", button { + id: "posts-tab-{id_prefix}-all", 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 { - "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), "全部文章" } button { + id: "posts-tab-{id_prefix}-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 { - "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), "回收站" @@ -484,6 +528,11 @@ pub(super) fn PostsTabs( } } } + // 绝对定位的滑动指示器:贴底边(-1px 盖住外层 border-b),transition 驱动滑动动画。 + 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}", + } } } }