1use std::collections::HashSet;
7
8use dioxus::prelude::*;
9use dioxus::router::components::Link;
10
11#[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
27const COMMENTS_PER_PAGE: i32 = 20;
29
30#[component]
32pub fn AdminComments() -> Element {
33 rsx! {
34 AdminCommentsPage { page: 1 }
35 }
36}
37
38#[component]
42pub fn AdminCommentsPage(page: i32) -> Element {
43 let current_page = page.max(1);
44 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 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 #[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 use_effect(move || {
86 let _ = active_filter();
87 let _ = current_page;
88
89 #[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 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 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 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#[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 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 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 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 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 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
509 "{date_str}"
510 }
511 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}