diff --git a/input.css b/input.css index 5a7891f..ca8ff90 100644 --- a/input.css +++ b/input.css @@ -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; } diff --git a/src/components/mod.rs b/src/components/mod.rs index 7a8704a..aaca272 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -29,5 +29,7 @@ pub mod post; pub mod post_card; /// 骨架屏组件集合。 pub mod skeletons; +/// 通用 UI 原子组件与类名常量(卡片、按钮、分页、徽章、空状态)。 +pub mod ui; /// 编辑器页面骨架屏组件。 pub mod write_skeleton; diff --git a/src/components/ui.rs b/src/components/ui.rs new file mode 100644 index 0000000..3964c58 --- /dev/null +++ b/src/components/ui.rs @@ -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}" } + } +} diff --git a/src/pages/admin/comments.rs b/src/pages/admin/comments.rs index 78ab628..52eac14 100644 --- a/src/pages/admin/comments.rs +++ b/src/pages/admin/comments.rs @@ -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 = 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 = 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 = 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", "»" } - } - } - } - } -} diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index cf724c2..2fe339a 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -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}" } diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index f9d9655..c34d938 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -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,53 +179,19 @@ 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 { - 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", "»" } - } + 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 } + }, + 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) -> 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) -> 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) -> 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), diff --git a/src/pages/admin/trash.rs b/src/pages/admin/trash.rs index 17763ba..f922d39 100644 --- a/src/pages/admin/trash.rs +++ b/src/pages/admin/trash.rs @@ -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 = 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 = 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", "»" } - } - } - } - } -} diff --git a/src/pages/home.rs b/src/pages/home.rs index 0a30b1f..2eccb2d 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -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", "»" } - } - } - } - } -}