Skip to main content

yggdrasil/pages/admin/
posts_trash.rs

1//! 文章回收站(`/admin/posts/trash`,面包屑「文章 / 回收站」)。
2//!
3//! 标题行(保留期说明、清空回收站)→ 表格(勾选、标题与 slug、原状态、删除日期、
4//! 自动清理剩余天数、恢复与彻底删除)与底栏分页。勾选后底部浮起批量操作条。
5//! 剩余天数由 `deleted_at` 与保留天数计算,5 天内以 Clay 强调;自动清理关闭时整列
6//! 不显示。彻底删除与清空都经危险确认对话框。自动清理的开关与天数在设置页修改。
7//! 数据仅在 WASM 前端通过 server function 加载(后台数据是私有的,SSR 不取数)。
8//! 样式见 styles/pages/admin-trash.css。
9
10use 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
36/// 每页展示的回收站文章数量。
37const TRASH_PER_PAGE: i32 = 20;
38/// 剩余天数不超过它时以 Clay 强调。
39const URGENT_DAYS: i64 = 5;
40const DAY_MS: i64 = 86_400_000;
41
42/// `/admin/posts/trash` 路由:纯客户端数据,`data()` 不取数。
43pub 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
56/// 距自动清理还剩几天:向上取整,已到期为 0(后台任务每小时清理一次)。
57fn 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
66/// 标题下的说明;配置未取到时不提保留天数。
67fn 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/// 等待确认的彻底删除。
76#[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/// 列表区域的四种互斥状态。
99#[derive(Clone, Copy, PartialEq)]
100enum ListState {
101    Error,
102    Loading,
103    Empty,
104    Table,
105}
106
107/// 回收站页。
108#[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    // 两种确认对话框标题不同,各用一个对话框,避免按目标重建(标题不是响应式 prop)。
117    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    // 操作后取消这些文章的勾选并重新请求;本页全部移走且不是第 1 页时退回上一页。
149    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    // 自动清理开启时的保留天数;关闭或配置未取到时为 None。
278    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                            // 说明文字依赖保留天数(配置异步到达),EmptyState 的 description 不是响应式 prop,
341                            // 配置变化时按新文案重建。
342                            {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/// 标题行:保留期说明与「清空回收站」。回收站为空时不渲染操作区,故按「有无文章」切换两种结构。
515#[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    // 说明文字在两种结构里相同。
524    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/// 表格的一行。`left` 为(剩余天数,保留天数),自动清理关闭时为 None。
584#[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        // 刚删除:整段保留期。
672        assert_eq!(days_left(NOW, 30, NOW), 30);
673        // 删除 1 小时:仍按 30 天显示。
674        assert_eq!(days_left(NOW - 3_600_000, 30, NOW), 30);
675        // 删除 25 天又 1 小时:还剩不到 5 天,显示 5。
676        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}