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:
parent
188e607ec2
commit
603a7f68cb
22
input.css
22
input.css
@ -640,28 +640,6 @@
|
|||||||
color: var(--color-paper-primary);
|
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 {
|
.post-card-accent {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,5 +29,7 @@ pub mod post;
|
|||||||
pub mod post_card;
|
pub mod post_card;
|
||||||
/// 骨架屏组件集合。
|
/// 骨架屏组件集合。
|
||||||
pub mod skeletons;
|
pub mod skeletons;
|
||||||
|
/// 通用 UI 原子组件与类名常量(卡片、按钮、分页、徽章、空状态)。
|
||||||
|
pub mod ui;
|
||||||
/// 编辑器页面骨架屏组件。
|
/// 编辑器页面骨架屏组件。
|
||||||
pub mod write_skeleton;
|
pub mod write_skeleton;
|
||||||
|
|||||||
185
src/components/ui.rs
Normal file
185
src/components/ui.rs
Normal 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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,10 @@ use crate::api::comments::{approve_comment, batch_update_comment_status, spam_co
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
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::models::comment::{AdminComment, CommentStatus};
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -143,7 +147,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
"已选择 {selected_ids().len()} 条"
|
"已选择 {selected_ids().len()} 条"
|
||||||
}
|
}
|
||||||
button {
|
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 |_| {
|
onclick: move |_| {
|
||||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||||
let ids_for_api = ids.clone();
|
let ids_for_api = ids.clone();
|
||||||
@ -156,7 +160,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
"批量通过"
|
"批量通过"
|
||||||
}
|
}
|
||||||
button {
|
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 |_| {
|
onclick: move |_| {
|
||||||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||||||
let ids_for_api = ids.clone();
|
let ids_for_api = ids.clone();
|
||||||
@ -169,7 +173,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
"批量垃圾"
|
"批量垃圾"
|
||||||
}
|
}
|
||||||
button {
|
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 |_| {
|
onclick: move |_| {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
@ -195,11 +199,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
|
|
||||||
{
|
{
|
||||||
if error().is_some() {
|
if error().is_some() {
|
||||||
rsx! {
|
rsx! { EmptyState { message: "加载失败", variant: "error" } }
|
||||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
|
||||||
"加载失败"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if loading() && comments().is_empty() {
|
} else if loading() && comments().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
DelayedSkeleton {
|
DelayedSkeleton {
|
||||||
@ -216,17 +216,13 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if comments().is_empty() {
|
} else if comments().is_empty() {
|
||||||
rsx! {
|
rsx! { EmptyState { message: "暂无评论", variant: "default" } }
|
||||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
|
||||||
"暂无评论"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let list = comments();
|
let list = comments();
|
||||||
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
|
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
|
||||||
let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
|
let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
|
||||||
rsx! {
|
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",
|
div { class: "overflow-x-auto",
|
||||||
table { class: "w-full text-sm",
|
table { class: "w-full text-sm",
|
||||||
thead {
|
thead {
|
||||||
@ -234,7 +230,7 @@ pub fn AdminCommentsPage(page: i32) -> Element {
|
|||||||
th { class: "px-4 py-3 font-medium w-10",
|
th { class: "px-4 py-3 font-medium w-10",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
class: "rounded border-gray-300 dark:border-[#555]",
|
class: "{CHECKBOX_CLASS}",
|
||||||
checked: all_selected,
|
checked: all_selected,
|
||||||
onchange: {
|
onchange: {
|
||||||
move |_| {
|
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_spam: EventHandler,
|
||||||
on_trash: EventHandler,
|
on_trash: EventHandler,
|
||||||
) -> Element {
|
) -> Element {
|
||||||
let (badge_class, status_label) = match &comment.status {
|
let status_label = match &comment.status {
|
||||||
CommentStatus::Pending => (
|
CommentStatus::Pending => "待审核".to_string(),
|
||||||
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
CommentStatus::Approved => "已通过".to_string(),
|
||||||
"待审核",
|
CommentStatus::Spam => "垃圾".to_string(),
|
||||||
),
|
CommentStatus::Trash => "已删除".to_string(),
|
||||||
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 date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
let date_str = comment.created_at.format("%Y-%m-%d").to_string();
|
||||||
let preview = if comment.content_md.len() > 100 {
|
let preview = if comment.content_md.len() > 100 {
|
||||||
@ -359,11 +355,11 @@ fn CommentRow(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rsx! {
|
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",
|
td { class: "px-4 py-3",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
class: "rounded border-gray-300 dark:border-[#555]",
|
class: "{CHECKBOX_CLASS}",
|
||||||
checked: selected,
|
checked: selected,
|
||||||
onchange: move |e| on_select.call(e.checked()),
|
onchange: move |e| on_select.call(e.checked()),
|
||||||
}
|
}
|
||||||
@ -398,8 +394,15 @@ fn CommentRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-center",
|
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}",
|
StatusBadge {
|
||||||
"{status_label}"
|
// 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]",
|
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",
|
div { class: "flex justify-end gap-2",
|
||||||
if !matches!(comment.status, CommentStatus::Approved) {
|
if !matches!(comment.status, CommentStatus::Approved) {
|
||||||
button {
|
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(()),
|
onclick: move |_| on_approve.call(()),
|
||||||
"通过"
|
"通过"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !matches!(comment.status, CommentStatus::Spam) {
|
if !matches!(comment.status, CommentStatus::Spam) {
|
||||||
button {
|
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(()),
|
onclick: move |_| on_spam.call(()),
|
||||||
"垃圾"
|
"垃圾"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !matches!(comment.status, CommentStatus::Trash) {
|
if !matches!(comment.status, CommentStatus::Trash) {
|
||||||
button {
|
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(()),
|
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", "»" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ use crate::api::comments::get_pending_count;
|
|||||||
use crate::api::posts::{get_post_stats, list_posts};
|
use crate::api::posts::{get_post_stats, list_posts};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::posts::{PostListResponse, PostStatsResponse};
|
use crate::api::posts::{PostListResponse, PostStatsResponse};
|
||||||
|
use crate::components::ui::ADMIN_CARD_CLASS;
|
||||||
use crate::models::post::{Post, PostStats};
|
use crate::models::post::{Post, PostStats};
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -151,7 +152,7 @@ pub fn Admin() -> Element {
|
|||||||
#[component]
|
#[component]
|
||||||
fn StatCard(value: String, label: String) -> Element {
|
fn StatCard(value: String, label: String) -> Element {
|
||||||
rsx! {
|
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]",
|
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||||
"{value}"
|
"{value}"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ use crate::api::posts::PostListResponse;
|
|||||||
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
|
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
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::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
|
|
||||||
@ -135,11 +136,9 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
if loading() && posts().is_empty() {
|
if loading() && posts().is_empty() {
|
||||||
DelayedSkeleton { PostsSkeleton {} }
|
DelayedSkeleton { PostsSkeleton {} }
|
||||||
} else if posts().is_empty() {
|
} else if posts().is_empty() {
|
||||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
EmptyState { message: "暂无文章", variant: "default" }
|
||||||
"暂无文章"
|
|
||||||
}
|
|
||||||
} else {
|
} 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",
|
table { class: "w-full text-sm",
|
||||||
thead {
|
thead {
|
||||||
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
|
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() }
|
Pagination {
|
||||||
}
|
variant: "admin",
|
||||||
}
|
current_page,
|
||||||
}
|
total: total(),
|
||||||
}
|
per_page: POSTS_PER_PAGE,
|
||||||
|
prev_route: if current_page - 1 <= 1 {
|
||||||
/// 分页导航组件,根据当前页与总数量生成上一页 / 下一页链接。
|
Route::Posts {}
|
||||||
#[component]
|
} else {
|
||||||
fn Pagination(current_page: i32, total: i64) -> Element {
|
Route::PostsPage { page: current_page - 1 }
|
||||||
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;
|
next_route: Route::PostsPage { page: current_page + 1 },
|
||||||
let has_next = current_page < total_pages;
|
unit: "篇",
|
||||||
|
}
|
||||||
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", "»" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -236,11 +201,9 @@ fn Pagination(current_page: i32, total: i64) -> Element {
|
|||||||
#[component]
|
#[component]
|
||||||
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
|
||||||
let date_str = post.formatted_date();
|
let date_str = post.formatted_date();
|
||||||
let status_label = post.status_label();
|
|
||||||
let status_badge_class = post.status_badge_class();
|
|
||||||
|
|
||||||
rsx! {
|
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",
|
td { class: "px-4 py-3",
|
||||||
Link {
|
Link {
|
||||||
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
|
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",
|
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}",
|
StatusBadge {
|
||||||
"{status_label}"
|
color_class: post.status_badge_class(),
|
||||||
|
label: post.status_label().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
|
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 {
|
class: if deleting {
|
||||||
"text-xs text-gray-400 cursor-not-allowed"
|
"text-xs text-gray-400 cursor-not-allowed"
|
||||||
} else {
|
} else {
|
||||||
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
|
BTN_TEXT_RED
|
||||||
},
|
},
|
||||||
disabled: deleting,
|
disabled: deleting,
|
||||||
onclick: move |_| on_delete.call(post.id),
|
onclick: move |_| on_delete.call(post.id),
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus::router::components::Link;
|
|
||||||
|
|
||||||
// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
|
// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
|
||||||
// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
|
// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
|
||||||
@ -20,6 +19,10 @@ use crate::api::posts::{list_deleted_posts, PostListResponse};
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
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::post::Post;
|
||||||
use crate::models::settings::TrashSettings;
|
use crate::models::settings::TrashSettings;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
@ -319,7 +322,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
"已选择 {selected_ids().len()} 条"
|
"已选择 {selected_ids().len()} 条"
|
||||||
}
|
}
|
||||||
button {
|
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 |_| {
|
onclick: move |_| {
|
||||||
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
@ -331,7 +334,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
"批量恢复"
|
"批量恢复"
|
||||||
}
|
}
|
||||||
button {
|
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 |_| {
|
onclick: move |_| {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
@ -356,9 +359,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
// 主内容:错误 / 加载骨架 / 空态 / 列表
|
// 主内容:错误 / 加载骨架 / 空态 / 列表
|
||||||
{
|
{
|
||||||
if error().is_some() {
|
if error().is_some() {
|
||||||
rsx! {
|
rsx! { EmptyState { message: "加载失败", variant: "error" } }
|
||||||
div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" }
|
|
||||||
}
|
|
||||||
} else if loading() && posts().is_empty() {
|
} else if loading() && posts().is_empty() {
|
||||||
rsx! {
|
rsx! {
|
||||||
DelayedSkeleton {
|
DelayedSkeleton {
|
||||||
@ -370,17 +371,13 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if posts().is_empty() {
|
} else if posts().is_empty() {
|
||||||
rsx! {
|
rsx! { EmptyState { message: "回收站为空", variant: "default" } }
|
||||||
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
|
|
||||||
"回收站为空"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let list = posts();
|
let list = posts();
|
||||||
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
|
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
|
||||||
let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||||
rsx! {
|
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",
|
div { class: "overflow-x-auto",
|
||||||
table { class: "w-full text-sm",
|
table { class: "w-full text-sm",
|
||||||
thead {
|
thead {
|
||||||
@ -388,7 +385,7 @@ pub fn TrashPage(page: i32) -> Element {
|
|||||||
th { class: "px-4 py-3 font-medium w-10",
|
th { class: "px-4 py-3 font-medium w-10",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
class: "rounded border-gray-300 dark:border-[#555]",
|
class: "{CHECKBOX_CLASS}",
|
||||||
checked: all_selected,
|
checked: all_selected,
|
||||||
onchange: {
|
onchange: {
|
||||||
move |_| {
|
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());
|
.unwrap_or_else(|| "—".to_string());
|
||||||
|
|
||||||
rsx! {
|
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",
|
td { class: "px-4 py-3",
|
||||||
input {
|
input {
|
||||||
r#type: "checkbox",
|
r#type: "checkbox",
|
||||||
class: "rounded border-gray-300 dark:border-[#555]",
|
class: "{CHECKBOX_CLASS}",
|
||||||
checked: selected,
|
checked: selected,
|
||||||
onchange: move |e| on_select.call(e.checked()),
|
onchange: move |e| on_select.call(e.checked()),
|
||||||
}
|
}
|
||||||
@ -552,27 +561,29 @@ fn TrashRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3",
|
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()}",
|
StatusBadge {
|
||||||
"{post.status_label()}"
|
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]",
|
td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
|
||||||
"{deleted_str}"
|
"{deleted_str}"
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-center",
|
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}",
|
StatusBadge {
|
||||||
"{badge_text}"
|
color_class: badge_class,
|
||||||
|
label: badge_text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { class: "px-4 py-3 text-right",
|
td { class: "px-4 py-3 text-right",
|
||||||
div { class: "flex justify-end gap-2",
|
div { class: "flex justify-end gap-2",
|
||||||
button {
|
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(()),
|
onclick: move |_| on_restore.call(()),
|
||||||
"恢复"
|
"恢复"
|
||||||
}
|
}
|
||||||
button {
|
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(()),
|
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", "»" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -10,12 +10,12 @@
|
|||||||
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus::router::components::Link;
|
|
||||||
|
|
||||||
use crate::api::posts::{list_published_posts, PostListResponse};
|
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||||
use crate::components::post_card::PostCard;
|
use crate::components::post_card::PostCard;
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::home_skeleton::HomeSkeleton;
|
use crate::components::skeletons::home_skeleton::HomeSkeleton;
|
||||||
|
use crate::components::ui::Pagination;
|
||||||
use crate::router::Route;
|
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)) => {
|
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", "»" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user