yggdrasil/pages/admin/comments.rs
1//! 评论管理页面。
2//!
3//! 提供评论列表、状态筛选(全部 / 待审核 / 已通过 / 垃圾箱)、批量操作与单条操作。
4//! 数据加载与状态变更仅在 WASM 前端通过 Dioxus server functions 交互。
5
6use std::collections::HashSet;
7
8use dioxus::prelude::*;
9use dioxus::router::components::Link;
10
11// 仅在 WASM 前端使用的评论管理接口。
12#[cfg(target_arch = "wasm32")]
13use crate::api::comments::trash_comment;
14use crate::api::comments::{approve_comment, batch_update_comment_status, spam_comment};
15#[cfg(target_arch = "wasm32")]
16use crate::api::comments::{get_all_comments, AllCommentsResponse};
17use crate::components::empty_state::EmptyState;
18use crate::components::skeletons::atoms::SkeletonBox;
19use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
20use crate::components::ui::{
21 FilterTabs, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
22 BTN_SOLID_AMBER, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_AMBER, BTN_TEXT_GREEN, BTN_TEXT_RED,
23 CHECKBOX_CLASS,
24};
25use crate::models::comment::{AdminComment, CommentStatus};
26use crate::router::Route;
27
28/// 每页展示的评论数量。
29const COMMENTS_PER_PAGE: i32 = 20;
30
31/// 评论管理入口组件,默认展示第 1 页。
32#[component]
33pub fn AdminComments() -> Element {
34 rsx! {
35 AdminCommentsPage { page: 1 }
36 }
37}
38
39/// 评论管理分页组件。
40///
41/// 支持按状态筛选、全选 / 单选、批量审批 / 标记垃圾 / 删除,以及单条评论状态操作。
42#[component]
43pub fn AdminCommentsPage(page: i32) -> Element {
44 let current_page = page.max(1);
45 // 当前筛选状态:优先从 URL 查询参数 `?status=` 读取(仅 WASM 前端)。
46 let mut active_filter = use_signal(|| {
47 #[cfg(target_arch = "wasm32")]
48 {
49 web_sys::window()
50 .and_then(|w| w.location().search().ok())
51 .and_then(|s| {
52 let params = s.trim_start_matches('?');
53 for pair in params.split('&') {
54 if let Some(val) = pair.strip_prefix("status=") {
55 return Some(val.to_string());
56 }
57 }
58 None
59 })
60 .unwrap_or_default()
61 }
62 #[cfg(not(target_arch = "wasm32"))]
63 String::new()
64 });
65 // 已选中的评论 ID 集合、评论列表、总数、加载与错误状态。
66 let mut selected_ids: Signal<HashSet<i64>> = use_signal(HashSet::new);
67 let mut comments: Signal<Vec<AdminComment>> = use_signal(Vec::new);
68 let mut total: Signal<i64> = use_signal(|| 0);
69 #[allow(unused_mut)]
70 let mut loading: Signal<bool> = use_signal(|| true);
71 #[allow(unused_mut)]
72 let mut error: Signal<Option<String>> = use_signal(|| None);
73
74 // 将当前筛选字符串转换为接口所需的 status 参数。
75 #[allow(unused_variables)]
76 let filter_status = move || {
77 let f = active_filter();
78 if f.is_empty() {
79 None
80 } else {
81 Some(f)
82 }
83 };
84
85 // 客户端(CSR)加载数据:筛选或页码变化时触发。
86 use_effect(move || {
87 let _ = active_filter();
88 let _ = current_page;
89
90 // 仅在 WASM 前端发起评论列表请求。
91 #[cfg(target_arch = "wasm32")]
92 {
93 let page = current_page;
94 let status = filter_status();
95 spawn(async move {
96 loading.set(true);
97 error.set(None);
98 match get_all_comments(page, status).await {
99 Ok(AllCommentsResponse {
100 comments: list,
101 total: t,
102 }) => {
103 comments.set(list);
104 total.set(t);
105 }
106 Err(e) => error.set(Some(e.to_string())),
107 }
108 loading.set(false);
109 });
110 }
111 });
112
113 #[allow(unused_mut)]
114 let mut set_comment_status = move |id: i64, status: CommentStatus| {
115 comments.with_mut(|list| {
116 if let Some(c) = list.iter_mut().find(|c| c.id == id) {
117 c.status = status;
118 }
119 });
120 };
121
122 #[allow(unused_mut, unused_variables)]
123 let mut remove_comment = move |id: i64| {
124 comments.with_mut(|list| list.retain(|c| c.id != id));
125 total.with_mut(|t| *t = t.saturating_sub(1));
126 };
127
128 rsx! {
129 div { class: "w-full max-w-7xl mx-auto space-y-6",
130 div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--color-paper-border)] mb-6",
131 div {
132 h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
133 "评论管理"
134 }
135 p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
136 "所有文章评论"
137 }
138 }
139 }
140
141 FilterTabs {
142 items: vec![
143 ("", "全部"),
144 ("pending", "待审核"),
145 ("approved", "已通过"),
146 ("spam", "垃圾箱"),
147 ],
148 active_value: active_filter(),
149 on_change: move |v| active_filter.set(v),
150 }
151
152 if !selected_ids().is_empty() {
153 {
154 rsx! {
155 div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
156 span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" }
157 button {
158 class: "{BTN_SOLID_GREEN}",
159 onclick: move |_| {
160 let ids: Vec<i64> = selected_ids().iter().copied().collect();
161 let ids_for_api = ids.clone();
162 spawn(async move {
163 let _ = batch_update_comment_status(ids_for_api, "approved".to_string())
164 .await;
165 });
166 for id in &ids {
167 set_comment_status(*id, CommentStatus::Approved);
168 }
169 selected_ids.set(HashSet::new());
170 },
171 "批量通过"
172 }
173 button {
174 class: "{BTN_SOLID_AMBER}",
175 onclick: move |_| {
176 let ids: Vec<i64> = selected_ids().iter().copied().collect();
177 let ids_for_api = ids.clone();
178 spawn(async move {
179 let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await;
180 });
181 for id in &ids {
182 set_comment_status(*id, CommentStatus::Spam);
183 }
184 selected_ids.set(HashSet::new());
185 },
186 "批量垃圾"
187 }
188 button {
189 class: "{BTN_SOLID_RED}",
190 onclick: move |_| {
191 #[cfg(target_arch = "wasm32")]
192 {
193 if web_sys::window()
194 .and_then(|w| {
195 w.confirm_with_message("确定要删除这些评论吗?").ok()
196 })
197 .unwrap_or(false)
198 {
199 let ids: Vec<i64> = selected_ids().iter().copied().collect();
200 let ids_for_api = ids.clone();
201 spawn(async move {
202 let _ = batch_update_comment_status(ids_for_api, "trash".to_string())
203 .await;
204 });
205 for id in &ids {
206 remove_comment(*id);
207 }
208 selected_ids.set(HashSet::new());
209 }
210 }
211 },
212 "批量删除"
213 }
214 }
215 }
216 }
217 }
218
219 {
220 if error().is_some() {
221 rsx! {
222 EmptyState {
223 title: "加载失败",
224 description: "获取评论列表时发生错误,请稍后重试。",
225 }
226 }
227 } else if loading() && comments().is_empty() {
228 rsx! {
229 DelayedSkeleton {
230 div { class: "{ADMIN_CARD_CLASS} p-6 space-y-4",
231 for _ in 0..5 {
232 div { class: "flex items-center gap-4",
233 SkeletonBox { class: "h-4 w-4 rounded" }
234 SkeletonBox { class: "h-8 w-8 rounded-full" }
235 SkeletonBox { class: "h-4 w-32 rounded" }
236 SkeletonBox { class: "h-4 flex-1 rounded" }
237 }
238 }
239 }
240 }
241 }
242 } else if comments().is_empty() {
243 rsx! {
244 EmptyState {
245 title: "暂无评论",
246 description: "当前分类下还没有任何评论。",
247 }
248 }
249 } else {
250 let list = comments();
251 let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
252 let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
253 rsx! {
254 div { class: "{ADMIN_TABLE_CLASS}",
255 div { class: "overflow-x-auto",
256 table { class: "w-full text-sm",
257 thead {
258 tr { class: "border-b border-paper-border text-left text-paper-secondary",
259 th { class: "px-4 py-3 font-medium w-10",
260 input {
261 r#type: "checkbox",
262 class: "{CHECKBOX_CLASS}",
263 checked: all_selected,
264 onchange: {
265 move |_| {
266 let mut s = selected_ids();
267 if all_selected {
268 for id in &all_ids {
269 s.remove(id);
270 }
271 } else {
272 for id in &all_ids {
273 s.insert(*id);
274 }
275 }
276 selected_ids.set(s);
277 }
278 },
279 }
280 }
281 th { class: "px-4 py-3 font-medium", "作者" }
282 th { class: "px-4 py-3 font-medium", "内容" }
283 th { class: "px-4 py-3 font-medium", "文章" }
284 th { class: "px-4 py-3 font-medium text-center w-24 whitespace-nowrap",
285 "状态"
286 }
287 th { class: "px-4 py-3 font-medium w-32 whitespace-nowrap", "日期" }
288 th { class: "px-4 py-3 font-medium w-32 text-right whitespace-nowrap",
289 "操作"
290 }
291 }
292 }
293 tbody {
294 for comment in list.iter() {
295 CommentRow {
296 key: "{comment.id}",
297 comment: comment.clone(),
298 selected: selected_ids().contains(&comment.id),
299 on_select: {
300 let id = comment.id;
301 move |checked: bool| {
302 let mut s = selected_ids();
303 if checked {
304 s.insert(id);
305 } else {
306 s.remove(&id);
307 }
308 selected_ids.set(s);
309 }
310 },
311 on_approve: {
312 let id = comment.id;
313 move |_| {
314 spawn(async move {
315 let _ = approve_comment(id).await;
316 });
317 set_comment_status(id, CommentStatus::Approved);
318 }
319 },
320 on_spam: {
321 let id = comment.id;
322 move |_| {
323 spawn(async move {
324 let _ = spam_comment(id).await;
325 });
326 set_comment_status(id, CommentStatus::Spam);
327 }
328 },
329 on_trash: {
330 let _id = comment.id;
331 move |_| {
332 #[cfg(target_arch = "wasm32")]
333 {
334 if web_sys::window()
335 .and_then(|w| {
336 w.confirm_with_message("确定要删除这条评论吗?").ok()
337 })
338 .unwrap_or(false)
339 {
340 spawn(async move {
341 let _ = trash_comment(_id).await;
342 });
343 remove_comment(_id);
344 }
345 }
346 }
347 },
348 }
349 }
350 }
351 }
352 }
353 }
354 Pagination {
355 variant: "admin",
356 current_page,
357 total: total(),
358 per_page: COMMENTS_PER_PAGE,
359 prev_route: if current_page - 1 <= 1 { Route::AdminComments {} } else { Route::AdminCommentsPage {
360 page: current_page - 1,
361 } },
362 next_route: Route::AdminCommentsPage {
363 page: current_page + 1,
364 },
365 unit: "条",
366 }
367 }
368 }
369 }
370 }
371 }
372}
373
374/// 评论表格行组件,展示单条评论的作者、内容、所属文章、状态与操作按钮。
375#[component]
376fn CommentRow(
377 comment: AdminComment,
378 selected: bool,
379 on_select: EventHandler<bool>,
380 on_approve: EventHandler,
381 on_spam: EventHandler,
382 on_trash: EventHandler,
383) -> Element {
384 let status_label = match &comment.status {
385 CommentStatus::Pending => "待审核".to_string(),
386 CommentStatus::Approved => "已通过".to_string(),
387 CommentStatus::Spam => "垃圾".to_string(),
388 CommentStatus::Trash => "已删除".to_string(),
389 };
390 let date_str = comment.created_at.format("%Y-%m-%d").to_string();
391 let preview = if comment.content_md.len() > 100 {
392 format!(
393 "{}...",
394 &comment.content_md[..comment.content_md.ceil_char_boundary(100)]
395 )
396 } else {
397 comment.content_md.clone()
398 };
399
400 rsx! {
401 tr { class: "{ADMIN_ROW_HOVER}",
402 td { class: "px-4 py-3",
403 input {
404 r#type: "checkbox",
405 class: "{CHECKBOX_CLASS}",
406 checked: selected,
407 onchange: move |e| on_select.call(e.checked()),
408 }
409 }
410 td { class: "px-4 py-3",
411 div { class: "flex items-center gap-2",
412 img {
413 class: "w-8 h-8 rounded-full flex-shrink-0",
414 src: "{comment.avatar_url}",
415 alt: "{comment.author_name}",
416 }
417 div { class: "min-w-0",
418 div { class: "text-sm font-medium text-paper-primary truncate",
419 "{comment.author_name}"
420 }
421 div { class: "text-xs text-paper-secondary truncate", "{comment.author_email}" }
422 }
423 }
424 }
425 td { class: "px-4 py-3 max-w-xs",
426 p { class: "text-sm text-paper-secondary truncate", "{preview}" }
427 }
428 td { class: "px-4 py-3",
429 Link {
430 class: "text-sm text-paper-primary hover:text-paper-accent transition-colors",
431 to: Route::PostDetail {
432 slug: comment.post_slug.clone(),
433 },
434 "{comment.post_title}"
435 }
436 }
437 td { class: "px-4 py-3 text-center whitespace-nowrap",
438 StatusBadge {
439 // badge_class 是 &'static str 字面量匹配,转为静态生命周期。
440 color_class: match &comment.status {
441 CommentStatus::Pending => {
442 "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
443 }
444 CommentStatus::Approved => {
445 "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
446 }
447 CommentStatus::Spam => {
448 "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
449 }
450 CommentStatus::Trash => {
451 "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400"
452 }
453 },
454 label: status_label,
455 }
456 }
457 td { class: "px-4 py-3 text-sm text-paper-secondary whitespace-nowrap",
458 "{date_str}"
459 }
460 td { class: "px-4 py-3 text-right whitespace-nowrap",
461 div { class: "flex justify-end gap-2",
462 if !matches!(comment.status, CommentStatus::Approved) {
463 button {
464 class: "{BTN_TEXT_GREEN}",
465 onclick: move |_| on_approve.call(()),
466 "通过"
467 }
468 }
469 if !matches!(comment.status, CommentStatus::Spam) {
470 button {
471 class: "{BTN_TEXT_AMBER}",
472 onclick: move |_| on_spam.call(()),
473 "垃圾"
474 }
475 }
476 if !matches!(comment.status, CommentStatus::Trash) {
477 button {
478 class: "{BTN_TEXT_RED}",
479 onclick: move |_| on_trash.call(()),
480 "删除"
481 }
482 }
483 }
484 }
485 }
486 }
487}