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,避免改变视觉
- 回收站分页计数单位从"条"修正为"篇"(回收站里是文章)
This commit is contained in:
xfy 2026-06-16 14:20:05 +08:00
parent 188e607ec2
commit 603a7f68cb
8 changed files with 301 additions and 285 deletions

View File

@ -640,28 +640,6 @@
color: var(--color-paper-primary);
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: white;
background: var(--color-paper-accent);
border-radius: 9999px;
border: none;
cursor: pointer;
transition: all 0.2s ease-out;
}
.btn-primary:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0) scale(0.98);
}
.post-card-accent {
position: relative;
}

View File

@ -29,5 +29,7 @@ pub mod post;
pub mod post_card;
/// 骨架屏组件集合。
pub mod skeletons;
/// 通用 UI 原子组件与类名常量(卡片、按钮、分页、徽章、空状态)。
pub mod ui;
/// 编辑器页面骨架屏组件。
pub mod write_skeleton;

185
src/components/ui.rs Normal file
View File

@ -0,0 +1,185 @@
//! 通用 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}" }
}
}

View File

@ -15,6 +15,10 @@ use crate::api::comments::{approve_comment, batch_update_comment_status, spam_co
#[cfg(target_arch = "wasm32")]
use crate::api::comments::{get_all_comments, AllCommentsResponse};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::{
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_AMBER,
BTN_TEXT_GREEN, BTN_TEXT_RED, BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, CHECKBOX_CLASS,
};
use crate::models::comment::{AdminComment, CommentStatus};
use crate::router::Route;
@ -143,7 +147,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"已选择 {selected_ids().len()} 条"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors",
class: "{BTN_SOLID_GREEN}",
onclick: move |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect();
let ids_for_api = ids.clone();
@ -156,7 +160,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"批量通过"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-amber-600 text-white rounded hover:bg-amber-700 transition-colors",
class: "{BTN_SOLID_AMBER}",
onclick: move |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect();
let ids_for_api = ids.clone();
@ -169,7 +173,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"批量垃圾"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors",
class: "{BTN_SOLID_RED}",
onclick: move |_| {
#[cfg(target_arch = "wasm32")]
{
@ -195,11 +199,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
{
if error().is_some() {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
}
rsx! { EmptyState { message: "加载失败", variant: "error" } }
} else if loading() && comments().is_empty() {
rsx! {
DelayedSkeleton {
@ -216,17 +216,13 @@ pub fn AdminCommentsPage(page: i32) -> Element {
}
}
} else if comments().is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无评论"
}
}
rsx! { EmptyState { message: "暂无评论", variant: "default" } }
} else {
let list = comments();
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
@ -234,7 +230,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
th { class: "px-4 py-3 font-medium w-10",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
class: "{CHECKBOX_CLASS}",
checked: all_selected,
onchange: {
move |_| {
@ -312,7 +308,19 @@ pub fn AdminCommentsPage(page: i32) -> Element {
}
}
}
CommentsPagination { current_page, total: total() }
Pagination {
variant: "admin",
current_page,
total: total(),
per_page: COMMENTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::AdminComments {}
} else {
Route::AdminCommentsPage { page: current_page - 1 }
},
next_route: Route::AdminCommentsPage { page: current_page + 1 },
unit: "",
}
}
}
}
@ -330,23 +338,11 @@ fn CommentRow(
on_spam: EventHandler,
on_trash: EventHandler,
) -> Element {
let (badge_class, status_label) = match &comment.status {
CommentStatus::Pending => (
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
"待审核",
),
CommentStatus::Approved => (
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
"已通过",
),
CommentStatus::Spam => (
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
"垃圾",
),
CommentStatus::Trash => (
"bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
"已删除",
),
let status_label = match &comment.status {
CommentStatus::Pending => "待审核".to_string(),
CommentStatus::Approved => "已通过".to_string(),
CommentStatus::Spam => "垃圾".to_string(),
CommentStatus::Trash => "已删除".to_string(),
};
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
let preview = if comment.content_md.len() > 100 {
@ -359,11 +355,11 @@ fn CommentRow(
};
rsx! {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
tr { class: "{ADMIN_ROW_HOVER}",
td { class: "px-4 py-3",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
class: "{CHECKBOX_CLASS}",
checked: selected,
onchange: move |e| on_select.call(e.checked()),
}
@ -398,8 +394,15 @@ fn CommentRow(
}
}
td { class: "px-4 py-3 text-center",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {badge_class}",
"{status_label}"
StatusBadge {
// badge_class 是 &'static str 字面量匹配,转为静态生命周期。
color_class: match &comment.status {
CommentStatus::Pending => "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
CommentStatus::Approved => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
CommentStatus::Spam => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
CommentStatus::Trash => "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
},
label: status_label,
}
}
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
@ -409,21 +412,21 @@ fn CommentRow(
div { class: "flex justify-end gap-2",
if !matches!(comment.status, CommentStatus::Approved) {
button {
class: "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer",
class: "{BTN_TEXT_GREEN}",
onclick: move |_| on_approve.call(()),
"通过"
}
}
if !matches!(comment.status, CommentStatus::Spam) {
button {
class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer",
class: "{BTN_TEXT_AMBER}",
onclick: move |_| on_spam.call(()),
"垃圾"
}
}
if !matches!(comment.status, CommentStatus::Trash) {
button {
class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer",
class: "{BTN_TEXT_RED}",
onclick: move |_| on_trash.call(()),
"删除"
}
@ -434,56 +437,3 @@ fn CommentRow(
}
}
/// 评论分页导航组件。
#[component]
fn CommentsPagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1;
let total_pages =
((total + COMMENTS_PER_PAGE as i64 - 1) / COMMENTS_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages;
let prev_route = if current_page - 1 <= 1 {
Route::AdminComments {}
} else {
Route::AdminCommentsPage {
page: current_page - 1,
}
};
let next_route = Route::AdminCommentsPage {
page: current_page + 1,
};
rsx! {
nav { class: "flex mt-6 justify-between",
if has_prev {
Link {
class: "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",
to: prev_route,
span { class: "mr-1", "«" }
"上一页"
}
} else {
span { 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",
span { class: "mr-1", "«" }
"上一页"
}
}
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
"{current_page} / {total_pages} 页 (共 {total} 条)"
}
if has_next {
Link {
class: "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",
to: next_route,
"下一页"
span { class: "ml-1", "»" }
}
} else {
span { 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",
"下一页"
span { class: "ml-1", "»" }
}
}
}
}
}

View File

@ -12,6 +12,7 @@ use crate::api::comments::get_pending_count;
use crate::api::posts::{get_post_stats, list_posts};
#[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::components::ui::ADMIN_CARD_CLASS;
use crate::models::post::{Post, PostStats};
use crate::router::Route;
@ -151,7 +152,7 @@ pub fn Admin() -> Element {
#[component]
fn StatCard(value: String, label: String) -> Element {
rsx! {
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center",
div { class: "{ADMIN_CARD_CLASS} p-6 text-center",
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
"{value}"
}

View File

@ -14,6 +14,7 @@ use crate::api::posts::PostListResponse;
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::components::ui::{EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED};
use crate::models::post::Post;
use crate::router::Route;
@ -135,11 +136,9 @@ pub fn PostsPage(page: i32) -> Element {
if loading() && posts().is_empty() {
DelayedSkeleton { PostsSkeleton {} }
} else if posts().is_empty() {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无文章"
}
EmptyState { message: "暂无文章", variant: "default" }
} else {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
div { class: "{ADMIN_TABLE_CLASS}",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
@ -180,52 +179,18 @@ pub fn PostsPage(page: i32) -> Element {
}
}
}
Pagination { current_page, total: total() }
}
}
}
}
/// 分页导航组件,根据当前页与总数量生成上一页 / 下一页链接。
#[component]
fn Pagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1;
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages;
rsx! {
nav { class: "flex mt-6 justify-between",
if has_prev {
Link {
class: "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",
to: if current_page - 1 <= 1 {
Pagination {
variant: "admin",
current_page,
total: total(),
per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::Posts {}
} else {
Route::PostsPage { page: current_page - 1 }
},
span { class: "mr-1", "«" }
"上一页"
}
} else {
span { 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",
span { class: "mr-1", "«" }
"上一页"
}
}
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
"{current_page} / {total_pages} 页 (共 {total} 篇)"
}
if has_next {
Link {
class: "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",
to: Route::PostsPage { page: current_page + 1 },
"下一页"
span { class: "ml-1", "»" }
}
} else {
span { 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",
"下一页"
span { class: "ml-1", "»" }
next_route: Route::PostsPage { page: current_page + 1 },
unit: "",
}
}
}
@ -236,11 +201,9 @@ fn Pagination(current_page: i32, total: i64) -> Element {
#[component]
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
let date_str = post.formatted_date();
let status_label = post.status_label();
let status_badge_class = post.status_badge_class();
rsx! {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
tr { class: "{ADMIN_ROW_HOVER}",
td { class: "px-4 py-3",
Link {
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
@ -249,8 +212,9 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
}
}
td { class: "px-4 py-3 text-center",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {status_badge_class}",
"{status_label}"
StatusBadge {
color_class: post.status_badge_class(),
label: post.status_label().to_string(),
}
}
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
@ -267,7 +231,7 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
class: if deleting {
"text-xs text-gray-400 cursor-not-allowed"
} else {
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
BTN_TEXT_RED
},
disabled: deleting,
onclick: move |_| on_delete.call(post.id),

View File

@ -7,7 +7,6 @@
use std::collections::HashSet;
use dioxus::prelude::*;
use dioxus::router::components::Link;
// 操作类 server function 在 SSR 与 WASM 均需可见spawn 闭包需类型检查),
// 但部分仅用于 WASM 代码路径SSR 下触发 unused imports按项目惯例放行。
@ -20,6 +19,10 @@ use crate::api::posts::{list_deleted_posts, PostListResponse};
#[allow(unused_imports)]
use crate::api::settings::{get_trash_settings, update_trash_settings};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::{
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
};
use crate::models::post::Post;
use crate::models::settings::TrashSettings;
use crate::router::Route;
@ -319,7 +322,7 @@ pub fn TrashPage(page: i32) -> Element {
"已选择 {selected_ids().len()} 条"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors",
class: "{BTN_SOLID_GREEN}",
onclick: move |_| {
let ids: Vec<i32> = selected_ids().iter().copied().collect();
spawn(async move {
@ -331,7 +334,7 @@ pub fn TrashPage(page: i32) -> Element {
"批量恢复"
}
button {
class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors",
class: "{BTN_SOLID_RED}",
onclick: move |_| {
#[cfg(target_arch = "wasm32")]
{
@ -356,9 +359,7 @@ pub fn TrashPage(page: i32) -> Element {
// 主内容:错误 / 加载骨架 / 空态 / 列表
{
if error().is_some() {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" }
}
rsx! { EmptyState { message: "加载失败", variant: "error" } }
} else if loading() && posts().is_empty() {
rsx! {
DelayedSkeleton {
@ -370,17 +371,13 @@ pub fn TrashPage(page: i32) -> Element {
}
}
} else if posts().is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"回收站为空"
}
}
rsx! { EmptyState { message: "回收站为空", variant: "default" } }
} else {
let list = posts();
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
@ -388,7 +385,7 @@ pub fn TrashPage(page: i32) -> Element {
th { class: "px-4 py-3 font-medium w-10",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
class: "{CHECKBOX_CLASS}",
checked: all_selected,
onchange: {
move |_| {
@ -480,7 +477,19 @@ pub fn TrashPage(page: i32) -> Element {
"清空回收站"
}
}
TrashPagination { current_page, total: total() }
Pagination {
variant: "admin",
current_page,
total: total(),
per_page: TRASH_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::Trash {}
} else {
Route::TrashPage { page: current_page - 1 }
},
next_route: Route::TrashPage { page: current_page + 1 },
unit: "",
}
}
}
}
@ -537,11 +546,11 @@ fn TrashRow(
.unwrap_or_else(|| "".to_string());
rsx! {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
tr { class: "{ADMIN_ROW_HOVER}",
td { class: "px-4 py-3",
input {
r#type: "checkbox",
class: "rounded border-gray-300 dark:border-[#555]",
class: "{CHECKBOX_CLASS}",
checked: selected,
onchange: move |e| on_select.call(e.checked()),
}
@ -552,27 +561,29 @@ fn TrashRow(
}
}
td { class: "px-4 py-3",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {post.status_badge_class()}",
"{post.status_label()}"
StatusBadge {
color_class: post.status_badge_class(),
label: post.status_label().to_string(),
}
}
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
"{deleted_str}"
}
td { class: "px-4 py-3 text-center",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {badge_class}",
"{badge_text}"
StatusBadge {
color_class: badge_class,
label: badge_text,
}
}
td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-2",
button {
class: "text-xs text-[#5c7a5e] hover:text-[#3d5a3f] dark:text-[#7da97f] dark:hover:text-[#9dc79f] transition-colors cursor-pointer",
class: "{BTN_TEXT_ACCENT}",
onclick: move |_| on_restore.call(()),
"恢复"
}
button {
class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer",
class: "{BTN_TEXT_RED}",
onclick: move |_| on_purge.call(()),
"彻底删除"
}
@ -582,52 +593,3 @@ fn TrashRow(
}
}
/// 回收站分页导航组件。
#[component]
fn TrashPagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1;
let total_pages =
((total + TRASH_PER_PAGE as i64 - 1) / TRASH_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages;
let prev_route = if current_page - 1 <= 1 {
Route::Trash {}
} else {
Route::TrashPage { page: current_page - 1 }
};
let next_route = Route::TrashPage { page: current_page + 1 };
rsx! {
nav { class: "flex mt-6 justify-between",
if has_prev {
Link {
class: "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",
to: prev_route,
span { class: "mr-1", "«" }
"上一页"
}
} else {
span { 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",
span { class: "mr-1", "«" }
"上一页"
}
}
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
"{current_page} / {total_pages} 页 (共 {total} 条)"
}
if has_next {
Link {
class: "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",
to: next_route,
"下一页"
span { class: "ml-1", "»" }
}
} else {
span { 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",
"下一页"
span { class: "ml-1", "»" }
}
}
}
}
}

View File

@ -10,12 +10,12 @@
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::post_card::PostCard;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::home_skeleton::HomeSkeleton;
use crate::components::ui::Pagination;
use crate::router::Route;
// 每页展示的已发布文章数量,用于分页计算。
@ -70,7 +70,20 @@ fn HomePosts(current_page: i32) -> Element {
}
}
// 在列表底部渲染分页导航。
Pagination { current_page, total }
// frontend variant 不渲染页码计数unit 不显示(仅满足必填 prop
Pagination {
variant: "frontend",
current_page,
total,
per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 {
Route::Home {}
} else {
Route::HomePage { page: current_page - 1 }
},
next_route: Route::HomePage { page: current_page + 1 },
unit: "",
}
}
}
Some(Err(e)) => {
@ -103,42 +116,3 @@ fn HomeInfo() -> Element {
}
}
/// 分页导航组件。
///
/// 根据当前页码与文章总数计算总页数,并渲染上一页/下一页链接。
/// 第一页的上一页链接固定指向 `Route::Home`,避免生成 `/page/1`。
#[component]
fn Pagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1;
// 向上取整计算总页数,至少为 1 页。
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages;
let prev = current_page - 1;
// 当上一页为第 1 页时,使用 `/` 路由而非 `/page/1`。
let prev_route = if prev <= 1 {
Route::Home {}
} else {
Route::HomePage { page: prev }
};
rsx! {
nav { class: "flex mt-10 mb-6 justify-between",
if has_prev {
Link {
class: "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",
to: prev_route,
span { class: "mr-1", "«" }
"上一页"
}
}
if has_next {
Link {
class: "ml-auto 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",
to: Route::HomePage { page: current_page + 1 },
"下一页"
span { class: "ml-1", "»" }
}
}
}
}
}