新增 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,避免改变视觉
- 回收站分页计数单位从"条"修正为"篇"(回收站里是文章)
186 lines
7.8 KiB
Rust
186 lines
7.8 KiB
Rust
//! 通用 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}" }
|
||
}
|
||
}
|