diff --git a/src/components/ui.rs b/src/components/ui.rs index b307f6a..225002b 100644 --- a/src/components/ui.rs +++ b/src/components/ui.rs @@ -192,6 +192,43 @@ pub fn EmptyState(message: &'static str, variant: &'static str) -> Element { } } +/// Tooltip 定位样式(胶囊:黑底白字,hover 显现)。 +const TOOLTIP_STYLE: &str = + "pointer-events-none absolute left-1/2 -translate-x-1/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"; + +/// Tooltip 包裹组件。 +/// +/// 将任意触发器(按钮等)包裹后,鼠标 hover 时在上方或下方居中弹出提示。 +/// 用 CSS `group` + `group-hover:opacity-100` 实现,无 JS 状态,`pointer-events-none` +/// 保证不拦截点击。 +/// +/// Props: +/// - `tip`:提示文案 +/// - `children`:触发器元素(按钮 / 链接等) +/// - `placement`:弹出方向,`"top"`(默认)或 `"bottom"` +/// +/// 注意:父容器若有 `overflow-hidden` 会裁掉 tooltip,此时应选朝外的方向 +/// (如表格行在 `overflow-hidden` 容器内,朝上的 tooltip 才不会被裁)。 +#[component] +pub fn Tooltip( + tip: String, + children: Element, + #[props(default = "top")] placement: &'static str, +) -> Element { + // 朝上:tooltip 在触发器上方(bottom-full + mb-2);朝下:在下方(top-full + mt-2)。 + let position_class = if placement == "bottom" { + "top-full mt-2" + } else { + "bottom-full mb-2" + }; + rsx! { + div { class: "group relative", + {children} + div { class: "{TOOLTIP_STYLE} {position_class}", "{tip}" } + } + } +} + static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); /// 筛选选项卡组件。 diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index c8c2754..ba480d8 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -16,8 +16,8 @@ use crate::components::empty_state::{EmptyState, EmptyStateAction}; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::components::ui::{ - Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_ACCENT, BTN_TEXT_RED, - SPINNER_SVG, + Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_ACCENT, + BTN_TEXT_RED, SPINNER_SVG, }; use crate::hooks::query::use_paginated; use crate::models::post::PostListItem; @@ -237,7 +237,9 @@ fn RebuildCacheBar( rsx! { // 仅渲染按钮行本身:结果消息已上提到 PostsPage,作为独立状态行进入文档流。 div { class: "flex items-center gap-3", - div { class: "group relative", + Tooltip { + tip: "重建 content_html 为空的文章渲染缓存".to_string(), + placement: "bottom", button { class: if rebuilding() { "relative 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(), @@ -253,11 +255,10 @@ fn RebuildCacheBar( } } } - 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", + Tooltip { + tip: "重建所有文章的渲染缓存(含已有内容)".to_string(), + placement: "bottom", button { class: if rebuilding() { "relative 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(), @@ -273,9 +274,6 @@ fn RebuildCacheBar( } } } - 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", - "重建所有文章的渲染缓存(含已有内容)" - } } } } @@ -317,22 +315,25 @@ fn PostRow( to: Route::WriteEdit { id: post.id }, "编辑" } - button { - class: if rebuilding { - "relative inline-flex items-center text-xs text-paper-accent cursor-not-allowed" - } else { - BTN_TEXT_ACCENT - }, - disabled: rebuilding, - onclick: move |_| on_rebuild.call(post.id), - span { - class: if rebuilding { "opacity-40" } else { "" }, - "重建" - } - if rebuilding { + Tooltip { + tip: "重新渲染这篇文章的 HTML".to_string(), + button { + class: if rebuilding { + "relative inline-flex items-center text-xs text-paper-accent cursor-not-allowed" + } else { + BTN_TEXT_ACCENT + }, + disabled: rebuilding, + onclick: move |_| on_rebuild.call(post.id), span { - class: "absolute inset-0 flex items-center justify-center", - dangerous_inner_html: SPINNER_SVG, + class: if rebuilding { "opacity-40" } else { "" }, + "重建" + } + if rebuilding { + span { + class: "absolute inset-0 flex items-center justify-center", + dangerous_inner_html: SPINNER_SVG, + } } } }