Skip to main content

yggdrasil/pages/admin/
comments.rs

1//! 评论管理(`/admin/comments`)。
2//!
3//! 标题行(总数与待审数)→ 待审 / 已通过 / 垃圾 / 回收站选项卡(带数量)→ 评论流:
4//! 头像首字、作者与网址、「回复 X」、时间、正文与图片、所属文章与按状态给出的操作
5//! (通过、回复、标为垃圾、不是垃圾、移到回收站、恢复)。回复在行内展开 Composer,
6//! 以管理员身份直接发表。勾选后底部浮起批量操作条。默认打开待审,没有待审时打开
7//! 已通过。数据仅在 WASM 前端读取(Effect + 世代号)。样式见 styles/pages/admin-comments.css。
8
9use std::collections::HashSet;
10
11use leptos::prelude::*;
12use leptos::task::spawn_local;
13use leptos_router::hooks::{use_params_map, use_query_map};
14use leptos_router::{lazy_route, LazyRoute};
15
16use crate::api::comments::{AllCommentsResponse, CommentCounts};
17use crate::components::admin_layout::AdminPageHead;
18use crate::components::empty_state::EmptyState;
19use crate::components::mascot::Mascot;
20use crate::components::skeletons::admin_comments_skeleton::AdminCommentsListSkeleton;
21use crate::components::ui::{
22    button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, Composer, ComposerInput,
23    ComposerSend, ComposerSize, IconButton, IconName, LoadError, SegmentedControl, SegmentedItem,
24    TablePager,
25};
26use crate::models::comment::{AdminComment, CommentStatus};
27use crate::utils::error::failure_message;
28use crate::utils::time::{hm, to_local, ymd};
29
30/// 每页评论数,与服务端列表接口一致。
31const COMMENTS_PER_PAGE: i32 = 20;
32
33/// 评论正文拆成文字与图片地址:Markdown 图片 `![alt](url)` 单独显示为缩略图。
34fn split_images(md: &str) -> (String, Vec<String>) {
35    let mut text = String::new();
36    let mut images = Vec::new();
37    let mut rest = md;
38    while let Some(start) = rest.find("![") {
39        let after = &rest[start..];
40        let parsed = after.find("](").and_then(|mid| {
41            let tail = &after[mid + 2..];
42            tail.find(')')
43                .map(|end| (mid + 2 + end + 1, tail[..end].trim()))
44        });
45        match parsed {
46            Some((len, url)) if !url.is_empty() => {
47                text.push_str(&rest[..start]);
48                images.push(url.split_whitespace().next().unwrap_or(url).to_string());
49                rest = &rest[start + len..];
50            }
51            _ => {
52                text.push_str(&rest[..start + 2]);
53                rest = &rest[start + 2..];
54            }
55        }
56    }
57    text.push_str(rest);
58    (text.trim().to_string(), images)
59}
60
61/// 某个选项卡对应的条数。
62fn count_of(counts: &CommentCounts, tab: &str) -> i64 {
63    match tab {
64        "pending" => counts.pending,
65        "approved" => counts.approved,
66        "spam" => counts.spam,
67        "trash" => counts.trash,
68        _ => 0,
69    }
70}
71
72/// 评论管理路由:`comments` 与 `comments/:page`(缺省第 1 页)。
73pub struct AdminCommentsRoute {
74    page: Memo<i32>,
75}
76
77#[lazy_route]
78impl LazyRoute for AdminCommentsRoute {
79    fn data() -> Self {
80        let params = use_params_map();
81        let page = Memo::new(move |_| {
82            params
83                .read()
84                .get("page")
85                .and_then(|p| p.parse::<i32>().ok())
86                .unwrap_or(1)
87                .max(1)
88        });
89        Self { page }
90    }
91
92    fn view(this: Self) -> AnyView {
93        let page = this.page;
94        view! {
95            {move || {
96                let page = page.get();
97                view! { <AdminCommentsPage page=page/> }
98            }}
99        }
100        .into_any()
101    }
102}
103
104/// 列表区域的显示形态;只在形态变化时重建 DOM。
105#[derive(Clone, PartialEq)]
106enum Body {
107    Error,
108    Skeleton,
109    Empty(String),
110    List,
111}
112
113/// 评论管理。`page` 来自 `/admin/comments/:page`,之后翻页只改本页状态。
114#[component]
115fn AdminCommentsPage(page: i32) -> impl IntoView {
116    let toast = use_toast();
117    let query = use_query_map();
118    // 空字符串表示还没决定:先读待审,没有待审时换到已通过。
119    let tab = RwSignal::new(
120        query
121            .read_untracked()
122            .get("status")
123            .filter(|s| matches!(s.as_str(), "pending" | "approved" | "spam" | "trash"))
124            .unwrap_or_default(),
125    );
126    let current_page = RwSignal::new(page.max(1));
127    let data = RwSignal::new(None::<AllCommentsResponse>);
128    let error = RwSignal::new(false);
129    let loading = RwSignal::new(true);
130    let reload = RwSignal::new(0_u32);
131    let selected = RwSignal::new(HashSet::<i64>::new());
132    let busy = RwSignal::new(false);
133    let replying = RwSignal::new(None::<i64>);
134    let generation = StoredValue::new(0_u64);
135
136    Effect::new(move |_| {
137        let requested = tab.get();
138        let p = current_page.get();
139        reload.track();
140        let mut id = 0;
141        generation.update_value(|g| {
142            *g += 1;
143            id = *g;
144        });
145        loading.set(true);
146        spawn_local(async move {
147            let status = if requested.is_empty() {
148                "pending".to_string()
149            } else {
150                requested.clone()
151            };
152            let result = crate::api::comments::get_all_comments(p, Some(status)).await;
153            if generation.try_get_value() != Some(id) {
154                return;
155            }
156            match result {
157                Ok(resp) => {
158                    if requested.is_empty() {
159                        if resp.counts.pending == 0 {
160                            let _ = tab.try_set("approved".into());
161                            return;
162                        }
163                        let _ = tab.try_set("pending".into());
164                    }
165                    let _ = data.try_set(Some(resp));
166                    let _ = error.try_set(false);
167                }
168                Err(_) => {
169                    let _ = error.try_set(true);
170                }
171            }
172            let _ = loading.try_set(false);
173        });
174    });
175
176    let refresh = move || {
177        selected.update(|s| s.clear());
178        reload.update(|n| *n += 1);
179    };
180    // 单条与批量共用:status 为 approved / spam / trash,或 restore。
181    let apply = Callback::new(move |(ids, action): (Vec<i64>, &'static str)| {
182        if ids.is_empty() || busy.get_untracked() {
183            return;
184        }
185        busy.set(true);
186        spawn_local(async move {
187            let result = if action == "restore" {
188                crate::api::comments::restore_comments(ids.clone()).await
189            } else {
190                crate::api::comments::batch_update_comment_status(ids.clone(), action.into()).await
191            };
192            match result {
193                Ok(res) if res.success => {
194                    let n = res.updated_count;
195                    toast.success(match action {
196                        "approved" => format!("已通过 {n} 条评论"),
197                        "spam" => format!("已把 {n} 条标为垃圾"),
198                        "trash" => format!("已把 {n} 条移到回收站"),
199                        _ => res.message,
200                    });
201                    refresh();
202                }
203                Ok(res) => toast.error(res.message),
204                Err(e) => toast.error(failure_message("操作失败", &e)),
205            }
206            let _ = busy.try_set(false);
207        });
208    });
209
210    let counts =
211        Memo::new(move |_| data.with(|d| d.as_ref().map(|r| r.counts).unwrap_or_default()));
212    let loaded = Memo::new(move |_| data.with(Option::is_some));
213    let rows = Memo::new(move |_| {
214        data.with(|d| d.as_ref().map(|r| r.comments.clone()).unwrap_or_default())
215    });
216    let total = Signal::derive(move || data.with(|d| d.as_ref().map(|r| r.total).unwrap_or(0)));
217    let selection_empty = Memo::new(move |_| selected.with(|s| s.is_empty()));
218    let page_all = Memo::new(move |_| {
219        selected.with(|sel| {
220            rows.with(|rows| !rows.is_empty() && rows.iter().all(|c| sel.contains(&c.id)))
221        })
222    });
223
224    let status_items = Signal::derive(move || {
225        let counts = counts.get();
226        let loaded = loaded.get();
227        [
228            ("pending", "待审", counts.pending),
229            ("approved", "已通过", counts.approved),
230            ("spam", "垃圾", counts.spam),
231            ("trash", "回收站", counts.trash),
232        ]
233        .into_iter()
234        .map(|(value, label, n)| {
235            let item = SegmentedItem::new(value.to_string(), label);
236            if loaded {
237                item.count(n)
238            } else {
239                item
240            }
241        })
242        .collect::<Vec<_>>()
243    });
244
245    let body = Memo::new(move |_| {
246        if error.get() {
247            Body::Error
248        } else if !loaded.get()
249            || (loading.get()
250                && rows.with(Vec::is_empty)
251                && tab.with(|t| count_of(&counts.get(), t)) > 0)
252        {
253            Body::Skeleton
254        } else if rows.with(Vec::is_empty) {
255            Body::Empty(tab.get())
256        } else {
257            Body::List
258        }
259    });
260
261    view! {
262        <div class="admin-comments">
263            <AdminPageHead title="评论">
264                {move || {
265                    loaded
266                        .get()
267                        .then(|| {
268                            let c = counts.get();
269                            format!("{} 条 · 待审 {}", c.pending + c.approved + c.spam, c.pending)
270                        })
271                }}
272            </AdminPageHead>
273            <div class="admin-toolbar">
274                <SegmentedControl
275                    aria_label="评论状态"
276                    items=status_items
277                    value=tab
278                    on_change=move |value: String| {
279                        selected.update(|s| s.clear());
280                        replying.set(None);
281                        current_page.set(1);
282                        tab.set(value);
283                    }
284                />
285                {move || {
286                    (!rows.with(Vec::is_empty))
287                        .then(|| {
288                            view! {
289                                <Checkbox
290                                    checked=page_all
291                                    label="选择本页全部评论".to_string()
292                                    onchange=move |on: bool| {
293                                        let ids: Vec<i64> = rows
294                                            .with_untracked(|r| r.iter().map(|c| c.id).collect());
295                                        selected
296                                            .update(|set| {
297                                                for id in ids {
298                                                    if on {
299                                                        set.insert(id);
300                                                    } else {
301                                                        set.remove(&id);
302                                                    }
303                                                }
304                                            });
305                                    }
306                                />
307                            }
308                        })
309                }}
310            </div>
311
312            {move || match body.get() {
313                Body::Error => {
314                    view! {
315                        <LoadError
316                            title="评论加载失败"
317                            on_retry=move |()| reload.update(|n| *n += 1)
318                        />
319                    }
320                        .into_any()
321                }
322                Body::Skeleton => view! { <AdminCommentsListSkeleton/> }.into_any(),
323                Body::Empty(active) => {
324                    view! {
325                        <div class="admin-panel">
326                            {match active.as_str() {
327                                "pending" => {
328                                    view! {
329                                        <EmptyState
330                                            mascot=Mascot::Peace
331                                            title="没有待审评论"
332                                            description="新评论需要审核时会出现在这里。"
333                                        />
334                                    }
335                                        .into_any()
336                                }
337                                "spam" => {
338                                    view! {
339                                        <EmptyState
340                                            icon=IconName::Shield
341                                            title="没有垃圾评论"
342                                            description="被标为垃圾的评论会集中在这里。"
343                                        />
344                                    }
345                                        .into_any()
346                                }
347                                "trash" => {
348                                    view! {
349                                        <EmptyState
350                                            icon=IconName::Trash
351                                            title="回收站是空的"
352                                            description="移到回收站的评论可以在这里恢复。"
353                                        />
354                                    }
355                                        .into_any()
356                                }
357                                _ => {
358                                    view! {
359                                        <EmptyState
360                                            icon=IconName::MessageSquare
361                                            title="还没有评论"
362                                            description="读者的评论通过审核后会出现在这里。"
363                                        />
364                                    }
365                                        .into_any()
366                                }
367                            }}
368                        </div>
369                    }
370                        .into_any()
371                }
372                Body::List => {
373                    view! {
374                        <div
375                            class="admin-panel admin-comment-list"
376                            aria-busy=move || loading.get().then_some("true")
377                        >
378                            <For
379                                each=move || rows.get()
380                                key=|c| format!("{}-{}", c.id, c.status.as_str())
381                                children=move |comment| {
382                                    let id = comment.id;
383                                    view! {
384                                        <CommentItem
385                                            comment=comment
386                                            selected=Signal::derive(move || {
387                                                selected.with(|s| s.contains(&id))
388                                            })
389                                            replying=Signal::derive(move || {
390                                                replying.get() == Some(id)
391                                            })
392                                            busy=busy
393                                            on_select=move |(id, on): (i64, bool)| {
394                                                selected
395                                                    .update(|s| {
396                                                        if on {
397                                                            s.insert(id);
398                                                        } else {
399                                                            s.remove(&id);
400                                                        }
401                                                    });
402                                            }
403                                            on_action=move |(id, action): (i64, &'static str)| {
404                                                if action == "reply" {
405                                                    replying
406                                                        .update(|r| {
407                                                            *r = if *r == Some(id) { None } else { Some(id) };
408                                                        });
409                                                } else {
410                                                    apply.run((vec![id], action));
411                                                }
412                                            }
413                                            on_replied=move |()| {
414                                                replying.set(None);
415                                                refresh();
416                                            }
417                                        />
418                                    }
419                                }
420                            />
421                            <TablePager
422                                page=current_page
423                                total=total
424                                per_page=COMMENTS_PER_PAGE
425                                unit="条"
426                                on_change=move |p| current_page.set(p)
427                            />
428                        </div>
429                    }
430                        .into_any()
431                }
432            }}
433
434            <Show when=move || !selection_empty.get()>
435                <div class="batch-bar" role="toolbar" aria-label="批量操作">
436                    <span class="batch-bar-count">
437                        {move || format!("已选 {} 条", selected.with(|s| s.len()))}
438                    </span>
439                    {move || {
440                        let active = tab.get();
441                        let take = move || selected.get_untracked().into_iter().collect::<Vec<_>>();
442                        if active == "trash" {
443                            view! {
444                                <button
445                                    class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
446                                    type="button"
447                                    disabled=move || busy.get()
448                                    on:click=move |_| apply.run((take(), "restore"))
449                                >
450                                    "恢复"
451                                </button>
452                            }
453                                .into_any()
454                        } else {
455                            view! {
456                                {(active != "approved")
457                                    .then(|| {
458                                        view! {
459                                            <button
460                                                class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
461                                                type="button"
462                                                disabled=move || busy.get()
463                                                on:click=move |_| apply.run((take(), "approved"))
464                                            >
465                                                "通过"
466                                            </button>
467                                        }
468                                    })}
469                                {(active != "spam")
470                                    .then(|| {
471                                        view! {
472                                            <button
473                                                class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
474                                                type="button"
475                                                disabled=move || busy.get()
476                                                on:click=move |_| apply.run((take(), "spam"))
477                                            >
478                                                "标为垃圾"
479                                            </button>
480                                        }
481                                    })}
482                                <button
483                                    class=format!(
484                                        "{} batch-bar-danger",
485                                        button_class(ButtonVariant::Ghost, ButtonSize::Sm),
486                                    )
487                                    type="button"
488                                    disabled=move || busy.get()
489                                    on:click=move |_| apply.run((take(), "trash"))
490                                >
491                                    "移到回收站"
492                                </button>
493                            }
494                                .into_any()
495                        }
496                    }}
497                    <span class="batch-bar-sep" aria-hidden="true"></span>
498                    <IconButton
499                        icon=IconName::X
500                        label="取消选择"
501                        size=ButtonSize::Sm
502                        onclick=move |_| selected.update(|s| s.clear())
503                    />
504                </div>
505            </Show>
506        </div>
507    }
508}
509
510/// 评论流的一条。操作按状态给出,回调 `(评论 id, 操作)`。
511#[component]
512fn CommentItem(
513    comment: AdminComment,
514    #[prop(into)] selected: Signal<bool>,
515    #[prop(into)] replying: Signal<bool>,
516    #[prop(into)] busy: Signal<bool>,
517    #[prop(into)] on_select: Callback<(i64, bool)>,
518    #[prop(into)] on_action: Callback<(i64, &'static str)>,
519    #[prop(into)] on_replied: Callback<()>,
520) -> impl IntoView {
521    let id = comment.id;
522    let (text, images) = split_images(&comment.content_md);
523    let initial = comment
524        .author_name
525        .chars()
526        .next()
527        .map(|c| c.to_uppercase().to_string())
528        .unwrap_or_default();
529    let site = comment
530        .author_url
531        .as_deref()
532        .filter(|u| !u.trim().is_empty())
533        .map(|u| {
534            u.trim_start_matches("https://")
535                .trim_start_matches("http://")
536                .trim_end_matches('/')
537                .to_string()
538        });
539    let when = {
540        let t = to_local(comment.created_at);
541        format!("{} {}", ymd(&t, '-'), hm(&t))
542    };
543    let actions: &[(&'static str, &'static str)] = match comment.status {
544        CommentStatus::Pending => &[
545            ("approved", "通过"),
546            ("spam", "标为垃圾"),
547            ("trash", "移到回收站"),
548        ],
549        CommentStatus::Approved => &[
550            ("reply", "回复"),
551            ("spam", "标为垃圾"),
552            ("trash", "移到回收站"),
553        ],
554        CommentStatus::Spam => &[("approved", "不是垃圾"), ("trash", "移到回收站")],
555        CommentStatus::Trash => &[("restore", "恢复为待审")],
556    };
557    let is_reply = comment.parent_id.is_some();
558    let author_label = format!("选择 {} 的评论", comment.author_name);
559    let avatar = if comment.avatar_url.is_empty() {
560        view! { <span class="admin-comment-avatar" aria-hidden="true">{initial}</span> }.into_any()
561    } else {
562        view! { <img class="admin-comment-avatar" src=comment.avatar_url.clone() alt="" loading="lazy"/> }
563            .into_any()
564    };
565    let images_view = (!images.is_empty()).then(|| {
566        view! {
567            <div class="admin-comment-images">
568                {images
569                    .into_iter()
570                    .map(|url| {
571                        let thumb = format!("{url}?thumb=240x240");
572                        view! {
573                            <a href=url target="_blank" rel="noopener">
574                                <img src=thumb alt="评论图片" loading="lazy"/>
575                            </a>
576                        }
577                    })
578                    .collect_view()}
579            </div>
580        }
581    });
582    let post_id = comment.post_id;
583    let author = comment.author_name.clone();
584    view! {
585        <article
586            class="admin-comment"
587            data-selected=move || selected.get().then_some("true")
588            data-reply=is_reply.then_some("true")
589        >
590            <Checkbox
591                checked=selected
592                label=author_label
593                onchange=move |on| on_select.run((id, on))
594            />
595            {avatar}
596            <div class="admin-comment-body">
597                <div class="admin-comment-head">
598                    <span class="admin-comment-author">{comment.author_name.clone()}</span>
599                    {site.map(|site| view! { <span class="admin-comment-site">{site}</span> })}
600                    {comment
601                        .reply_to
602                        .as_ref()
603                        .map(|to| view! { <span class="admin-comment-to">{format!("回复 {to}")}</span> })}
604                    <time>{when}</time>
605                </div>
606                {(!text.is_empty()).then(|| view! { <p class="admin-comment-text">{text}</p> })}
607                {images_view}
608                <div class="admin-comment-foot">
609                    <a
610                        class="admin-comment-post"
611                        href=format!("/post/{}", comment.post_slug)
612                        target="_blank"
613                        rel="noopener"
614                    >
615                        {format!("于《{}》", comment.post_title)}
616                    </a>
617                    <div class="admin-comment-actions">
618                        {actions
619                            .iter()
620                            .copied()
621                            .map(|(action, label)| {
622                                view! {
623                                    <button
624                                        class=button_class(ButtonVariant::Ghost, ButtonSize::Xs)
625                                        data-danger=(action == "trash").then_some("true")
626                                        type="button"
627                                        disabled=move || busy.get()
628                                        aria-expanded=move || {
629                                            (action == "reply").then(|| replying.get().to_string())
630                                        }
631                                        on:click=move |_| on_action.run((id, action))
632                                    >
633                                        {label}
634                                    </button>
635                                }
636                            })
637                            .collect_view()}
638                    </div>
639                </div>
640                <Show when=move || replying.get()>
641                    <ReplyComposer
642                        post_id=post_id
643                        parent_id=id
644                        author=author.clone()
645                        on_done=move |()| on_replied.run(())
646                    />
647                </Show>
648            </div>
649        </article>
650    }
651}
652
653/// 行内回复:以当前管理员身份发表,直接通过。
654#[component]
655fn ReplyComposer(
656    post_id: i32,
657    parent_id: i64,
658    author: String,
659    #[prop(into)] on_done: Callback<()>,
660) -> impl IntoView {
661    let toast = use_toast();
662    let text = RwSignal::new(String::new());
663    let sending = RwSignal::new(false);
664    let send = move || {
665        let content = text.get_untracked().trim().to_string();
666        if content.is_empty() || sending.get_untracked() {
667            return;
668        }
669        sending.set(true);
670        spawn_local(async move {
671            match crate::api::comments::create_comment(
672                post_id,
673                Some(parent_id),
674                String::new(),
675                String::new(),
676                None,
677                content,
678                String::new(),
679            )
680            .await
681            {
682                Ok(res) if res.success => {
683                    toast.success("回复已发表");
684                    on_done.try_run(());
685                }
686                Ok(res) => toast.error(res.message),
687                Err(e) => toast.error(failure_message("回复失败", &e)),
688            }
689            let _ = sending.try_set(false);
690        });
691    };
692    let aria = format!("回复 {author}");
693    let placeholder = format!("回复 {author}…(⌘↵ 发送)");
694    view! {
695        <Composer size=ComposerSize::Sm class="admin-comment-reply">
696            <ComposerInput
697                multiline=true
698                autofocus=true
699                aria_label=aria
700                placeholder=placeholder
701                value=text
702                oninput=move |value: String| text.set(value)
703                on_submit=move |()| send()
704            />
705            <ComposerSend
706                label="发送回复"
707                icon=IconName::Send
708                disabled=Signal::derive(move || {
709                    sending.get() || text.with(|t| t.trim().is_empty())
710                })
711                onclick=move |_| send()
712            />
713        </Composer>
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn split_images_pulls_markdown_images_out_of_text() {
723        let (text, images) = split_images("看图 ![截图](/uploads/a.webp) 就懂了");
724        assert_eq!(text, "看图  就懂了");
725        assert_eq!(images, ["/uploads/a.webp"]);
726        let (text, images) = split_images("![](/uploads/b.webp \"标题\")");
727        assert_eq!(text, "");
728        assert_eq!(images, ["/uploads/b.webp"]);
729        let (text, images) = split_images("不是图片 ![没有地址]");
730        assert_eq!(text, "不是图片 ![没有地址]");
731        assert!(images.is_empty());
732    }
733
734    #[test]
735    fn count_of_matches_tabs() {
736        let counts = CommentCounts {
737            pending: 1,
738            approved: 2,
739            spam: 3,
740            trash: 4,
741        };
742        assert_eq!(count_of(&counts, "spam"), 3);
743        assert_eq!(count_of(&counts, "trash"), 4);
744        assert_eq!(count_of(&counts, ""), 0);
745    }
746}