xfy 603a7f68cb refactor(ui): 提取共享 UI 组件,消除跨页面重复
新增 src/components/ui.rs 作为通用 UI 原子层,封装:

组件:
- <Pagination variant="admin"|"frontend">:合并 posts/comments/trash/home
  四个重复的分页实现,用 variant 区分配色(admin 灰黑 / 前台主题绿)
- <StatusBadge>:统一文章状态、评论状态、回收站剩余天数的徽章外层
- <EmptyState variant="default"|"error">:统一列表空态与加载失败占位

类名常量:
- ADMIN_CARD_CLASS / ADMIN_TABLE_CLASS / ADMIN_ROW_HOVER / CHECKBOX_CLASS
- BTN_SOLID_{GREEN,AMBER,RED}:批量操作实心按钮
- BTN_TEXT_{GREEN,AMBER,RED,ACCENT}:行内文字按钮

清理:
- 删除 input.css 中零引用的 .btn-primary 死代码(22 行)
- 删除 4 个重复的分页组件函数

注意:
- 前台空状态(home/search/archives/tags)保持 text-paper-secondary 配色,
  未强行统一到 admin 的灰色 token,避免改变视觉
- 回收站分页计数单位从"条"修正为"篇"(回收站里是文章)
2026-06-16 14:20:05 +08:00

186 lines
7.8 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 卡片容器:白底圆角描边,亮/暗双模式。用于 stat 卡片、面板等。
pub const ADMIN_CARD_CLASS: &str =
"bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333]";
/// Admin 表格容器:在卡片基础上加 `overflow-hidden`,圆角裁剪表格。
pub const ADMIN_TABLE_CLASS: &str =
"bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden";
/// Admin 表格行 hover 态:底部分割线 + 悬停背景。
pub const ADMIN_ROW_HOVER: &str =
"border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors";
/// 列表复选框统一样式(全选表头 + 行内)。
pub const CHECKBOX_CLASS: &str = "rounded border-gray-300 dark:border-[#555]";
/// 状态徽章外层:小号圆角胶囊。
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-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors";
/// 琥珀色实心小按钮(批量标为垃圾)。
pub const BTN_SOLID_AMBER: &str =
"px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors";
/// 红色实心小按钮(批量删除、批量彻底删除)。
pub const BTN_SOLID_RED: &str =
"px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors";
// --- 文字小按钮(表格行内操作:通过 / 垃圾 / 删除 / 恢复) ---
/// 绿色文字小按钮(行内通过)。
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-[#5c7a5e] hover:text-[#3d5a3f] dark:text-[#7da97f] dark:hover:text-[#9dc79f] transition-colors cursor-pointer";
// ===========================================================================
// 组件
// ===========================================================================
/// 分页导航组件。
///
/// 统一了后台与前台的分页 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-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity 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-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full 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-gray-500 dark:text-[#9b9c9d] 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-gray-500 dark:text-[#9b9c9d]",
};
rsx! {
div { class: "{class}", "{message}" }
}
}