1#[allow(unused_imports)]
8use std::collections::HashSet;
9
10use crate::router::Route;
11use dioxus::prelude::*;
12use dioxus::router::components::Link;
13#[allow(unused_imports)]
16use crate::api::posts::{
17 batch_purge_posts, batch_restore_posts, empty_trash, list_deleted_posts, purge_post,
18 restore_post, PostListResponse,
19};
20#[allow(unused_imports)]
21use crate::api::settings::{get_trash_settings, update_trash_settings};
22use crate::components::empty_state::EmptyState;
23use crate::components::forms::ToggleSwitch;
24use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
25use crate::components::skeletons::posts_trash_skeleton::PostsTrashTableSkeleton;
26use crate::components::ui::{
27 Checkbox, CollapsibleSettingsCard, LoadingButton, Pagination, Popover, BTN_DANGER_OUTLINE,
28 BTN_GHOST, BTN_ICON, BTN_SOLID_GREEN, BTN_SOLID_RED,
29};
30use crate::hooks::query::use_paginated;
31use crate::models::post::PostListItem;
32use crate::models::settings::TrashSettings;
33const TRASH_PER_PAGE: i32 = 20;
35
36#[allow(unused_mut, unused_variables)]
43#[component]
44pub fn PostsTrash() -> Element {
45 let current_page = use_signal(|| 1);
46 let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
47
48 let paginated = use_paginated(
51 move || current_page.with(|p| *p),
52 TRASH_PER_PAGE,
53 |p, pp| async move {
54 list_deleted_posts(p, pp)
55 .await
56 .map(|PostListResponse { posts, total }| (posts, total))
57 },
58 );
59 let mut posts = paginated.items;
60 let mut total = paginated.total;
61 let loading = paginated.loading;
62 let mut error = paginated.error;
63
64 let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
67
68 let mut remove_post = move |id: i32| {
70 posts.with_mut(|list| list.retain(|p| p.id != id));
71 total.with_mut(|t| *t = t.saturating_sub(1));
72 selected_ids.with_mut(|s| {
73 s.remove(&id);
74 });
75 };
76
77 let subtitle = if total() > 0 || !loading() {
79 format!("已删除文章 ({})", total())
80 } else {
81 "已删除文章".to_string()
82 };
83
84 rsx! {
85 div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-6",
86 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",
88 div {
89 h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
90 "回收站"
91 }
92 p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
93 "{subtitle} · 可随时恢复或彻底删除"
94 }
95 }
96 div { class: "flex items-center gap-3",
97 Link {
98 class: "inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
99 to: Route::Posts {},
100 svg {
101 class: "w-4 h-4",
102 xmlns: "http://www.w3.org/2000/svg",
103 view_box: "0 0 24 24",
104 fill: "none",
105 stroke: "currentColor",
106 stroke_width: "2",
107 stroke_linecap: "round",
108 stroke_linejoin: "round",
109 path { d: "M19 12H5M12 19l-7-7 7-7" }
110 }
111 "全部文章"
112 }
113 if total() > 0 {
114 button {
115 class: "{BTN_DANGER_OUTLINE} inline-flex items-center gap-1.5",
116 onclick: move |_| {
117 #[cfg(target_arch = "wasm32")]
118 {
119 if web_sys::window()
120 .and_then(|w| {
121 w.confirm_with_message(
122 "确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。",
123 )
124 .ok()
125 })
126 .unwrap_or(false)
127 {
128 spawn(async move {
129 let _ = empty_trash().await;
130 });
131 posts.set(Vec::new());
132 total.set(0);
133 selected_ids.set(HashSet::new());
134 }
135 }
136 },
137 svg {
138 class: "w-4 h-4",
139 xmlns: "http://www.w3.org/2000/svg",
140 view_box: "0 0 24 24",
141 fill: "none",
142 stroke: "currentColor",
143 stroke_width: "2",
144 stroke_linecap: "round",
145 stroke_linejoin: "round",
146 polyline { points: "3 6 5 6 21 6" }
147 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" }
148 }
149 "清空回收站"
150 }
151 }
152 }
153 }
154
155 div { class: "space-y-6",
156 AutoPurgeSettings { settings }
158
159 if !selected_ids().is_empty() {
161 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",
162 div { class: "flex items-center gap-2 text-sm font-medium text-[var(--color-paper-primary)]",
163 span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
164 span { "已选中 {selected_ids().len()} 篇文章" }
165 }
166 div { class: "flex items-center gap-2",
167 button {
168 class: "{BTN_SOLID_GREEN} inline-flex items-center gap-1.5",
169 onclick: move |_| {
170 let ids: Vec<i32> = selected_ids().iter().copied().collect();
171 spawn(async move {
172 let _ = batch_restore_posts(ids).await;
173 });
174 for id in selected_ids() {
175 remove_post(id);
176 }
177 selected_ids.set(HashSet::new());
178 },
179 svg {
180 class: "w-3.5 h-3.5",
181 xmlns: "http://www.w3.org/2000/svg",
182 view_box: "0 0 24 24",
183 fill: "none",
184 stroke: "currentColor",
185 stroke_width: "2",
186 stroke_linecap: "round",
187 stroke_linejoin: "round",
188 polyline { points: "1 4 1 10 7 10" }
189 path { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" }
190 }
191 "批量恢复"
192 }
193 button {
194 class: "{BTN_SOLID_RED} inline-flex items-center gap-1.5",
195 onclick: move |_| {
196 #[cfg(target_arch = "wasm32")]
197 {
198 if web_sys::window()
199 .and_then(|w| {
200 w.confirm_with_message(
201 "确定要彻底删除选中的文章吗?此操作不可恢复。",
202 )
203 .ok()
204 })
205 .unwrap_or(false)
206 {
207 let ids: Vec<i32> = selected_ids().iter().copied().collect();
208 spawn(async move {
209 let _ = batch_purge_posts(ids).await;
210 });
211 for id in selected_ids() {
212 remove_post(id);
213 }
214 selected_ids.set(HashSet::new());
215 }
216 }
217 },
218 svg {
219 class: "w-3.5 h-3.5",
220 xmlns: "http://www.w3.org/2000/svg",
221 view_box: "0 0 24 24",
222 fill: "none",
223 stroke: "currentColor",
224 stroke_width: "2",
225 stroke_linecap: "round",
226 stroke_linejoin: "round",
227 polyline { points: "3 6 5 6 21 6" }
228 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" }
229 }
230 "批量彻底删除"
231 }
232 button {
233 class: "{BTN_GHOST}",
234 onclick: move |_| selected_ids.set(HashSet::new()),
235 "取消"
236 }
237 }
238 }
239 }
240 {
242 if error().is_some() {
243 rsx! {
244 EmptyState {
245 title: "加载失败",
246 description: "获取回收站列表时发生错误,请稍后重试。",
247 }
248 }
249 } else if loading() && posts().is_empty() {
250 rsx! {
251 DelayedSkeleton {
252 PostsTrashTableSkeleton {}
253 }
254 }
255 } else if posts().is_empty() {
256 rsx! {
257 EmptyState {
258 title: "回收站为空",
259 description: "当前没有被软删除的文章。",
260 }
261 }
262 } else {
263 let list = posts();
264 let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
265 let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
266 rsx! {
267 div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
268 div { class: "overflow-x-auto overflow-y-hidden",
269 table { class: "w-full text-sm",
270 thead {
271 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",
272 th { class: "px-4 py-3.5 w-10 text-center",
273 Checkbox {
274 checked: all_selected,
275 onchange: move |_checked: bool| {
276 let mut s = selected_ids();
277 if all_selected {
278 for id in &all_ids {
279 s.remove(id);
280 }
281 } else {
282 for id in &all_ids {
283 s.insert(*id);
284 }
285 }
286 selected_ids.set(s);
287 },
288 }
289 }
290 th { class: "px-5 py-3.5 font-semibold", "文章标题" }
291 th { class: "px-4 py-3.5 font-semibold whitespace-nowrap text-center", "原发布状态" }
292 th { class: "px-4 py-3.5 font-semibold w-32 whitespace-nowrap", "删除日期" }
293 th { class: "px-4 py-3.5 font-semibold w-24 text-center whitespace-nowrap",
294 "剩余保留"
295 }
296 th { class: "px-5 py-3.5 font-semibold w-36 text-right whitespace-nowrap",
297 "操作"
298 }
299 }
300 }
301 tbody {
302 for (idx, post) in list.iter().enumerate() {
303 TrashRow {
304 key: "{post.id}",
305 post: post.clone(),
306 retention_days: settings().retention_days,
307 selected: selected_ids().contains(&post.id),
308 stagger_index: idx as u32,
309 on_select: {
310 let id = post.id;
311 move |checked: bool| {
312 let mut s = selected_ids();
313 if checked {
314 s.insert(id);
315 } else {
316 s.remove(&id);
317 }
318 selected_ids.set(s);
319 }
320 },
321 on_restore: {
322 let id = post.id;
323 move |_| {
324 spawn(async move {
325 let _ = restore_post(id).await;
326 });
327 remove_post(id);
328 }
329 },
330 on_purge: {
331 let id = post.id;
332 move |_| {
333 #[cfg(target_arch = "wasm32")]
334 spawn(async move {
335 let _ = purge_post(id).await;
336 });
337 remove_post(id);
338 }
339 },
340 }
341 }
342 }
343 }
344 }
345 }
346 Pagination::<Route> {
347 variant: "admin",
348 current_page: current_page(),
349 total: total(),
350 per_page: TRASH_PER_PAGE,
351 unit: "篇",
352 on_prev: {
353 let mut page = current_page;
354 move |_| {
355 page.with_mut(|p| *p = (*p - 1).max(1));
356 }
357 },
358 on_next: {
359 let mut page = current_page;
360 move |_| {
361 page.with_mut(|p| *p += 1);
362 }
363 },
364 on_jump: {
365 let mut page = current_page;
366 move |p: i32| {
367 page.set(p);
368 }
369 },
370 }
371 }
372 }
373 }
374 }
375 }
376 }
377}
378
379#[component]
389#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
390fn AutoPurgeSettings(settings: Signal<TrashSettings>) -> Element {
391 let mut settings_draft_days: Signal<String> = use_signal(|| "30".to_string());
392 let mut settings_draft_enabled: Signal<bool> = use_signal(|| false);
393 let mut saving_settings: Signal<bool> = use_signal(|| false);
394 let mut just_saved: Signal<bool> = use_signal(|| false);
396
397 use_effect(move || {
400 #[cfg(target_arch = "wasm32")]
401 spawn(async move {
402 if let Ok(s) = get_trash_settings().await {
403 settings_draft_days.set(s.retention_days.to_string());
404 settings_draft_enabled.set(s.auto_purge_enabled);
405 settings.set(s);
406 }
407 });
408 });
409
410 let dirty = use_memo(move || {
413 settings_draft_enabled() != settings().auto_purge_enabled
414 || settings_draft_days()
415 .trim()
416 .parse::<i32>()
417 .ok()
418 .map(|d| d != settings().retention_days)
419 .unwrap_or(true)
420 });
421
422 rsx! {
423 CollapsibleSettingsCard {
424 title: "自动清理".to_string(),
425 summary: if settings().auto_purge_enabled { format!(
426 "已开启 · 超过 {} 天的文章将被自动删除",
427 settings().retention_days,
428 ) } else { "已关闭".to_string() },
429 enabled: settings().auto_purge_enabled,
430 on_toggle: move |_| just_saved.set(false),
431 div { class: "border-t border-paper-border p-5 space-y-6",
432 div { class: "flex items-center justify-between gap-4",
434 div { class: "min-w-0",
435 div { class: "text-sm font-medium text-paper-primary", "启用自动清理" }
436 div { class: "text-xs text-paper-secondary mt-1",
437 "后台任务定期彻底删除超过保留期的文章"
438 }
439 }
440 ToggleSwitch {
441 checked: settings_draft_enabled(),
442 ontoggle: move |_| {
443 settings_draft_enabled.set(!settings_draft_enabled());
444 just_saved.set(false);
445 },
446 }
447 }
448
449 div { class: "space-y-3",
451 div { class: "min-w-0",
452 div { class: "text-sm font-medium text-paper-primary", "保留天数" }
453 div { class: "text-xs text-paper-secondary mt-1",
454 "文章删除后保留的时长,到期后自动彻底清除(1–365)"
455 }
456 }
457 div { class: "flex items-center gap-3",
459 div { class: "flex items-center rounded-lg border border-paper-border bg-paper-entry overflow-hidden",
460 button {
462 class: "{BTN_ICON}",
463 r#type: "button",
464 aria_label: "减少保留天数",
465 onclick: move |_| {
466 let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
467 let next = cur.saturating_sub(1).max(1);
468 settings_draft_days.set(next.to_string());
469 just_saved.set(false);
470 },
471 "−"
472 }
473 input {
475 r#type: "number",
476 min: "1",
477 max: "365",
478 class: "w-14 h-9 px-1 text-center text-sm tabular-nums text-paper-primary bg-transparent border-0 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
479 value: "{settings_draft_days()}",
480 oninput: move |e| {
481 settings_draft_days.set(e.value());
482 just_saved.set(false);
483 },
484 }
485 button {
487 class: "{BTN_ICON}",
488 r#type: "button",
489 aria_label: "增加保留天数",
490 onclick: move |_| {
491 let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
492 let next = cur.saturating_add(1).min(365);
493 settings_draft_days.set(next.to_string());
494 just_saved.set(false);
495 },
496 "+"
497 }
498 }
499 span { class: "text-xs text-paper-secondary", "天" }
500 }
501 }
502
503 div { class: "flex items-center justify-between gap-4 pt-1",
505 if just_saved() {
507 span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent",
508 svg {
509 class: "w-3.5 h-3.5",
510 view_box: "0 0 24 24",
511 fill: "none",
512 stroke: "currentColor",
513 stroke_width: "2.5",
514 path {
515 stroke_linecap: "round",
516 stroke_linejoin: "round",
517 d: "M5 13l4 4L19 7",
518 }
519 }
520 "已保存"
521 }
522 } else if dirty() {
523 span { class: "text-xs text-paper-secondary", "有未保存的更改" }
524 } else {
525 span { class: "text-xs text-transparent select-none", "·" }
526 }
527 LoadingButton {
529 label: "保存设置".to_string(),
530 loading: saving_settings(),
531 disabled: just_saved() || !dirty(),
532 variant: "sm",
533 onclick: move |_| {
534 let days: i32 = settings_draft_days().parse().unwrap_or(30);
535 let enabled = settings_draft_enabled();
536 saving_settings.set(true);
537 spawn(async move {
538 if let Ok(s) = update_trash_settings(enabled, days).await {
539 settings.set(s);
540 just_saved.set(true);
541 }
542 saving_settings.set(false);
543 });
544 },
545 }
546 }
547 }
548 }
549 }
550}
551
552fn remaining_days(post: &PostListItem, retention_days: i32) -> (i64, bool) {
556 #[cfg(target_arch = "wasm32")]
557 {
558 if let Some(deleted_at) = post.deleted_at {
559 let now_ms = js_sys::Date::now() as i64; let deleted_ms = deleted_at.timestamp_millis();
561 let elapsed_days = (now_ms - deleted_ms) / 86_400_000;
562 let remaining = retention_days as i64 - elapsed_days;
563 (remaining, remaining <= 0)
564 } else {
565 (retention_days as i64, false)
566 }
567 }
568 #[cfg(not(target_arch = "wasm32"))]
569 {
570 let _ = post;
571 (retention_days as i64, false)
572 }
573}
574
575#[component]
577fn TrashRow(
578 post: PostListItem,
579 retention_days: i32,
580 selected: bool,
581 stagger_index: u32,
582 on_select: EventHandler<bool>,
583 on_restore: EventHandler,
584 on_purge: EventHandler,
585) -> Element {
586 let (remaining, expired) = remaining_days(&post, retention_days);
587 let mut purge_open = use_signal(|| false);
589 let mut anchor_x = use_signal(|| 0i32);
590 let mut anchor_y = use_signal(|| 0i32);
591 let badge_class = if expired {
593 "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
594 } else if remaining <= 7 {
595 "bg-paper-accent-soft text-paper-accent"
596 } else {
597 "bg-paper-tertiary text-paper-secondary"
598 };
599 let badge_text = if expired {
600 "待清理".to_string()
601 } else {
602 format!("{remaining}天")
603 };
604 let deleted_str = post
605 .deleted_at
606 .map(|d| d.format("%Y-%m-%d").to_string())
607 .unwrap_or_else(|| "—".to_string());
608
609 rsx! {
610 tr {
611 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",
612 style: "animation-delay: {stagger_index * 35}ms",
613 td { class: "px-4 py-3.5 text-center",
614 Checkbox {
615 checked: selected,
616 onchange: move |checked: bool| on_select.call(checked),
617 }
618 }
619 td { class: "px-5 py-3.5",
620 div { class: "flex flex-col gap-1",
621 div { class: "font-semibold text-sm text-[var(--color-paper-primary)] leading-snug line-clamp-1",
622 "{post.title}"
623 }
624 div { class: "flex items-center gap-2 text-xs",
625 span { class: "font-mono text-[11px] text-[var(--color-paper-tertiary)]",
626 "/post/{post.slug}"
627 }
628 }
629 }
630 }
631 td { class: "px-4 py-3.5 text-center whitespace-nowrap",
632 if post.status == crate::models::post::PostStatus::Published {
633 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",
634 span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
635 "公开"
636 }
637 } else {
638 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",
639 span { class: "w-1.5 h-1.5 rounded-full bg-amber-500" }
640 "草稿"
641 }
642 }
643 }
644 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
645 "{deleted_str}"
646 }
647 td { class: "px-4 py-3.5 text-center whitespace-nowrap",
648 span { class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border {badge_class}",
649 "{badge_text}"
650 }
651 }
652 td { class: "px-5 py-3.5 text-right whitespace-nowrap",
653 div { class: "flex justify-end items-center gap-2",
654 button {
655 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",
656 onclick: move |_| on_restore.call(()),
657 svg {
658 class: "w-3.5 h-3.5",
659 xmlns: "http://www.w3.org/2000/svg",
660 view_box: "0 0 24 24",
661 fill: "none",
662 stroke: "currentColor",
663 stroke_width: "2",
664 stroke_linecap: "round",
665 stroke_linejoin: "round",
666 polyline { points: "1 4 1 10 7 10" }
667 path { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" }
668 }
669 "恢复"
670 }
671 button {
672 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",
673 onclick: move |e| {
674 let coordinates = e.client_coordinates();
675 anchor_x.set(coordinates.x as i32);
676 anchor_y.set(coordinates.y as i32);
677 purge_open.set(true);
678 },
679 svg {
680 class: "w-3.5 h-3.5",
681 xmlns: "http://www.w3.org/2000/svg",
682 view_box: "0 0 24 24",
683 fill: "none",
684 stroke: "currentColor",
685 stroke_width: "2",
686 stroke_linecap: "round",
687 stroke_linejoin: "round",
688 polyline { points: "3 6 5 6 21 6" }
689 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" }
690 }
691 "彻底删除"
692 }
693 }
694 }
695 Popover {
696 open: purge_open(),
697 anchor_x: anchor_x(),
698 anchor_y: anchor_y(),
699 placement: "bottom",
700 align: "end",
701 on_close: move |_| purge_open.set(false),
702 div { class: "w-64 space-y-3",
703 p { class: "text-sm text-paper-primary leading-relaxed",
704 "彻底删除这篇文章?此操作不可恢复。"
705 }
706 div { class: "flex justify-end gap-2 pt-1",
707 button {
708 class: "{BTN_GHOST}",
709 onclick: move |_| purge_open.set(false),
710 "取消"
711 }
712 button {
713 class: "{BTN_DANGER_OUTLINE}",
714 onclick: move |_| {
715 purge_open.set(false);
716 on_purge.call(());
717 },
718 "确认删除"
719 }
720 }
721 }
722 }
723 }
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 #[test]
730 fn settings_pages_use_shared_collapsible_card() {
731 for (page, source) in [
733 ("posts_trash", include_str!("posts_trash.rs")),
734 ("system/backup", include_str!("system/backup.rs")),
735 ] {
736 let page_code = source.split("#[cfg(test)]").next().unwrap();
738 assert!(
739 page_code.contains("CollapsibleSettingsCard {"),
740 "{page} should render the shared collapsible settings card"
741 );
742 }
743 }
744}