refactor(ui): 抽取 Tooltip 组件,行内重建按钮补 tooltip

新增可复用 Tooltip 组件(group-hover 显现,支持 top/bottom 朝向),
消除顶部重建按钮手写的内联 tooltip div 重复,并为表格行内重建
按钮补上 tooltip(朝上,避免被表格容器的 overflow-hidden 裁掉)。
This commit is contained in:
xfy 2026-07-03 11:03:56 +08:00
parent b6fe0ae082
commit 73b9cad550
2 changed files with 63 additions and 25 deletions

View File

@ -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);
/// 筛选选项卡组件。

View File

@ -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,
}
}
}
}