479 lines
22 KiB
Rust
479 lines
22 KiB
Rust
//! 评论管理页面。
|
||
//!
|
||
//! 提供评论列表、状态筛选(全部 / 待审核 / 已通过 / 垃圾箱)、批量操作与单条操作。
|
||
//! 数据加载与状态变更仅在 WASM 前端通过 Dioxus server functions 交互。
|
||
|
||
use std::collections::HashSet;
|
||
|
||
use dioxus::prelude::*;
|
||
use dioxus::router::components::Link;
|
||
|
||
// 仅在 WASM 前端使用的评论管理接口。
|
||
#[cfg(target_arch = "wasm32")]
|
||
use crate::api::comments::trash_comment;
|
||
use crate::api::comments::{approve_comment, batch_update_comment_status, spam_comment};
|
||
#[cfg(target_arch = "wasm32")]
|
||
use crate::api::comments::{get_all_comments, AllCommentsResponse};
|
||
use crate::components::skeletons::atoms::SkeletonBox;
|
||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||
use crate::components::empty_state::EmptyState;
|
||
use crate::components::ui::{
|
||
Pagination, StatusBadge, ADMIN_CARD_CLASS, 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;
|
||
|
||
/// 每页展示的评论数量。
|
||
const COMMENTS_PER_PAGE: i32 = 20;
|
||
|
||
/// 评论管理入口组件,默认展示第 1 页。
|
||
#[component]
|
||
pub fn AdminComments() -> Element {
|
||
rsx! {
|
||
AdminCommentsPage { page: 1 }
|
||
}
|
||
}
|
||
|
||
/// 评论管理分页组件。
|
||
///
|
||
/// 支持按状态筛选、全选 / 单选、批量审批 / 标记垃圾 / 删除,以及单条评论状态操作。
|
||
#[component]
|
||
pub fn AdminCommentsPage(page: i32) -> Element {
|
||
let current_page = page.max(1);
|
||
// 当前筛选状态:优先从 URL 查询参数 `?status=` 读取(仅 WASM 前端)。
|
||
let mut active_filter = use_signal(|| {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
web_sys::window()
|
||
.and_then(|w| w.location().search().ok())
|
||
.and_then(|s| {
|
||
let params = s.trim_start_matches('?');
|
||
for pair in params.split('&') {
|
||
if let Some(val) = pair.strip_prefix("status=") {
|
||
return Some(val.to_string());
|
||
}
|
||
}
|
||
None
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
String::new()
|
||
});
|
||
// 已选中的评论 ID 集合、评论列表、总数、加载与错误状态。
|
||
let mut selected_ids: Signal<HashSet<i64>> = use_signal(HashSet::new);
|
||
let mut comments: Signal<Vec<AdminComment>> = use_signal(Vec::new);
|
||
let mut total: Signal<i64> = use_signal(|| 0);
|
||
#[allow(unused_mut)]
|
||
let mut loading: Signal<bool> = use_signal(|| false);
|
||
#[allow(unused_mut)]
|
||
let mut error: Signal<Option<String>> = use_signal(|| None);
|
||
|
||
// 将当前筛选字符串转换为接口所需的 status 参数。
|
||
#[allow(unused_variables)]
|
||
let filter_status = move || {
|
||
let f = active_filter();
|
||
if f.is_empty() {
|
||
None
|
||
} else {
|
||
Some(f)
|
||
}
|
||
};
|
||
|
||
// 客户端(CSR)加载数据:筛选或页码变化时触发。
|
||
use_effect(move || {
|
||
let _ = active_filter();
|
||
let _ = current_page;
|
||
|
||
// 仅在 WASM 前端发起评论列表请求。
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let page = current_page;
|
||
let status = filter_status();
|
||
spawn(async move {
|
||
loading.set(true);
|
||
error.set(None);
|
||
match get_all_comments(page, status).await {
|
||
Ok(AllCommentsResponse {
|
||
comments: list,
|
||
total: t,
|
||
}) => {
|
||
comments.set(list);
|
||
total.set(t);
|
||
}
|
||
Err(e) => error.set(Some(e.to_string())),
|
||
}
|
||
loading.set(false);
|
||
});
|
||
}
|
||
});
|
||
|
||
#[allow(unused_mut)]
|
||
let mut set_comment_status = move |id: i64, status: CommentStatus| {
|
||
comments.with_mut(|list| {
|
||
if let Some(c) = list.iter_mut().find(|c| c.id == id) {
|
||
c.status = status;
|
||
}
|
||
});
|
||
};
|
||
|
||
#[allow(unused_mut, unused_variables)]
|
||
let mut remove_comment = move |id: i64| {
|
||
comments.with_mut(|list| list.retain(|c| c.id != id));
|
||
total.with_mut(|t| *t = t.saturating_sub(1));
|
||
};
|
||
|
||
rsx! {
|
||
div { class: "space-y-6",
|
||
h1 { class: "text-2xl font-bold text-paper-primary", "评论管理" }
|
||
|
||
div { class: "flex gap-1 border-b border-paper-border",
|
||
for (status, label) in [
|
||
("", "全部"),
|
||
("pending", "待审核"),
|
||
("approved", "已通过"),
|
||
("spam", "垃圾箱"),
|
||
]
|
||
{
|
||
button {
|
||
class: if active_filter() == status { "px-4 py-2 text-sm font-medium border-b-2 border-paper-accent text-paper-primary" } else { "px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors" },
|
||
onclick: move |_| active_filter.set(status.to_string()),
|
||
"{label}"
|
||
}
|
||
}
|
||
}
|
||
|
||
if !selected_ids().is_empty() {
|
||
{
|
||
rsx! {
|
||
div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
|
||
span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" }
|
||
button {
|
||
class: "{BTN_SOLID_GREEN}",
|
||
onclick: move |_| {
|
||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||
let ids_for_api = ids.clone();
|
||
spawn(async move {
|
||
let _ = batch_update_comment_status(ids_for_api, "approved".to_string())
|
||
.await;
|
||
});
|
||
for id in &ids {
|
||
set_comment_status(*id, CommentStatus::Approved);
|
||
}
|
||
selected_ids.set(HashSet::new());
|
||
},
|
||
"批量通过"
|
||
}
|
||
button {
|
||
class: "{BTN_SOLID_AMBER}",
|
||
onclick: move |_| {
|
||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||
let ids_for_api = ids.clone();
|
||
spawn(async move {
|
||
let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await;
|
||
});
|
||
for id in &ids {
|
||
set_comment_status(*id, CommentStatus::Spam);
|
||
}
|
||
selected_ids.set(HashSet::new());
|
||
},
|
||
"批量垃圾"
|
||
}
|
||
button {
|
||
class: "{BTN_SOLID_RED}",
|
||
onclick: move |_| {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if web_sys::window()
|
||
.and_then(|w| {
|
||
w.confirm_with_message("确定要删除这些评论吗?").ok()
|
||
})
|
||
.unwrap_or(false)
|
||
{
|
||
let ids: Vec<i64> = selected_ids().iter().copied().collect();
|
||
let ids_for_api = ids.clone();
|
||
spawn(async move {
|
||
let _ = batch_update_comment_status(ids_for_api, "trash".to_string())
|
||
.await;
|
||
});
|
||
for id in &ids {
|
||
remove_comment(*id);
|
||
}
|
||
selected_ids.set(HashSet::new());
|
||
}
|
||
}
|
||
},
|
||
"批量删除"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
{
|
||
if error().is_some() {
|
||
rsx! {
|
||
EmptyState {
|
||
title: "加载失败",
|
||
description: "获取评论列表时发生错误,请稍后重试。",
|
||
}
|
||
}
|
||
} else if loading() && comments().is_empty() {
|
||
rsx! {
|
||
DelayedSkeleton {
|
||
div { class: "{ADMIN_CARD_CLASS} p-6 space-y-4",
|
||
for _ in 0..5 {
|
||
div { class: "flex items-center gap-4",
|
||
SkeletonBox { class: "h-4 w-4 rounded" }
|
||
SkeletonBox { class: "h-8 w-8 rounded-full" }
|
||
SkeletonBox { class: "h-4 w-32 rounded" }
|
||
SkeletonBox { class: "h-4 flex-1 rounded" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if comments().is_empty() {
|
||
rsx! {
|
||
EmptyState {
|
||
title: "暂无评论",
|
||
description: "当前分类下还没有任何评论。",
|
||
}
|
||
}
|
||
} 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: "{ADMIN_TABLE_CLASS}",
|
||
div { class: "overflow-x-auto",
|
||
table { class: "w-full text-sm",
|
||
thead {
|
||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||
th { class: "px-4 py-3 font-medium w-10",
|
||
input {
|
||
r#type: "checkbox",
|
||
class: "{CHECKBOX_CLASS}",
|
||
checked: all_selected,
|
||
onchange: {
|
||
move |_| {
|
||
let mut s = selected_ids();
|
||
if all_selected {
|
||
for id in &all_ids {
|
||
s.remove(id);
|
||
}
|
||
} else {
|
||
for id in &all_ids {
|
||
s.insert(*id);
|
||
}
|
||
}
|
||
selected_ids.set(s);
|
||
}
|
||
},
|
||
}
|
||
}
|
||
th { class: "px-4 py-3 font-medium", "作者" }
|
||
th { class: "px-4 py-3 font-medium", "内容" }
|
||
th { class: "px-4 py-3 font-medium", "文章" }
|
||
th { class: "px-4 py-3 font-medium text-center", "状态" }
|
||
th { class: "px-4 py-3 font-medium w-28", "日期" }
|
||
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" }
|
||
}
|
||
}
|
||
tbody {
|
||
for comment in list.iter() {
|
||
CommentRow {
|
||
key: "{comment.id}",
|
||
comment: comment.clone(),
|
||
selected: selected_ids().contains(&comment.id),
|
||
on_select: {
|
||
let id = comment.id;
|
||
move |checked: bool| {
|
||
let mut s = selected_ids();
|
||
if checked {
|
||
s.insert(id);
|
||
} else {
|
||
s.remove(&id);
|
||
}
|
||
selected_ids.set(s);
|
||
}
|
||
},
|
||
on_approve: {
|
||
let id = comment.id;
|
||
move |_| {
|
||
spawn(async move {
|
||
let _ = approve_comment(id).await;
|
||
});
|
||
set_comment_status(id, CommentStatus::Approved);
|
||
}
|
||
},
|
||
on_spam: {
|
||
let id = comment.id;
|
||
move |_| {
|
||
spawn(async move {
|
||
let _ = spam_comment(id).await;
|
||
});
|
||
set_comment_status(id, CommentStatus::Spam);
|
||
}
|
||
},
|
||
on_trash: {
|
||
let _id = comment.id;
|
||
move |_| {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if web_sys::window()
|
||
.and_then(|w| {
|
||
w.confirm_with_message("确定要删除这条评论吗?").ok()
|
||
})
|
||
.unwrap_or(false)
|
||
{
|
||
spawn(async move {
|
||
let _ = trash_comment(_id).await;
|
||
});
|
||
remove_comment(_id);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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: "条",
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 评论表格行组件,展示单条评论的作者、内容、所属文章、状态与操作按钮。
|
||
#[component]
|
||
fn CommentRow(
|
||
comment: AdminComment,
|
||
selected: bool,
|
||
on_select: EventHandler<bool>,
|
||
on_approve: EventHandler,
|
||
on_spam: EventHandler,
|
||
on_trash: EventHandler,
|
||
) -> Element {
|
||
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 {
|
||
format!(
|
||
"{}...",
|
||
&comment.content_md[..comment.content_md.ceil_char_boundary(100)]
|
||
)
|
||
} else {
|
||
comment.content_md.clone()
|
||
};
|
||
|
||
rsx! {
|
||
tr { class: "{ADMIN_ROW_HOVER}",
|
||
td { class: "px-4 py-3",
|
||
input {
|
||
r#type: "checkbox",
|
||
class: "{CHECKBOX_CLASS}",
|
||
checked: selected,
|
||
onchange: move |e| on_select.call(e.checked()),
|
||
}
|
||
}
|
||
td { class: "px-4 py-3",
|
||
div { class: "flex items-center gap-2",
|
||
img {
|
||
class: "w-8 h-8 rounded-full flex-shrink-0",
|
||
src: "{comment.avatar_url}",
|
||
alt: "{comment.author_name}",
|
||
}
|
||
div { class: "min-w-0",
|
||
div { class: "text-sm font-medium text-paper-primary truncate",
|
||
"{comment.author_name}"
|
||
}
|
||
div { class: "text-xs text-paper-secondary truncate", "{comment.author_email}" }
|
||
}
|
||
}
|
||
}
|
||
td { class: "px-4 py-3 max-w-xs",
|
||
p { class: "text-sm text-paper-secondary truncate", "{preview}" }
|
||
}
|
||
td { class: "px-4 py-3",
|
||
Link {
|
||
class: "text-sm text-paper-primary hover:text-paper-accent transition-colors",
|
||
to: Route::PostDetail {
|
||
slug: comment.post_slug.clone(),
|
||
},
|
||
"{comment.post_title}"
|
||
}
|
||
}
|
||
td { class: "px-4 py-3 text-center",
|
||
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-paper-secondary", "{date_str}" }
|
||
td { class: "px-4 py-3 text-right",
|
||
div { class: "flex justify-end gap-2",
|
||
if !matches!(comment.status, CommentStatus::Approved) {
|
||
button {
|
||
class: "{BTN_TEXT_GREEN}",
|
||
onclick: move |_| on_approve.call(()),
|
||
"通过"
|
||
}
|
||
}
|
||
if !matches!(comment.status, CommentStatus::Spam) {
|
||
button {
|
||
class: "{BTN_TEXT_AMBER}",
|
||
onclick: move |_| on_spam.call(()),
|
||
"垃圾"
|
||
}
|
||
}
|
||
if !matches!(comment.status, CommentStatus::Trash) {
|
||
button {
|
||
class: "{BTN_TEXT_RED}",
|
||
onclick: move |_| on_trash.call(()),
|
||
"删除"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|