xfy 545651d304 refactor(ui): 新增 BTN_PRIMARY/BTN_OUTLINE 等按钮令牌与 LoadingButton 组件
消除 admin 页面按钮样式散落问题:
- BTN_PRIMARY / BTN_PRIMARY_SM:主题绿实心胶囊主操作按钮
- BTN_OUTLINE:描边次要按钮(relative 以承载 spinner 叠加)
- BTN_DANGER_OUTLINE:红色描边危险按钮(清空回收站)
- BTN_CLOSE_ICON / BTN_ICON:× 关闭与方形步进图标按钮
- LoadingButton 组件:封装主题绿三态(loading spinner 叠加 / disabled 灰化 / 正常),
  统一全站 loading 态为 posts.rs 的 spinner 叠加模式(按钮宽度不变,避免布局抖动)
2026-07-06 00:17:09 +08:00

407 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 通用 UI 原子组件与类名常量。
//!
//! 提供跨页面共享的样式常量(卡片、按钮、徽章外层等)与可复用组件
//! (分页导航、状态徽章、空状态)。样式常量用于消除散落在各页面的重复
//! Tailwind 类字符串;组件用于封装结构固定的 UI 单元。
//!
//! 与 `forms.rs`(表单控件)并列,本模块聚焦通用展示类原子。
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::router::Route;
// ===========================================================================
// 样式常量
// ===========================================================================
/// Admin 卡片容器:大圆角,极简风,现代简约。
pub const ADMIN_CARD_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-3xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition-colors";
/// Admin 表格容器:大圆角,现代简约,与卡片一致。
pub const ADMIN_TABLE_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-3xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition-colors overflow-hidden";
/// Admin 表格行 hover 态:底部分割线 + 悬停背景。
pub const ADMIN_ROW_HOVER: &str =
"border-b border-paper-border last:border-b-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors";
/// 列表复选框统一样式(全选表头 + 行内)。
pub const CHECKBOX_CLASS: &str = "rounded border-paper-border";
/// 行内加载 spinner环形渐变 + 自旋动画,用 currentColor 继承文字色。
///
/// 内联 SVG含 `@keyframes`),通过 `dangerous_inner_html` 注入;尺寸由外层
/// Tailwind 类(如 `w-3.5 h-3.5`)控制。源文件 `public/icons/90-ring-with-gradient.svg`。
pub const SPINNER_SVG: &str = r#"<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="yggSpinnerGrad"><stop offset="0%" stop-color="currentColor" stop-opacity="1"/><stop offset="100%" stop-color="currentColor" stop-opacity="0.25"/></linearGradient></defs><style>@keyframes yggSpin { to { transform: rotate(360deg); } } .ygg-spinner-circle { transform-origin: 50% 50%; stroke: url(#yggSpinnerGrad); fill: none; animation: yggSpin .5s infinite linear; }</style><circle cx="10" cy="10" r="8" class="ygg-spinner-circle" stroke-width="2"/></svg>"#;
/// 状态徽章外层:小号圆角胶囊。
pub const BADGE_BASE: &str =
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
// --- 实心小按钮(批量操作栏:通过 / 垃圾 / 删除) ---
/// 绿色实心小按钮(批量通过、批量恢复)。
pub const BTN_SOLID_GREEN: &str =
"px-4 py-1.5 text-sm font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors cursor-pointer";
/// 琥珀色实心小按钮(批量标为垃圾)。
pub const BTN_SOLID_AMBER: &str =
"px-4 py-1.5 text-sm font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-full hover:bg-amber-500/20 transition-colors cursor-pointer";
/// 红色实心小按钮(批量删除、批量彻底删除)。
pub const BTN_SOLID_RED: &str =
"px-4 py-1.5 text-sm font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full hover:bg-red-500/20 transition-colors cursor-pointer";
// --- 文字小按钮(表格行内操作:通过 / 垃圾 / 删除 / 恢复) ---
/// 绿色文字小按钮(行内通过)。
pub const BTN_TEXT_GREEN: &str = "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer";
/// 琥珀色文字小按钮(行内标为垃圾)。
pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer";
/// 红色文字小按钮(行内删除 / 彻底删除)。
pub const BTN_TEXT_RED: &str =
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
/// 主题绿(鼠尾草)文字小按钮(行内恢复)。
pub const BTN_TEXT_ACCENT: &str =
"text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
// --- 次要按钮Teal 第二色ghost 描边风格,从属于主色 Green ---
/// 次要按钮:极简风次要操作。
pub const BTN_SECONDARY: &str =
"px-6 py-2.5 rounded-full text-sm font-medium text-center text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)] transition-all cursor-pointer";
// --- 主操作按钮(主题绿实心胶囊,全站统一 CTA ---
/// 主操作按钮:主题绿实心胶囊(用于 `<Link>`、无 loading 态的静态按钮)。
pub const BTN_PRIMARY: &str =
"inline-flex items-center justify-center px-5 py-2 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full shadow-sm hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
/// 小号主操作按钮:工具栏场景(刷新 / 导出 / 创建备份)。
pub const BTN_PRIMARY_SM: &str =
"inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
// --- 描边按钮 ---
/// 描边次要按钮posts 重建、system 刷新列表):`relative` 以承载 spinner 叠加层。
pub const BTN_OUTLINE: &str =
"relative px-4 py-2 rounded-full text-sm font-medium text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all cursor-pointer";
/// 红色描边危险按钮trash 清空回收站)。
pub const BTN_DANGER_OUTLINE: &str =
"px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer";
// --- 图标按钮 ---
/// 关闭图标按钮(× 关闭提示条)。
pub const BTN_CLOSE_ICON: &str =
"shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
/// 方形图标按钮trash 步进 /+)。
pub const BTN_ICON: &str =
"w-9 h-9 flex items-center justify-center text-sm text-paper-secondary hover:text-paper-primary hover:bg-paper-theme cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40";
// ===========================================================================
// 组件
// ===========================================================================
/// 分页导航组件。
///
/// 统一了后台与前台的分页 UI通过 `variant` 切换配色与展示细节:
/// - `"admin"`:灰黑胶囊按钮,显示页码计数(`{当前} / {总} 页 (共 {total} {unit})`
/// 首尾页渲染禁用态。
/// - `"frontend"`:主题绿胶囊按钮,不显示计数,首尾页直接不渲染按钮。
///
/// Props
/// - `variant``"admin"` 或 `"frontend"`
/// - `current_page`:当前页码(从 1 开始)
/// - `total`:数据总条数
/// - `per_page`:每页条数,用于计算总页数
/// - `prev_route`:点击上一页跳转的目标路由
/// - `next_route`:点击下一页跳转的目标路由
/// - `unit`:计数单位("篇" / "条"),仅 admin 显示计数时使用
#[component]
pub fn Pagination(
variant: &'static str,
current_page: i32,
total: i64,
per_page: i32,
prev_route: Route,
next_route: Route,
unit: &'static str,
) -> Element {
let has_prev = current_page > 1;
let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
let has_next = current_page < total_pages;
// admin 与 frontend 的配色差异。
let is_admin = variant == "admin";
let (link_class, link_extra_next) = if is_admin {
(
"inline-flex items-center px-4 py-2 text-xs font-mono tracking-wider text-paper-theme bg-paper-primary rounded-sm hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
"",
)
} else {
(
"inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
"ml-auto",
)
};
let disabled_class =
"inline-flex items-center px-4 py-2 text-xs font-mono tracking-wider text-paper-secondary bg-paper-entry border border-paper-border rounded-sm cursor-not-allowed";
// admin 首尾页渲染禁用态frontend 首尾页直接不渲染。
rsx! {
nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" },
if has_prev {
Link { class: "{link_class}", to: prev_route,
span { class: "mr-1", "«" }
"上一页"
}
} else if is_admin {
span { class: "{disabled_class}",
span { class: "mr-1", "«" }
"上一页"
}
}
// admin 显示页码计数。
if is_admin {
span { class: "text-sm text-paper-secondary self-center",
"{current_page} / {total_pages} 页 (共 {total} {unit})"
}
}
if has_next {
Link { class: "{link_class} {link_extra_next}", to: next_route,
"下一页"
span { class: "ml-1", "»" }
}
} else if is_admin {
span { class: "{disabled_class}",
"下一页"
span { class: "ml-1", "»" }
}
}
}
}
}
/// 状态徽章组件。
///
/// 外层固定 `BADGE_BASE`,颜色类由调用方传入。之所以用 `color_class` prop
/// 而非枚举变体,是因为部分场景(如回收站剩余天数)的颜色由动态逻辑决定
/// >7 天中性 / ≤7 天主题绿 / ≤0 琥珀),硬编码 variant 反而不够灵活。
///
/// Props
/// - `color_class`:背景与文字颜色类(如 `post.status_badge_class()` 的返回值)
/// - `label`:徽章文本
#[component]
pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
rsx! {
span { class: "{BADGE_BASE} {color_class}", "{label}" }
}
}
/// 空状态 / 错误状态组件。
///
/// 用于列表页无数据或加载失败时的居中占位提示。
///
/// Props
/// - `message`:提示文本
/// - `variant``"default"`(灰色,空状态)或 `"error"`(红色,加载失败)
#[component]
pub fn EmptyState(message: &'static str, variant: &'static str) -> Element {
let class = match variant {
"error" => "text-center text-red-500 dark:text-red-400 py-20",
_ => "text-center py-20 text-paper-secondary",
};
rsx! {
div { class: "{class}", "{message}" }
}
}
/// 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 inline-flex",
{children}
div { class: "{TOOLTIP_STYLE} {position_class}", "{tip}" }
}
}
}
static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// 筛选选项卡组件。
///
/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
/// 具备高级的平滑滑动底部指示器动画。
///
/// Props
/// - `items`:选项卡列表,每一项为 `(value, label)`
/// - `active_value`:当前选中的值
/// - `on_change`:选项卡切换时的回调
#[component]
pub fn FilterTabs(
items: Vec<(&'static str, &'static str)>,
active_value: String,
on_change: EventHandler<String>,
) -> Element {
#[allow(unused_mut)]
let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
let update_indicator = move |active: String| {
spawn(async move {
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::JsCast;
// 等待 DOM 节点更新
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window
.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 50);
}
});
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
let element_id = format!("tab-{}-{}", id_prefix, active);
if let Some(el) = doc.get_element_by_id(&element_id) {
if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
let left = html_el.offset_left();
let width = html_el.offset_width();
indicator_style.set(format!(
"left: {}px; width: {}px; opacity: 1;",
left, width
));
}
}
}
}
}
});
};
use_effect({
let active_value = active_value.clone();
move || {
update_indicator(active_value.clone());
}
});
rsx! {
div { class: "relative flex gap-4 border-b border-paper-border mb-6",
for (value, label) in items {
button {
id: "tab-{id_prefix}-{value}",
key: "{value}",
class: if active_value == *value { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors" } else { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors" },
onclick: {
let v = value.to_string();
move |_| {
on_change.call(v.clone());
update_indicator(v.clone());
}
},
"{label}"
}
}
// 绝对定位的滑动颜色条
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}",
}
}
}
}
/// 主操作按钮,内置 loading spinner 叠加(统一三态)。
///
/// 用于全站所有主题绿 CTA执行 / 刷新 / 创建备份 / 发布 / 保存设置)。
/// 三态:
/// - `loading=true`:主题绿底 + 文字隐藏(`opacity-0`+ spinner 绝对居中
/// (按钮宽度不变,避免加载时布局抖动)
/// - `disabled=true`(且未 loading灰色底`bg-paper-tertiary`+
/// `cursor-not-allowed`
/// - 正常:主题绿底 + `hover:brightness-110` + `active:scale-[0.98]`
///
/// Props
/// - `label`正常态显示的文案loading 时隐藏,由 spinner 占位)
/// - `loading`:是否处于加载态
/// - `disabled`是否禁用loading 优先级更高)
/// - `variant``"primary"`(默认,`px-5 py-2`)或 `"sm"``px-4 py-1.5`
/// - `onclick`:点击回调
#[component]
pub fn LoadingButton(
label: String,
loading: bool,
#[props(default = false)] disabled: bool,
#[props(default = "primary")] variant: &'static str,
onclick: EventHandler<()>,
) -> Element {
// 尺寸变体sm 用于工具栏(刷新/导出primary 用于主 CTA执行/发布/保存)。
let size = if variant == "sm" {
"px-4 py-1.5"
} else {
"px-5 py-2 shadow-sm"
};
// 三态背景loading 与正常都是主题绿保持视觉连续disabled 灰化。
let (bg, cursor) = if disabled && !loading {
(
"bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)]",
"cursor-not-allowed",
)
} else {
(
"text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98]",
"cursor-pointer",
)
};
rsx! {
button {
class: "relative inline-flex items-center justify-center {size} {bg} {cursor} rounded-full text-sm font-medium transition-all",
disabled: loading || disabled,
onclick: move |_| onclick.call(()),
span { class: if loading { "opacity-0" } else { "" }, "{label}" }
if loading {
span {
class: "absolute inset-0 flex items-center justify-center",
dangerous_inner_html: SPINNER_SVG,
}
}
}
}
}