Skip to main content

yggdrasil/pages/admin/
posts_trash.rs

1//! 回收站页面(`/admin/posts/trash` 独立路由,属侧边栏「内容管理」子菜单)。
2//!
3//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空,
4//! 以及自动清理配置(启用开关 + 保留天数)。
5//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。
6//!
7#[allow(unused_imports)]
8use std::collections::HashSet;
9
10use crate::router::Route;
11use dioxus::prelude::*;
12use dioxus::router::components::Link;
13// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
14// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
15#[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;
33/// 每页展示的回收站文章数量。
34const TRASH_PER_PAGE: i32 = 20;
35
36/// 回收站页面:列表 + 批量操作 + 自动清理配置。
37///
38/// 独立路由 `/admin/posts/trash` 的页面组件(原 `posts.rs::Posts` 的回收站 tab
39/// 提升而来)。翻页用客户端 signal 驱动(`current_page` signal + `use_paginated`
40/// 闭包内读取建立依赖),不走路由参数。支持单条/批量恢复与彻底删除、一键清空,
41/// 以及内联自动清理配置。header 副标题的删除计数取自 `use_paginated` 的 `total`。
42#[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    // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。
49    // 闭包内读取 current_page(.with)建立 reactive 依赖,翻页时自动重新请求。
50    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    // 自动清理配置:由子组件 AutoPurgeSettings 写入(加载/保存),本组件读取
65    // retention_days 供 TrashRow 的「剩余天数」展示。
66    let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
67
68    // 本地移除一篇文章(乐观更新)。
69    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    // 首次加载完成前不显示数量,避免「(0)」闪烁;翻页重载时 total 仍为上页值,计数保留。
78    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            // 页面页头:标题 + 副标题 + 返回列表与清空回收站入口
87            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                // 自动清理配置卡片
157                AutoPurgeSettings { settings }
158
159                // 批量操作栏(浮动卡片风格)
160                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                // 主内容:错误 / 加载骨架 / 空态 / 列表
241                {
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/// 自动清理配置子组件:使用共享可折叠设置卡片。
380///
381/// 封装自动清理的全部状态:表单草稿(`settings_draft_*`)、保存态、已保存反馈,
382/// 以及派生的 `dirty`。面板折叠态与摘要外壳由 `CollapsibleSettingsCard` 统一管理。
383/// 配置加载与保存均在组件内部完成。`settings`(已保存的服务端配置)由父组件传入
384/// 双向绑定 signal:本组件加载/保存时写入,父组件读取 `retention_days` 供 TrashRow
385/// 的「剩余天数」。
386///
387/// 从 `PostsTrashPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
388#[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    // 保存成功后的短暂反馈标记(用户再次编辑时清除)。
395    let mut just_saved: Signal<bool> = use_signal(|| false);
396
397    // 首次渲染加载服务端配置:本组件挂载即触发一次,无需 settings_loaded 守卫
398    //(父组件每次翻页重渲染的是列表 effect,本组件 effect 只在自身首次挂载跑)。
399    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    // 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
411    // 派生值用 use_memo:依赖信号不变时不重算(避免每次渲染重复 parse 字符串)。
412    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                // 开关行:启用自动清理
433                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                // 保留天数行
450                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                    // 数字输入 + 步进按钮 + 单位后缀
458                    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                            // 减号
461                            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                            // 数字输入(无边框,衔接步进按钮)
474                            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                            // 加号
486                            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                // 底部操作行:未保存提示 + 保存按钮
504                div { class: "flex items-center justify-between gap-4 pt-1",
505                    // 草稿状态提示
506                    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                    // 保存按钮:主题绿主操作,saving 态显示 spinner,just_saved/无改动禁用
528                    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
552/// 计算剩余天数(保留期 - 已删除天数)。
553///
554/// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。
555fn 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; // 毫秒
560            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/// 回收站表格行组件。
576#[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    // 彻底删除确认浮层:用触发按钮的视口坐标锚定,避免被表格 overflow 裁剪。
588    let mut purge_open = use_signal(|| false);
589    let mut anchor_x = use_signal(|| 0i32);
590    let mut anchor_y = use_signal(|| 0i32);
591    // 剩余天数徽章配色:>7 天中性,≤7 天鼠尾草绿(主题色),≤0/过期琥珀色。
592    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        // 此处只检查页面复用;折叠动画由 input.css 管理。
732        for (page, source) in [
733            ("posts_trash", include_str!("posts_trash.rs")),
734            ("system/backup", include_str!("system/backup.rs")),
735        ] {
736            // 排除测试自身,避免断言中的组件名让检查始终通过。
737            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}