Skip to main content

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}