1use std::collections::HashSet;
11
12use leptos::prelude::*;
13use leptos::task::spawn_local;
14use leptos_router::{lazy_route, LazyRoute};
15
16use crate::api::posts::{
17 batch_purge_posts, batch_restore_posts, empty_trash, list_deleted_posts, purge_post,
18 restore_post, PostListResponse,
19};
20use crate::components::admin_layout::AdminPageHead;
21use crate::components::empty_state::{EmptyState, EmptyStateAction};
22use crate::components::mascot::Mascot;
23use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
24use crate::components::skeletons::posts_trash_skeleton::PostsTrashTableSkeleton;
25use crate::components::ui::{
26 button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, ConfirmDialog, IconButton,
27 IconName, LoadError, StatusDot, TablePager,
28};
29use crate::hooks::query::use_paginated;
30use crate::models::post::PostListItem;
31use crate::models::settings::TrashSettings;
32use crate::routes::Route;
33use crate::utils::error::failure_message;
34use crate::utils::time::{now_millis, to_local, ymd};
35
36const TRASH_PER_PAGE: i32 = 20;
38const URGENT_DAYS: i64 = 5;
40const DAY_MS: i64 = 86_400_000;
41
42pub struct PostsTrashRoute;
44
45#[lazy_route]
46impl LazyRoute for PostsTrashRoute {
47 fn data() -> Self {
48 Self
49 }
50
51 fn view(_this: Self) -> AnyView {
52 view! { <PostsTrash/> }.into_any()
53 }
54}
55
56fn days_left(deleted_at_ms: i64, retention_days: i32, now_ms: i64) -> i64 {
58 let remaining = deleted_at_ms + retention_days as i64 * DAY_MS - now_ms;
59 if remaining <= 0 {
60 0
61 } else {
62 (remaining + DAY_MS - 1) / DAY_MS
63 }
64}
65
66fn retention_note(settings: Option<&TrashSettings>) -> &'static str {
68 match settings {
69 Some(s) if s.auto_purge_enabled => "到期自动彻底删除。",
70 Some(_) => "自动清理已关闭,文章会一直保留到手动彻底删除。",
71 None => "可随时恢复或彻底删除。",
72 }
73}
74
75#[derive(Clone, PartialEq)]
77enum PurgeTarget {
78 One { id: i32, title: String },
79 Selected(Vec<i32>),
80 All(i64),
81}
82
83impl PurgeTarget {
84 fn description(&self) -> String {
85 const REFS: &str = "被这些文章引用的素材会变为未引用。";
86 match self {
87 PurgeTarget::One { title, .. } => {
88 format!("《{title}》会被彻底删除,无法恢复。它引用的素材会变为未引用。")
89 }
90 PurgeTarget::Selected(ids) => {
91 format!("选中的 {} 篇文章会被彻底删除,无法恢复。{REFS}", ids.len())
92 }
93 PurgeTarget::All(n) => format!("{n} 篇文章会被彻底删除,无法恢复。{REFS}"),
94 }
95 }
96}
97
98#[derive(Clone, Copy, PartialEq)]
100enum ListState {
101 Error,
102 Loading,
103 Empty,
104 Table,
105}
106
107#[component]
109fn PostsTrash() -> impl IntoView {
110 let toast = use_toast();
111 let current_page = RwSignal::new(1);
112 let selected = RwSignal::new(HashSet::<i32>::new());
113 let busy = RwSignal::new(HashSet::<i32>::new());
114 let batch_busy = RwSignal::new(false);
115 let settings = RwSignal::new(None::<TrashSettings>);
116 let confirm_open = RwSignal::new(false);
118 let confirm_all_open = RwSignal::new(false);
119 let pending = RwSignal::new(None::<PurgeTarget>);
120 let refresh = RwSignal::new(0_u32);
121
122 Effect::new(move |_| {
123 spawn_local(async move {
124 if let Ok(s) = crate::api::settings::get_trash_settings().await {
125 settings.try_set(Some(s));
126 }
127 });
128 });
129
130 let paginated = use_paginated(
131 move || {
132 refresh.track();
133 current_page.get()
134 },
135 TRASH_PER_PAGE,
136 |p, pp| async move {
137 list_deleted_posts(p, pp)
138 .await
139 .map(|PostListResponse { posts, total }| (posts, total))
140 .map_err(|e| failure_message("回收站加载失败", &e))
141 },
142 );
143 let posts = paginated.items;
144 let total = paginated.total;
145 let loading = paginated.loading;
146 let error = paginated.error;
147
148 let settle = move |removed: &[i32]| {
150 selected.try_update(|set| removed.iter().for_each(|id| _ = set.remove(id)));
151 let Some((gone, len)) = posts.try_with_untracked(|list| {
152 (
153 list.iter().filter(|p| removed.contains(&p.id)).count(),
154 list.len(),
155 )
156 }) else {
157 return;
158 };
159 if gone >= len && current_page.try_get_untracked().is_some_and(|p| p > 1) {
160 current_page.try_update(|p| *p -= 1);
161 } else {
162 refresh.try_update(|n| *n += 1);
163 }
164 };
165
166 let restore = move |ids: Vec<i32>| {
167 if ids.is_empty() {
168 return;
169 }
170 let single = ids.len() == 1;
171 if single {
172 busy.update(|b| _ = b.insert(ids[0]));
173 } else {
174 batch_busy.set(true);
175 }
176 spawn_local(async move {
177 let result = if single {
178 restore_post(ids[0]).await
179 } else {
180 batch_restore_posts(ids.clone()).await
181 };
182 match result {
183 Ok(res) if res.success => {
184 toast.success(if single {
185 "已恢复,文章回到原来的状态".to_string()
186 } else {
187 res.message
188 });
189 settle(&ids);
190 }
191 Ok(res) => toast.error(res.message),
192 Err(e) => toast.error(failure_message("恢复失败", &e)),
193 }
194 if single {
195 busy.try_update(|b| _ = b.remove(&ids[0]));
196 } else {
197 batch_busy.try_set(false);
198 }
199 });
200 };
201
202 let ask_purge = move |target: PurgeTarget| {
203 let all = matches!(target, PurgeTarget::All(_));
204 pending.set(Some(target));
205 if all {
206 confirm_all_open.set(true);
207 } else {
208 confirm_open.set(true);
209 }
210 };
211
212 let purge = move |_: ()| {
213 let Some(target) = pending.get_untracked() else {
214 return;
215 };
216 spawn_local(async move {
217 let (result, removed) = match &target {
218 PurgeTarget::One { id, .. } => {
219 busy.update(|b| _ = b.insert(*id));
220 (purge_post(*id).await, vec![*id])
221 }
222 PurgeTarget::Selected(ids) => {
223 batch_busy.set(true);
224 (batch_purge_posts(ids.clone()).await, ids.clone())
225 }
226 PurgeTarget::All(_) => {
227 batch_busy.set(true);
228 (empty_trash().await, Vec::new())
229 }
230 };
231 match result {
232 Ok(res) if res.success => {
233 toast.success(match &target {
234 PurgeTarget::One { title, .. } => format!("已彻底删除《{title}》"),
235 PurgeTarget::All(_) => "回收站已清空".to_string(),
236 PurgeTarget::Selected(_) => res.message,
237 });
238 if matches!(target, PurgeTarget::All(_)) {
239 selected.try_update(|s| s.clear());
240 current_page.try_set(1);
241 refresh.try_update(|n| *n += 1);
242 } else {
243 settle(&removed);
244 }
245 }
246 Ok(res) => toast.error(res.message),
247 Err(e) => toast.error(failure_message("彻底删除失败", &e)),
248 }
249 if let PurgeTarget::One { id, .. } = target {
250 busy.try_update(|b| _ = b.remove(&id));
251 } else {
252 batch_busy.try_set(false);
253 }
254 });
255 };
256
257 let on_row_select = Callback::new(move |(id, checked): (i32, bool)| {
258 selected.update(|s| {
259 if checked {
260 s.insert(id);
261 } else {
262 s.remove(&id);
263 }
264 });
265 });
266 let on_row_restore = Callback::new(move |id: i32| restore(vec![id]));
267 let on_row_purge =
268 Callback::new(move |(id, title): (i32, String)| ask_purge(PurgeTarget::One { id, title }));
269
270 let page_all = Memo::new(move |_| {
271 posts.with(|rows| {
272 selected.with(|selection| {
273 !rows.is_empty() && rows.iter().all(|p| selection.contains(&p.id))
274 })
275 })
276 });
277 let retention = Memo::new(move |_| {
279 settings.with(|s| {
280 s.as_ref()
281 .filter(|s| s.auto_purge_enabled)
282 .map(|s| s.retention_days)
283 })
284 });
285 let list_state = Memo::new(move |_| {
286 if error.with(Option::is_some) {
287 ListState::Error
288 } else if posts.with(Vec::is_empty) {
289 if loading.get() {
290 ListState::Loading
291 } else {
292 ListState::Empty
293 }
294 } else {
295 ListState::Table
296 }
297 });
298 let selection_count = Memo::new(move |_| selected.with(HashSet::len));
299 let has_selection = Memo::new(move |_| selection_count.get() > 0);
300 let confirm_description = Signal::derive(move || {
301 pending.with(|p| p.as_ref().map(PurgeTarget::description).unwrap_or_default())
302 });
303
304 view! {
305 <div class="admin-trash">
306 <TrashHead
307 settings=settings
308 total=total
309 batch_busy=batch_busy
310 on_empty=move |_: ()| ask_purge(PurgeTarget::All(total.get_untracked()))
311 />
312
313 {move || {
314 (!loading.get() || error.with(Option::is_some))
315 .then(|| view! { <span hidden data-vt-list="true"></span> })
316 }}
317 {move || match list_state.get() {
318 ListState::Error => {
319 view! {
320 <LoadError
321 title="回收站加载失败"
322 on_retry=move |_: ()| {
323 refresh.update(|n| *n += 1);
324 }
325 />
326 }
327 .into_any()
328 }
329 ListState::Loading => {
330 view! {
331 <DelayedSkeleton>
332 <PostsTrashTableSkeleton/>
333 </DelayedSkeleton>
334 }
335 .into_any()
336 }
337 ListState::Empty => {
338 view! {
339 <div class="admin-panel">
340 {move || {
343 let description = match retention.get() {
344 Some(days) => format!("删除的文章会在这里保留 {days} 天。"),
345 None => "删除的文章会出现在这里,可随时恢复。".to_string(),
346 };
347 view! {
348 <EmptyState
349 mascot=Mascot::Blissful
350 title="回收站是空的"
351 description=description
352 action=EmptyStateAction::new(
353 "返回文章",
354 move |_| {
355 crate::navigation::push(&Route::Posts.to_string());
356 },
357 )
358 />
359 }
360 }}
361 </div>
362 }
363 .into_any()
364 }
365 ListState::Table => {
366 view! {
367 <div
368 class="admin-panel admin-table-wrap"
369 aria-busy=move || loading.get().then_some("true")
370 >
371 <table class="admin-table admin-trash-table">
372 <thead>
373 <tr>
374 <th class="admin-table-check">
375 <Checkbox
376 checked=page_all
377 label="选择本页全部文章".to_string()
378 onchange=move |checked: bool| {
379 let ids: Vec<i32> = posts
380 .with_untracked(|rows| rows.iter().map(|p| p.id).collect());
381 selected
382 .update(|set| {
383 for id in &ids {
384 if checked {
385 set.insert(*id);
386 } else {
387 set.remove(id);
388 }
389 }
390 });
391 }
392 />
393 </th>
394 <th>"标题"</th>
395 <th>"原状态"</th>
396 <th class="admin-trash-deleted">"删除于"</th>
397 {move || {
398 retention
399 .get()
400 .is_some()
401 .then(|| view! { <th class="admin-trash-left">"自动清理"</th> })
402 }}
403 <th class="admin-trash-actions">
404 <span class="sr-only">"操作"</span>
405 </th>
406 </tr>
407 </thead>
408 <tbody>
409 {move || {
410 let retention = retention.get();
411 let now = now_millis();
412 posts
413 .get()
414 .into_iter()
415 .map(|post| {
416 let id = post.id;
417 let left = retention
418 .zip(post.deleted_at)
419 .map(|(days, at)| {
420 (days_left(at.timestamp_millis(), days, now), days)
421 });
422 view! {
423 <TrashRow
424 selected=Signal::derive(move || {
425 selected.with(|s| s.contains(&id))
426 })
427 busy=Signal::derive(move || {
428 batch_busy.get() || busy.with(|b| b.contains(&id))
429 })
430 left=left
431 on_select=on_row_select
432 on_restore=on_row_restore
433 on_purge=on_row_purge
434 post=post
435 />
436 }
437 })
438 .collect_view()
439 }}
440 </tbody>
441 </table>
442 <TablePager
443 page=current_page
444 total=total
445 per_page=TRASH_PER_PAGE
446 unit="篇"
447 on_change=move |page: i32| {
448 selected.update(|s| s.clear());
449 current_page.set(page);
450 }
451 />
452 </div>
453 }
454 .into_any()
455 }
456 }}
457
458 <Show when=move || has_selection.get()>
459 <div class="batch-bar" role="toolbar" aria-label="批量操作">
460 <span class="batch-bar-count">
461 {move || format!("已选 {} 篇", selection_count.get())}
462 </span>
463 <button
464 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
465 type="button"
466 disabled=move || batch_busy.get()
467 on:click=move |_| restore(selected.get_untracked().into_iter().collect())
468 >
469 "批量恢复"
470 </button>
471 <button
472 class=format!(
473 "{} batch-bar-danger",
474 button_class(ButtonVariant::Ghost, ButtonSize::Sm),
475 )
476 type="button"
477 disabled=move || batch_busy.get()
478 on:click=move |_| {
479 ask_purge(
480 PurgeTarget::Selected(selected.get_untracked().into_iter().collect()),
481 )
482 }
483 >
484 "彻底删除"
485 </button>
486 <span class="batch-bar-sep" aria-hidden="true"></span>
487 <IconButton
488 icon=IconName::X
489 label="取消选择"
490 size=ButtonSize::Sm
491 onclick=move |_| selected.update(|s| s.clear())
492 />
493 </div>
494 </Show>
495
496 <ConfirmDialog
497 visible=confirm_open
498 title="彻底删除文章?"
499 description=confirm_description
500 confirm_label="彻底删除"
501 on_confirm=purge
502 />
503 <ConfirmDialog
504 visible=confirm_all_open
505 title="清空回收站?"
506 description=confirm_description
507 confirm_label="彻底删除"
508 on_confirm=purge
509 />
510 </div>
511 }
512}
513
514#[component]
516fn TrashHead(
517 settings: RwSignal<Option<TrashSettings>>,
518 total: RwSignal<i64>,
519 batch_busy: RwSignal<bool>,
520 #[prop(into)] on_empty: Callback<()>,
521) -> impl IntoView {
522 let has_items = Memo::new(move |_| total.get() > 0);
523 let note = move || {
525 view! {
526 {move || {
527 settings
528 .with(|s| {
529 s.as_ref()
530 .filter(|s| s.auto_purge_enabled)
531 .map(|s| format!("已删除的文章保留 {} 天,", s.retention_days))
532 })
533 }}
534 {move || settings.with(|s| retention_note(s.as_ref()))}
535 {move || {
536 settings
537 .with(Option::is_some)
538 .then(|| {
539 view! {
540 <a
541 class="admin-trash-settings"
542 href=Route::SiteSettingsPage.to_string()
543 >
544 "可在设置中修改"
545 </a>
546 "。"
547 }
548 })
549 }}
550 }
551 };
552 move || {
553 if has_items.get() {
554 view! {
555 <AdminPageHead
556 title="回收站"
557 actions=move || {
558 view! {
559 <button
560 class=format!(
561 "{} admin-trash-empty",
562 button_class(ButtonVariant::Outline, ButtonSize::Md),
563 )
564 type="button"
565 disabled=move || batch_busy.get()
566 on:click=move |_| on_empty.run(())
567 >
568 "清空回收站"
569 </button>
570 }
571 }
572 >
573 {note()}
574 </AdminPageHead>
575 }
576 .into_any()
577 } else {
578 view! { <AdminPageHead title="回收站">{note()}</AdminPageHead> }.into_any()
579 }
580 }
581}
582
583#[component]
585fn TrashRow(
586 post: PostListItem,
587 #[prop(into)] selected: Signal<bool>,
588 #[prop(into)] busy: Signal<bool>,
589 left: Option<(i64, i32)>,
590 #[prop(into)] on_select: Callback<(i32, bool)>,
591 #[prop(into)] on_restore: Callback<i32>,
592 #[prop(into)] on_purge: Callback<(i32, String)>,
593) -> impl IntoView {
594 let deleted = post.deleted_at.map(|d| ymd(&to_local(d), '-'));
595 let id = post.id;
596 let title = post.title.clone();
597 let checkbox_label = format!("选择《{}》", post.title);
598 let deleted_cell = match deleted {
599 Some(deleted) => view! { <time>{deleted}</time> }.into_any(),
600 None => view! { <span class="admin-table-muted">"—"</span> }.into_any(),
601 };
602 let left_cell = left.map(|(days, retention)| {
603 let width = (days as f64 / retention.max(1) as f64 * 100.0).min(100.0);
604 view! {
605 <td class="admin-trash-left">
606 <div class="trash-left" data-urgent=(days <= URGENT_DAYS).then_some("true")>
607 <span class="trash-left-track" aria-hidden="true">
608 <span class="trash-left-bar" style=format!("width: {width:.0}%")></span>
609 </span>
610 <span class="trash-left-label">
611 {if days == 0 { "即将清理".to_string() } else { format!("剩 {days} 天") }}
612 </span>
613 </div>
614 </td>
615 }
616 });
617 view! {
618 <tr
619 data-selected=move || selected.get().then_some("true")
620 aria-busy=move || busy.get().then_some("true")
621 >
622 <td class="admin-table-check">
623 <Checkbox
624 checked=selected
625 label=checkbox_label
626 onchange=move |checked: bool| on_select.run((id, checked))
627 />
628 </td>
629 <td class="admin-trash-title">
630 <span class="admin-trash-name">{post.title}</span>
631 <span class="admin-posts-slug">{format!("/{}", post.slug)}</span>
632 </td>
633 <td class="admin-trash-status">
634 <StatusDot label=post.status.label() tone=post.status.tone()/>
635 </td>
636 <td class="admin-trash-deleted">{deleted_cell}</td>
637 {left_cell}
638 <td class="admin-trash-actions">
639 <div>
640 <button
641 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
642 type="button"
643 disabled=move || busy.get()
644 on:click=move |_| on_restore.run(id)
645 >
646 "恢复"
647 </button>
648 <IconButton
649 icon=IconName::Trash
650 label="彻底删除"
651 size=ButtonSize::Sm
652 disabled=busy
653 tooltip_align="end"
654 class="admin-trash-purge"
655 onclick=move |_| on_purge.run((id, title.clone()))
656 />
657 </div>
658 </td>
659 </tr>
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 const NOW: i64 = 1_760_000_000_000;
668
669 #[test]
670 fn days_left_rounds_up_partial_days() {
671 assert_eq!(days_left(NOW, 30, NOW), 30);
673 assert_eq!(days_left(NOW - 3_600_000, 30, NOW), 30);
675 assert_eq!(days_left(NOW - 25 * DAY_MS - 3_600_000, 30, NOW), 5);
677 }
678
679 #[test]
680 fn days_left_is_zero_once_expired() {
681 assert_eq!(days_left(NOW - 30 * DAY_MS, 30, NOW), 0);
682 assert_eq!(days_left(NOW - 40 * DAY_MS, 30, NOW), 0);
683 }
684
685 #[test]
686 fn retention_note_follows_auto_purge() {
687 let on = TrashSettings {
688 auto_purge_enabled: true,
689 retention_days: 30,
690 };
691 let off = TrashSettings {
692 auto_purge_enabled: false,
693 ..on.clone()
694 };
695 assert_eq!(retention_note(Some(&on)), "到期自动彻底删除。");
696 assert!(retention_note(Some(&off)).contains("已关闭"));
697 assert_eq!(retention_note(None), "可随时恢复或彻底删除。");
698 }
699
700 #[test]
701 fn purge_target_describes_scope() {
702 let one = PurgeTarget::One {
703 id: 1,
704 title: "标题".to_string(),
705 };
706 assert!(one.description().starts_with("《标题》"));
707 assert!(PurgeTarget::All(4).description().starts_with("4 篇文章"));
708 assert!(PurgeTarget::Selected(vec![1, 2])
709 .description()
710 .starts_with("选中的 2 篇"));
711 }
712}