Skip to main content

yggdrasil/pages/admin/
posts_trash.rs

1//! 回收站(文章管理下的 tab,由 `posts.rs::Posts` 容器渲染)。
2//!
3//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空,
4//! 以及自动清理配置(启用开关 + 保留天数)。
5//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。
6//!
7//! 本组件作为 `PostsTrashPanel` 被 `/admin/posts` 单路由下的 tab 容器(见
8//! `posts.rs::Posts`)渲染:header 与 tab 栏由容器统一提供,本组件只负责
9//! 回收站列表内容(自动清理配置 + 批量栏 + 列表 + 分页)。翻页走客户端 signal,
10//! 与全部文章 tab 状态通过容器层的 key 化挂载隔离。
11
12use std::collections::HashSet;
13
14use dioxus::prelude::*;
15
16// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
17// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
18#[allow(unused_imports)]
19use crate::api::posts::{
20    batch_purge_posts, batch_restore_posts, empty_trash, list_deleted_posts, purge_post,
21    restore_post, PostListResponse,
22};
23#[allow(unused_imports)]
24use crate::api::settings::{get_trash_settings, update_trash_settings};
25use crate::components::empty_state::EmptyState;
26use crate::components::skeletons::atoms::SkeletonBox;
27use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
28use crate::components::ui::{
29    LoadingButton, Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
30    BTN_DANGER_OUTLINE, BTN_ICON, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED,
31    CHECKBOX_CLASS,
32};
33use crate::hooks::query::use_paginated;
34use crate::models::post::PostListItem;
35use crate::models::settings::TrashSettings;
36
37/// 每页展示的回收站文章数量。
38const TRASH_PER_PAGE: i32 = 20;
39
40/// 回收站 tab 内容:列表 + 批量操作 + 自动清理配置。
41///
42/// 作为 `PostsTrashPanel` 被 `posts.rs::Posts` 容器的 tab match 渲染。翻页用客户端
43/// signal 驱动(`current_page` signal + `use_paginated` 闭包内读取建立依赖),
44/// 不走路由。支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。
45#[allow(unused_mut, unused_variables)]
46#[component]
47pub(super) fn PostsTrashPanel() -> Element {
48    let current_page = use_signal(|| 1);
49    let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
50
51    // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。
52    // 闭包内读取 current_page(.with)建立 reactive 依赖,翻页时自动重新请求。
53    let paginated = use_paginated(
54        move || current_page.with(|p| *p),
55        TRASH_PER_PAGE,
56        |p, pp| async move {
57            list_deleted_posts(p, pp)
58                .await
59                .map(|PostListResponse { posts, total }| (posts, total))
60        },
61    );
62    let mut posts = paginated.items;
63    let mut total = paginated.total;
64    let loading = paginated.loading;
65    let mut error = paginated.error;
66
67    // 自动清理配置:由子组件 AutoPurgeSettings 写入(加载/保存),本组件读取
68    // retention_days 供 TrashRow 的「剩余天数」展示。
69    let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
70
71    // 本地移除一篇文章(乐观更新)。
72    let mut remove_post = move |id: i32| {
73        posts.with_mut(|list| list.retain(|p| p.id != id));
74        total.with_mut(|t| *t = t.saturating_sub(1));
75        selected_ids.with_mut(|s| {
76            s.remove(&id);
77        });
78    };
79
80    rsx! {
81        div { class: "space-y-6",
82            // 自动清理配置卡片(抽取为子组件 AutoPurgeSettings,见文件末尾)。
83            AutoPurgeSettings { settings }
84
85            // 批量操作栏(选中时显示)
86            if !selected_ids().is_empty() {
87                div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
88                    span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" }
89                    button {
90                        class: "{BTN_SOLID_GREEN}",
91                        onclick: move |_| {
92                            let ids: Vec<i32> = selected_ids().iter().copied().collect();
93                            spawn(async move {
94                                let _ = batch_restore_posts(ids).await;
95                            });
96                            for id in selected_ids() {
97                                remove_post(id);
98                            }
99                            selected_ids.set(HashSet::new());
100                        },
101                        "批量恢复"
102                    }
103                    button {
104                        class: "{BTN_SOLID_RED}",
105                        onclick: move |_| {
106                            #[cfg(target_arch = "wasm32")]
107                            {
108                                if web_sys::window()
109                                    .and_then(|w| {
110                                        w.confirm_with_message(
111                                                "确定要彻底删除选中的文章吗?此操作不可恢复。",
112                                            )
113                                            .ok()
114                                    })
115                                    .unwrap_or(false)
116                                {
117                                    let ids: Vec<i32> = selected_ids().iter().copied().collect();
118                                    spawn(async move {
119                                        let _ = batch_purge_posts(ids).await;
120                                    });
121                                    for id in selected_ids() {
122                                        remove_post(id);
123                                    }
124                                    selected_ids.set(HashSet::new());
125                                }
126                            }
127                        },
128                        "批量彻底删除"
129                    }
130                }
131            }
132
133            // 主内容:错误 / 加载骨架 / 空态 / 列表
134            {
135                if error().is_some() {
136                    rsx! {
137                        EmptyState {
138                            title: "加载失败",
139                            description: "获取回收站列表时发生错误,请稍后重试。",
140                        }
141                    }
142                } else if loading() && posts().is_empty() {
143                    rsx! {
144                        DelayedSkeleton {
145                            div { class: "{ADMIN_CARD_CLASS} p-6 space-y-4",
146                                for _ in 0..5 {
147                                    SkeletonBox { class: "h-10 rounded" }
148                                }
149                            }
150                        }
151                    }
152                } else if posts().is_empty() {
153                    rsx! {
154                        EmptyState {
155                            title: "回收站为空",
156                            description: "当前没有被软删除的文章。",
157                        }
158                    }
159                } else {
160                    let list = posts();
161                    let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
162                    let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
163                    rsx! {
164                        div { class: "{ADMIN_TABLE_CLASS}",
165                            div { class: "overflow-x-auto",
166                                table { class: "w-full text-sm",
167                                    thead {
168                                        tr { class: "border-b border-paper-border text-left text-paper-secondary",
169                                            th { class: "px-4 py-3 font-medium w-10",
170                                                input {
171                                                    r#type: "checkbox",
172                                                    class: "{CHECKBOX_CLASS}",
173                                                    checked: all_selected,
174                                                    onchange: {
175                                                        move |_| {
176                                                            let mut s = selected_ids();
177                                                            if all_selected {
178                                                                for id in &all_ids {
179                                                                    s.remove(id);
180                                                                }
181                                                            } else {
182                                                                for id in &all_ids {
183                                                                    s.insert(*id);
184                                                                }
185                                                            }
186                                                            selected_ids.set(s);
187                                                        }
188                                                    },
189                                                }
190                                            }
191                                            th { class: "px-4 py-3 font-medium", "标题" }
192                                            th { class: "px-4 py-3 font-medium whitespace-nowrap", "原状态" }
193                                            th { class: "px-4 py-3 font-medium w-32 whitespace-nowrap", "删除时间" }
194                                            th { class: "px-4 py-3 font-medium w-24 text-center whitespace-nowrap",
195                                                "剩余"
196                                            }
197                                            th { class: "px-4 py-3 font-medium w-32 text-right whitespace-nowrap",
198                                                "操作"
199                                            }
200                                        }
201                                    }
202                                    tbody {
203                                        for post in list.iter() {
204                                            TrashRow {
205                                                key: "{post.id}",
206                                                post: post.clone(),
207                                                retention_days: settings().retention_days,
208                                                selected: selected_ids().contains(&post.id),
209                                                on_select: {
210                                                    let id = post.id;
211                                                    move |checked: bool| {
212                                                        let mut s = selected_ids();
213                                                        if checked {
214                                                            s.insert(id);
215                                                        } else {
216                                                            s.remove(&id);
217                                                        }
218                                                        selected_ids.set(s);
219                                                    }
220                                                },
221                                                on_restore: {
222                                                    let id = post.id;
223                                                    move |_| {
224                                                        spawn(async move {
225                                                            let _ = restore_post(id).await;
226                                                        });
227                                                        remove_post(id);
228                                                    }
229                                                },
230                                                on_purge: {
231                                                    let id = post.id;
232                                                    move |_| {
233                                                        #[cfg(target_arch = "wasm32")]
234                                                        {
235                                                            if web_sys::window()
236                                                                .and_then(|w| {
237                                                                    w
238                                                                        .confirm_with_message(
239                                                                            "确定要彻底删除这篇文章吗?此操作不可恢复。",
240                                                                        )
241                                                                        .ok()
242                                                                })
243                                                                .unwrap_or(false)
244                                                            {
245                                                                spawn(async move {
246                                                                    let _ = purge_post(id).await;
247                                                                });
248                                                                remove_post(id);
249                                                            }
250                                                        }
251                                                    }
252                                                },
253                                            }
254                                        }
255                                    }
256                                }
257                            }
258                        }
259                        // 底部:清空回收站 + 分页
260                        div { class: "flex items-center justify-between mt-4",
261                            button {
262                                class: "{BTN_DANGER_OUTLINE}",
263                                onclick: move |_| {
264                                    #[cfg(target_arch = "wasm32")]
265                                    {
266                                        if web_sys::window()
267                                            .and_then(|w| {
268                                                w.confirm_with_message(
269                                                        "确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。",
270                                                    )
271                                                    .ok()
272                                            })
273                                            .unwrap_or(false)
274                                        {
275                                            spawn(async move {
276                                                let _ = empty_trash().await;
277                                            });
278                                            posts.set(Vec::new());
279                                            total.set(0);
280                                            selected_ids.set(HashSet::new());
281                                        }
282                                    }
283                                },
284                                "清空回收站"
285                            }
286                        }
287                        Pagination {
288                            variant: "admin",
289                            current_page: current_page(),
290                            total: total(),
291                            per_page: TRASH_PER_PAGE,
292                            unit: "篇",
293                            on_prev: {
294                                let mut page = current_page;
295                                move |_| {
296                                    page.with_mut(|p| *p = (*p - 1).max(1));
297                                }
298                            },
299                            on_next: {
300                                let mut page = current_page;
301                                move |_| {
302                                    page.with_mut(|p| *p += 1);
303                                }
304                            },
305                            on_jump: {
306                                let mut page = current_page;
307                                move |p: i32| {
308                                    page.set(p);
309                                }
310                            },
311                        }
312                    }
313                }
314            }
315        }
316    }
317}
318
319/// 自动清理配置子组件:可折叠的设置面板。
320///
321/// 封装自动清理的全部状态:表单草稿(`settings_draft_*`)、面板折叠态、保存态、
322/// 已保存反馈、以及派生的 `dirty` / `chevron_rotate`。配置加载与保存均在组件
323/// 内部完成。`settings`(已保存的服务端配置)由父组件传入双向绑定 signal:
324/// 本组件加载/保存时写入,父组件读取 `retention_days` 供 TrashRow 的「剩余天数」。
325///
326/// 从 `PostsTrashPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
327#[component]
328#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
329fn AutoPurgeSettings(settings: Signal<TrashSettings>) -> Element {
330    let mut settings_draft_days: Signal<String> = use_signal(|| "30".to_string());
331    let mut settings_draft_enabled: Signal<bool> = use_signal(|| false);
332    let mut settings_panel_open: Signal<bool> = use_signal(|| false);
333    let mut saving_settings: Signal<bool> = use_signal(|| false);
334    // 保存成功后的短暂反馈标记(用户再次编辑时清除)。
335    let mut just_saved: Signal<bool> = use_signal(|| false);
336
337    // 首次渲染加载服务端配置:本组件挂载即触发一次,无需 settings_loaded 守卫
338    //(父组件每次翻页重渲染的是列表 effect,本组件 effect 只在自身首次挂载跑)。
339    use_effect(move || {
340        #[cfg(target_arch = "wasm32")]
341        spawn(async move {
342            if let Ok(s) = get_trash_settings().await {
343                settings_draft_days.set(s.retention_days.to_string());
344                settings_draft_enabled.set(s.auto_purge_enabled);
345                settings.set(s);
346            }
347        });
348    });
349
350    // 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
351    // 派生值用 use_memo:依赖信号不变时不重算(避免每次渲染重复 parse 字符串)。
352    let dirty = use_memo(move || {
353        settings_draft_enabled() != settings().auto_purge_enabled
354            || settings_draft_days()
355                .trim()
356                .parse::<i32>()
357                .ok()
358                .map(|d| d != settings().retention_days)
359                .unwrap_or(true)
360    });
361    // 折叠箭头旋转类(展开时翻转 180°)。
362    let chevron_rotate = if settings_panel_open() {
363        "rotate-180"
364    } else {
365        ""
366    };
367
368    rsx! {
369        div { class: "rounded-2xl border border-paper-border overflow-hidden bg-paper-entry",
370            // 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头
371            button {
372                class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-paper-theme focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40",
373                onclick: move |_| {
374                    settings_panel_open.set(!settings_panel_open());
375                    just_saved.set(false);
376                },
377                // 状态指示灯
378                {
379                    let dot_class = if settings().auto_purge_enabled {
380                        "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(64,160,43,0.15)]"
381                    } else {
382                        "w-2 h-2 rounded-full bg-paper-tertiary"
383                    };
384                    rsx! {
385                        div { class: "w-2 flex-shrink-0 flex items-center justify-center",
386                            div { class: "{dot_class}" }
387                        }
388                    }
389                }
390                // 标题 + 当前状态描述
391                div { class: "flex-1 min-w-0",
392                    div { class: "text-sm font-medium text-paper-primary", "自动清理" }
393                    div { class: "text-xs text-paper-secondary mt-0.5 truncate",
394                        if settings().auto_purge_enabled {
395                            "已开启 · 超过 {settings().retention_days} 天的文章将被自动删除"
396                        } else {
397                            "已关闭"
398                        }
399                    }
400                }
401                // 展开箭头(旋转动画)
402                svg {
403                    class: "w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
404                    view_box: "0 0 24 24",
405                    fill: "none",
406                    stroke: "currentColor",
407                    stroke_width: "2",
408                    path {
409                        stroke_linecap: "round",
410                        stroke_linejoin: "round",
411                        d: "M19 9l-7 7-7-7",
412                    }
413                }
414            }
415
416            // 设置面板(可折叠带平滑动画)
417            div {
418                class: "grid transition-all duration-300 ease-in-out",
419                style: if settings_panel_open() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
420                div { class: "overflow-hidden min-h-0",
421                    div { class: "border-t border-paper-border p-5 space-y-6",
422                        // 开关行:启用自动清理
423                        div { class: "flex items-center justify-between gap-4",
424                            div { class: "min-w-0",
425                                div { class: "text-sm font-medium text-paper-primary",
426                                    "启用自动清理"
427                                }
428                                div { class: "text-xs text-paper-secondary mt-1",
429                                    "后台任务定期彻底删除超过保留期的文章"
430                                }
431                            }
432                            // 自定义开关(toggle switch)—— 取代原生 checkbox
433                            button {
434                                role: "switch",
435                                aria_checked: "{settings_draft_enabled()}",
436                                class: if settings_draft_enabled() { "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-accent cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" } else { "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-tertiary cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" },
437                                onclick: move |_| {
438                                    settings_draft_enabled.set(!settings_draft_enabled());
439                                    just_saved.set(false);
440                                },
441                                // 滑块圆点
442                                span { class: if settings_draft_enabled() { "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5" } else { "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200" } }
443                            }
444                        }
445
446                        // 保留天数行
447                        div { class: "space-y-3",
448                            div { class: "min-w-0",
449                                div { class: "text-sm font-medium text-paper-primary",
450                                    "保留天数"
451                                }
452                                div { class: "text-xs text-paper-secondary mt-1",
453                                    "文章删除后保留的时长,到期后自动彻底清除(1–365)"
454                                }
455                            }
456                            // 数字输入 + 步进按钮 + 单位后缀
457                            div { class: "flex items-center gap-3",
458                                div { class: "flex items-center rounded-lg border border-paper-border bg-paper-entry overflow-hidden",
459                                    // 减号
460                                    button {
461                                        class: "{BTN_ICON}",
462                                        r#type: "button",
463                                        aria_label: "减少保留天数",
464                                        onclick: move |_| {
465                                            let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
466                                            let next = cur.saturating_sub(1).max(1);
467                                            settings_draft_days.set(next.to_string());
468                                            just_saved.set(false);
469                                        },
470                                        "−"
471                                    }
472                                    // 数字输入(无边框,衔接步进按钮)
473                                    input {
474                                        r#type: "number",
475                                        min: "1",
476                                        max: "365",
477                                        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",
478                                        value: "{settings_draft_days()}",
479                                        oninput: move |e| {
480                                            settings_draft_days.set(e.value());
481                                            just_saved.set(false);
482                                        },
483                                    }
484                                    // 加号
485                                    button {
486                                        class: "{BTN_ICON}",
487                                        r#type: "button",
488                                        aria_label: "增加保留天数",
489                                        onclick: move |_| {
490                                            let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
491                                            let next = cur.saturating_add(1).min(365);
492                                            settings_draft_days.set(next.to_string());
493                                            just_saved.set(false);
494                                        },
495                                        "+"
496                                    }
497                                }
498                                span { class: "text-xs text-paper-secondary", "天" }
499                            }
500                        }
501
502                        // 底部操作行:未保存提示 + 保存按钮
503                        div { class: "flex items-center justify-between gap-4 pt-1",
504                            // 草稿状态提示
505                            if just_saved() {
506                                span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent",
507                                    svg {
508                                        class: "w-3.5 h-3.5",
509                                        view_box: "0 0 24 24",
510                                        fill: "none",
511                                        stroke: "currentColor",
512                                        stroke_width: "2.5",
513                                        path {
514                                            stroke_linecap: "round",
515                                            stroke_linejoin: "round",
516                                            d: "M5 13l4 4L19 7",
517                                        }
518                                    }
519                                    "已保存"
520                                }
521                            } else if dirty() {
522                                span { class: "text-xs text-paper-secondary", "有未保存的更改" }
523                            } else {
524                                span { class: "text-xs text-transparent select-none",
525                                    "·"
526                                }
527                            }
528                            // 保存按钮:主题绿主操作,saving 态显示 spinner,just_saved/无改动禁用
529                            LoadingButton {
530                                label: "保存设置".to_string(),
531                                loading: saving_settings(),
532                                disabled: just_saved() || !dirty(),
533                                variant: "sm",
534                                onclick: move |_| {
535                                    let days: i32 = settings_draft_days().parse().unwrap_or(30);
536                                    let enabled = settings_draft_enabled();
537                                    saving_settings.set(true);
538                                    spawn(async move {
539                                        if let Ok(s) = update_trash_settings(enabled, days).await {
540                                            settings.set(s);
541                                            just_saved.set(true);
542                                        }
543                                        saving_settings.set(false);
544                                    });
545                                },
546                            }
547                        }
548                    }
549                }
550            }
551        }
552    }
553}
554
555/// 计算剩余天数(保留期 - 已删除天数)。
556///
557/// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。
558fn remaining_days(post: &PostListItem, retention_days: i32) -> (i64, bool) {
559    #[cfg(target_arch = "wasm32")]
560    {
561        if let Some(deleted_at) = post.deleted_at {
562            let now_ms = js_sys::Date::now() as i64; // 毫秒
563            let deleted_ms = deleted_at.timestamp_millis();
564            let elapsed_days = (now_ms - deleted_ms) / 86_400_000;
565            let remaining = retention_days as i64 - elapsed_days;
566            (remaining, remaining <= 0)
567        } else {
568            (retention_days as i64, false)
569        }
570    }
571    #[cfg(not(target_arch = "wasm32"))]
572    {
573        let _ = post;
574        (retention_days as i64, false)
575    }
576}
577
578/// 回收站表格行组件。
579#[component]
580fn TrashRow(
581    post: PostListItem,
582    retention_days: i32,
583    selected: bool,
584    on_select: EventHandler<bool>,
585    on_restore: EventHandler,
586    on_purge: EventHandler,
587) -> Element {
588    let (remaining, expired) = remaining_days(&post, retention_days);
589    // 剩余天数徽章配色:>7 天中性,≤7 天鼠尾草绿(主题色),≤0/过期琥珀色。
590    let badge_class = if expired {
591        "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
592    } else if remaining <= 7 {
593        "bg-paper-accent-soft text-paper-accent"
594    } else {
595        "bg-paper-tertiary text-paper-secondary"
596    };
597    let badge_text = if expired {
598        "待清理".to_string()
599    } else {
600        format!("{remaining}天")
601    };
602    let deleted_str = post
603        .deleted_at
604        .map(|d| d.format("%Y-%m-%d").to_string())
605        .unwrap_or_else(|| "—".to_string());
606
607    rsx! {
608        tr { class: "{ADMIN_ROW_HOVER}",
609            td { class: "px-4 py-3",
610                input {
611                    r#type: "checkbox",
612                    class: "{CHECKBOX_CLASS}",
613                    checked: selected,
614                    onchange: move |e| on_select.call(e.checked()),
615                }
616            }
617            td { class: "px-4 py-3",
618                div { class: "text-sm font-medium text-paper-primary truncate max-w-xs",
619                    "{post.title}"
620                }
621            }
622            td { class: "px-4 py-3 whitespace-nowrap",
623                StatusBadge {
624                    color_class: post.status_badge_class(),
625                    label: post.status_label().to_string(),
626                }
627            }
628            td { class: "px-4 py-3 text-sm text-paper-secondary whitespace-nowrap",
629                "{deleted_str}"
630            }
631            td { class: "px-4 py-3 text-center whitespace-nowrap",
632                StatusBadge { color_class: badge_class, label: badge_text }
633            }
634            td { class: "px-4 py-3 text-right whitespace-nowrap",
635                div { class: "flex justify-end gap-2",
636                    button {
637                        class: "{BTN_TEXT_ACCENT}",
638                        onclick: move |_| on_restore.call(()),
639                        "恢复"
640                    }
641                    button {
642                        class: "{BTN_TEXT_RED}",
643                        onclick: move |_| on_purge.call(()),
644                        "彻底删除"
645                    }
646                }
647            }
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    #[test]
655    fn test_auto_purge_settings_has_transition_class() {
656        let full_code = include_str!("posts_trash.rs");
657        let code = full_code.split("#[cfg(test)]").next().unwrap_or(full_code);
658        assert!(code.contains("grid transition-all duration-300 ease-in-out"));
659        assert!(code.contains("grid-template-rows: 1fr; opacity: 1;"));
660        assert!(code.contains("grid-template-rows: 0fr; opacity: 0;"));
661    }
662}