1use 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
30const COMMENTS_PER_PAGE: i32 = 20;
32
33fn 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
61fn 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
72pub 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#[derive(Clone, PartialEq)]
106enum Body {
107 Error,
108 Skeleton,
109 Empty(String),
110 List,
111}
112
113#[component]
115fn AdminCommentsPage(page: i32) -> impl IntoView {
116 let toast = use_toast();
117 let query = use_query_map();
118 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 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#[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#[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("看图  就懂了");
724 assert_eq!(text, "看图 就懂了");
725 assert_eq!(images, ["/uploads/a.webp"]);
726 let (text, images) = split_images("");
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}