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::admin_comments_skeleton::AdminCommentsTableSkeleton;
19use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
20use crate::components::ui::{
21    Checkbox, FilterTabs, Pagination, UserAvatar, BTN_GHOST, BTN_SOLID_AMBER, BTN_SOLID_GREEN,
22    BTN_SOLID_RED,
23};
24use crate::models::comment::{AdminComment, CommentStatus};
25use crate::router::Route;
26
27/// 每页展示的评论数量。
28const COMMENTS_PER_PAGE: i32 = 20;
29
30/// 评论管理入口组件,默认展示第 1 页。
31#[component]
32pub fn AdminComments() -> Element {
33    rsx! {
34        AdminCommentsPage { page: 1 }
35    }
36}
37
38/// 评论管理分页组件。
39///
40/// 支持按状态筛选、全选 / 单选、批量审批 / 标记垃圾 / 删除,以及单条评论状态操作。
41#[component]
42pub fn AdminCommentsPage(page: i32) -> Element {
43    let current_page = page.max(1);
44    // 当前筛选状态:优先从 URL 查询参数 `?status=` 读取(仅 WASM 前端)。
45    let mut active_filter = use_signal(|| {
46        #[cfg(target_arch = "wasm32")]
47        {
48            web_sys::window()
49                .and_then(|w| w.location().search().ok())
50                .and_then(|s| {
51                    let params = s.trim_start_matches('?');
52                    for pair in params.split('&') {
53                        if let Some(val) = pair.strip_prefix("status=") {
54                            return Some(val.to_string());
55                        }
56                    }
57                    None
58                })
59                .unwrap_or_default()
60        }
61        #[cfg(not(target_arch = "wasm32"))]
62        String::new()
63    });
64    // 已选中的评论 ID 集合、评论列表、总数、加载与错误状态。
65    let mut selected_ids: Signal<HashSet<i64>> = use_signal(HashSet::new);
66    let mut comments: Signal<Vec<AdminComment>> = use_signal(Vec::new);
67    let mut total: Signal<i64> = use_signal(|| 0);
68    #[allow(unused_mut)]
69    let mut loading: Signal<bool> = use_signal(|| true);
70    #[allow(unused_mut)]
71    let mut error: Signal<Option<String>> = use_signal(|| None);
72
73    // 将当前筛选字符串转换为接口所需的 status 参数。
74    #[allow(unused_variables)]
75    let filter_status = move || {
76        let f = active_filter();
77        if f.is_empty() {
78            None
79        } else {
80            Some(f)
81        }
82    };
83
84    // 客户端(CSR)加载数据:筛选或页码变化时触发。
85    use_effect(move || {
86        let _ = active_filter();
87        let _ = current_page;
88
89        // 仅在 WASM 前端发起评论列表请求。
90        #[cfg(target_arch = "wasm32")]
91        {
92            let page = current_page;
93            let status = filter_status();
94            spawn(async move {
95                loading.set(true);
96                error.set(None);
97                match get_all_comments(page, status).await {
98                    Ok(AllCommentsResponse {
99                        comments: list,
100                        total: t,
101                    }) => {
102                        comments.set(list);
103                        total.set(t);
104                    }
105                    Err(e) => error.set(Some(e.to_string())),
106                }
107                loading.set(false);
108            });
109        }
110    });
111
112    #[allow(unused_mut)]
113    let mut set_comment_status = move |id: i64, status: CommentStatus| {
114        comments.with_mut(|list| {
115            if let Some(c) = list.iter_mut().find(|c| c.id == id) {
116                c.status = status;
117            }
118        });
119    };
120
121    #[allow(unused_mut, unused_variables)]
122    let mut remove_comment = move |id: i64| {
123        comments.with_mut(|list| list.retain(|c| c.id != id));
124        total.with_mut(|t| *t = t.saturating_sub(1));
125    };
126
127    rsx! {
128        div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-6",
129            // 页头:标题与副标题
130            div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-[var(--color-paper-border)]/70",
131                div {
132                    h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
133                        "评论管理"
134                    }
135                    p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
136                        "所有文章评论 ({total()}) · 审核读者互动与拦截垃圾内容"
137                    }
138                }
139            }
140
141            // 状态筛选 Tab 胶囊
142            FilterTabs {
143                items: vec![
144                    ("", "全部"),
145                    ("pending", "待审核"),
146                    ("approved", "已通过"),
147                    ("spam", "垃圾箱"),
148                ],
149                active_value: active_filter(),
150                on_change: move |v| active_filter.set(v),
151            }
152
153            // 批量操作栏(选中时浮动展开)
154            if !selected_ids().is_empty() {
155                div { class: "animate-row-enter flex flex-wrap items-center justify-between gap-3 p-3.5 bg-[var(--color-paper-entry)] rounded-2xl border border-[var(--color-paper-border)] shadow-xs",
156                    div { class: "flex items-center gap-2 text-sm font-medium text-[var(--color-paper-primary)]",
157                        span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
158                        span { "已选中 {selected_ids().len()} 条评论" }
159                    }
160                    div { class: "flex items-center gap-2",
161                        button {
162                            class: "{BTN_SOLID_GREEN} inline-flex items-center gap-1.5",
163                            onclick: move |_| {
164                                let ids: Vec<i64> = selected_ids().iter().copied().collect();
165                                let ids_for_api = ids.clone();
166                                spawn(async move {
167                                    let _ = batch_update_comment_status(ids_for_api, "approved".to_string())
168                                        .await;
169                                });
170                                for id in &ids {
171                                    set_comment_status(*id, CommentStatus::Approved);
172                                }
173                                selected_ids.set(HashSet::new());
174                            },
175                            svg {
176                                class: "w-3.5 h-3.5",
177                                xmlns: "http://www.w3.org/2000/svg",
178                                view_box: "0 0 24 24",
179                                fill: "none",
180                                stroke: "currentColor",
181                                stroke_width: "2",
182                                stroke_linecap: "round",
183                                stroke_linejoin: "round",
184                                polyline { points: "20 6 9 17 4 12" }
185                            }
186                            "批量通过"
187                        }
188                        button {
189                            class: "{BTN_SOLID_AMBER} inline-flex items-center gap-1.5",
190                            onclick: move |_| {
191                                let ids: Vec<i64> = selected_ids().iter().copied().collect();
192                                let ids_for_api = ids.clone();
193                                spawn(async move {
194                                    let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await;
195                                });
196                                for id in &ids {
197                                    set_comment_status(*id, CommentStatus::Spam);
198                                }
199                                selected_ids.set(HashSet::new());
200                            },
201                            svg {
202                                class: "w-3.5 h-3.5",
203                                xmlns: "http://www.w3.org/2000/svg",
204                                view_box: "0 0 24 24",
205                                fill: "none",
206                                stroke: "currentColor",
207                                stroke_width: "2",
208                                stroke_linecap: "round",
209                                stroke_linejoin: "round",
210                                path { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" }
211                                line { x1: "12", y1: "9", x2: "12", y2: "13" }
212                                line { x1: "12", y1: "17", x2: "12.01", y2: "17" }
213                            }
214                            "批量垃圾"
215                        }
216                        button {
217                            class: "{BTN_SOLID_RED} inline-flex items-center gap-1.5",
218                            onclick: move |_| {
219                                #[cfg(target_arch = "wasm32")]
220                                {
221                                    if web_sys::window()
222                                        .and_then(|w| {
223                                            w.confirm_with_message("确定要删除这些评论吗?").ok()
224                                        })
225                                        .unwrap_or(false)
226                                    {
227                                        let ids: Vec<i64> = selected_ids().iter().copied().collect();
228                                        let ids_for_api = ids.clone();
229                                        spawn(async move {
230                                            let _ = batch_update_comment_status(ids_for_api, "trash".to_string())
231                                                .await;
232                                        });
233                                        for id in &ids {
234                                            remove_comment(*id);
235                                        }
236                                        selected_ids.set(HashSet::new());
237                                    }
238                                }
239                            },
240                            svg {
241                                class: "w-3.5 h-3.5",
242                                xmlns: "http://www.w3.org/2000/svg",
243                                view_box: "0 0 24 24",
244                                fill: "none",
245                                stroke: "currentColor",
246                                stroke_width: "2",
247                                stroke_linecap: "round",
248                                stroke_linejoin: "round",
249                                polyline { points: "3 6 5 6 21 6" }
250                                path { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }
251                            }
252                            "批量删除"
253                        }
254                        button {
255                            class: "{BTN_GHOST}",
256                            onclick: move |_| selected_ids.set(HashSet::new()),
257                            "取消"
258                        }
259                    }
260                }
261            }
262            {
263                if error().is_some() {
264                    rsx! {
265                        EmptyState {
266                            title: "加载失败",
267                            description: "获取评论列表时发生错误,请稍后重试。",
268                        }
269                    }
270                } else if loading() && comments().is_empty() {
271                    rsx! {
272                        DelayedSkeleton { AdminCommentsTableSkeleton {} }
273                    }
274                } else if comments().is_empty() {
275                    rsx! {
276                        EmptyState {
277                            title: "暂无评论",
278                            description: "当前分类下还没有任何评论。",
279                        }
280                    }
281                } else {
282                    let list = comments();
283                    let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
284                    let all_ids: Vec<i64> = list.iter().map(|c| c.id).collect();
285                    rsx! {
286                        div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
287                            div { class: "overflow-x-auto overflow-y-hidden",
288                                table { class: "w-full text-sm",
289                                    thead {
290                                        tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)] select-none",
291                                            th { class: "px-4 py-3.5 w-10 text-center",
292                                                Checkbox {
293                                                    checked: all_selected,
294                                                    onchange: move |_checked: bool| {
295                                                        let mut s = selected_ids();
296                                                        if all_selected {
297                                                            for id in &all_ids {
298                                                                s.remove(id);
299                                                            }
300                                                        } else {
301                                                            for id in &all_ids {
302                                                                s.insert(*id);
303                                                            }
304                                                        }
305                                                        selected_ids.set(s);
306                                                    },
307                                                }
308                                            }
309                                            th { class: "px-5 py-3.5 font-semibold w-48", "评论作者" }
310                                            th { class: "px-5 py-3.5 font-semibold", "评论内容" }
311                                            th { class: "px-5 py-3.5 font-semibold w-56", "关联文章" }
312                                            th { class: "px-4 py-3.5 font-semibold text-center w-24 whitespace-nowrap",
313                                                "状态"
314                                            }
315                                            th { class: "px-4 py-3.5 font-semibold w-28 whitespace-nowrap", "发表日期" }
316                                            th { class: "px-5 py-3.5 font-semibold w-36 text-right whitespace-nowrap",
317                                                "操作"
318                                            }
319                                        }
320                                    }
321                                    tbody {
322                                        for (idx, comment) in list.iter().enumerate() {
323                                            CommentRow {
324                                                key: "{comment.id}",
325                                                comment: comment.clone(),
326                                                selected: selected_ids().contains(&comment.id),
327                                                stagger_index: idx as u32,
328                                                on_select: {
329                                                    let id = comment.id;
330                                                    move |checked: bool| {
331                                                        let mut s = selected_ids();
332                                                        if checked {
333                                                            s.insert(id);
334                                                        } else {
335                                                            s.remove(&id);
336                                                        }
337                                                        selected_ids.set(s);
338                                                    }
339                                                },
340                                                on_approve: {
341                                                    let id = comment.id;
342                                                    move |_| {
343                                                        spawn(async move {
344                                                            let _ = approve_comment(id).await;
345                                                        });
346                                                        set_comment_status(id, CommentStatus::Approved);
347                                                    }
348                                                },
349                                                on_spam: {
350                                                    let id = comment.id;
351                                                    move |_| {
352                                                        spawn(async move {
353                                                            let _ = spam_comment(id).await;
354                                                        });
355                                                        set_comment_status(id, CommentStatus::Spam);
356                                                    }
357                                                },
358                                                on_trash: {
359                                                    let _id = comment.id;
360                                                    move |_| {
361                                                        #[cfg(target_arch = "wasm32")]
362                                                        {
363                                                            if web_sys::window()
364                                                                .and_then(|w| {
365                                                                    w.confirm_with_message("确定要删除这条评论吗?").ok()
366                                                                })
367                                                                .unwrap_or(false)
368                                                            {
369                                                                spawn(async move {
370                                                                    let _ = trash_comment(_id).await;
371                                                                });
372                                                                remove_comment(_id);
373                                                            }
374                                                        }
375                                                    }
376                                                },
377                                            }
378                                        }
379                                    }
380                                }
381                            }
382                        }
383                        Pagination {
384                            variant: "admin",
385                            current_page,
386                            total: total(),
387                            per_page: COMMENTS_PER_PAGE,
388                            prev_route: if current_page - 1 <= 1 { Route::AdminComments {} } else { Route::AdminCommentsPage {
389                                page: current_page - 1,
390                            } },
391                            next_route: Route::AdminCommentsPage {
392                                page: current_page + 1,
393                            },
394                            unit: "条",
395                        }
396                    }
397                }
398            }
399        }
400    }
401}
402
403/// 评论表格行组件,展示单条评论的作者、内容、所属文章、状态与操作按钮。
404#[component]
405fn CommentRow(
406    comment: AdminComment,
407    selected: bool,
408    stagger_index: u32,
409    on_select: EventHandler<bool>,
410    on_approve: EventHandler,
411    on_spam: EventHandler,
412    on_trash: EventHandler,
413) -> Element {
414    let date_str = comment.created_at.format("%Y-%m-%d").to_string();
415    let preview = if comment.content_md.len() > 100 {
416        format!(
417            "{}...",
418            &comment.content_md[..comment.content_md.ceil_char_boundary(100)]
419        )
420    } else {
421        comment.content_md.clone()
422    };
423
424    rsx! {
425        tr {
426            class: "animate-row-enter border-b border-[var(--color-paper-border)]/60 last:border-b-0 hover:bg-[var(--color-paper-accent-soft)]/30 transition-colors duration-150 group",
427            style: "animation-delay: {stagger_index * 35}ms",
428            td { class: "px-4 py-3.5 text-center",
429                Checkbox {
430                    checked: selected,
431                    onchange: move |checked: bool| on_select.call(checked),
432                }
433            }
434            // 作者信息:头像 + 姓名 + 邮箱
435            td { class: "px-5 py-3.5",
436                div { class: "flex items-center gap-2.5",
437                    UserAvatar {
438                        name: comment.author_name.clone(),
439                        avatar_url: if comment.avatar_url.is_empty() { None } else { Some(comment.avatar_url.clone()) },
440                        class: "w-8 h-8 rounded-full border border-[var(--color-paper-border)]/60 text-xs shrink-0",
441                    }
442                    div { class: "min-w-0",
443                        div { class: "text-sm font-semibold text-[var(--color-paper-primary)] truncate",
444                            "{comment.author_name}"
445                        }
446                        div { class: "text-xs font-mono text-[var(--color-paper-tertiary)] truncate",
447                            "{comment.author_email}"
448                        }
449                    }
450                }
451            }
452            // 评论内容
453            td { class: "px-5 py-3.5 max-w-sm",
454                p { class: "text-sm text-[var(--color-paper-primary)] leading-relaxed line-clamp-2",
455                    "{preview}"
456                }
457            }
458            // 关联文章
459            td { class: "px-5 py-3.5 max-w-xs",
460                Link {
461                    class: "inline-flex items-center gap-1.5 text-xs font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-accent)] transition-colors line-clamp-1 leading-normal",
462                    to: NavigationTarget::<Route>::External(format!("/post/{}", comment.post_slug)),
463                    svg {
464                        class: "w-3.5 h-3.5 shrink-0 opacity-70",
465                        xmlns: "http://www.w3.org/2000/svg",
466                        view_box: "0 0 24 24",
467                        fill: "none",
468                        stroke: "currentColor",
469                        stroke_width: "2",
470                        stroke_linecap: "round",
471                        stroke_linejoin: "round",
472                        path { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }
473                        polyline { points: "14 2 14 8 20 8" }
474                    }
475                    "{comment.post_title}"
476                }
477            }
478            // 状态胶囊
479            td { class: "px-4 py-3.5 text-center whitespace-nowrap",
480                match &comment.status {
481                    CommentStatus::Approved => rsx! {
482                        span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20",
483                            span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
484                            "已通过"
485                        }
486                    },
487                    CommentStatus::Pending => rsx! {
488                        span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
489                            span { class: "w-1.5 h-1.5 rounded-full bg-amber-500" }
490                            "待审核"
491                        }
492                    },
493                    CommentStatus::Spam => rsx! {
494                        span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20",
495                            span { class: "w-1.5 h-1.5 rounded-full bg-red-500" }
496                            "垃圾"
497                        }
498                    },
499                    CommentStatus::Trash => rsx! {
500                        span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/20",
501                            span { class: "w-1.5 h-1.5 rounded-full bg-gray-400" }
502                            "已删除"
503                        }
504                    },
505                }
506            }
507            // 发表日期
508            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
509                "{date_str}"
510            }
511            // 操作按钮
512            td { class: "px-5 py-3.5 text-right whitespace-nowrap",
513                div { class: "flex justify-end items-center gap-1.5",
514                    if !matches!(comment.status, CommentStatus::Approved) {
515                        button {
516                            class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-colors cursor-pointer",
517                            onclick: move |_| on_approve.call(()),
518                            svg {
519                                class: "w-3.5 h-3.5",
520                                xmlns: "http://www.w3.org/2000/svg",
521                                view_box: "0 0 24 24",
522                                fill: "none",
523                                stroke: "currentColor",
524                                stroke_width: "2",
525                                stroke_linecap: "round",
526                                stroke_linejoin: "round",
527                                polyline { points: "20 6 9 17 4 12" }
528                            }
529                            "通过"
530                        }
531                    }
532                    if !matches!(comment.status, CommentStatus::Spam) {
533                        button {
534                            class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors cursor-pointer",
535                            onclick: move |_| on_spam.call(()),
536                            svg {
537                                class: "w-3.5 h-3.5",
538                                xmlns: "http://www.w3.org/2000/svg",
539                                view_box: "0 0 24 24",
540                                fill: "none",
541                                stroke: "currentColor",
542                                stroke_width: "2",
543                                stroke_linecap: "round",
544                                stroke_linejoin: "round",
545                                path { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" }
546                                line { x1: "12", y1: "9", x2: "12", y2: "13" }
547                                line { x1: "12", y1: "17", x2: "12.01", y2: "17" }
548                            }
549                            "垃圾"
550                        }
551                    }
552                    if !matches!(comment.status, CommentStatus::Trash) {
553                        button {
554                            class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer",
555                            onclick: move |_| on_trash.call(()),
556                            svg {
557                                class: "w-3.5 h-3.5",
558                                xmlns: "http://www.w3.org/2000/svg",
559                                view_box: "0 0 24 24",
560                                fill: "none",
561                                stroke: "currentColor",
562                                stroke_width: "2",
563                                stroke_linecap: "round",
564                                stroke_linejoin: "round",
565                                polyline { points: "3 6 5 6 21 6" }
566                                path { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }
567                            }
568                            "删除"
569                        }
570                    }
571                }
572            }
573        }
574    }
575}