配合 input.css 的 token 化,把散落在 RSX 组件里的孤悬圆角档位 统一到内容档 16px (rounded-2xl): 消除 24px (rounded-3xl, 4处无规范依据的中间档): - ADMIN_CARD_CLASS / ADMIN_TABLE_CLASS (ui.rs) - write.rs 编辑器容器 / write_skeleton.rs 编辑器骨架 消除 12px (rounded-xl, 10处): - INPUT_CLASS (forms.rs) 与 write 页输入框统一到 16px - write 页 3 处输入框 + 4 处错误提示框 - admin_layout 退出按钮 - system/admin_skeleton/posts_trash 卡片容器 - write_skeleton 骨架条(模拟输入框形状,跟随真实圆角) 改造后全站只剩 4 种圆角语义: 32px容器 / 16px内容 / 8px微元素 / 胶囊按钮,孤悬档位彻底清除。
662 lines
33 KiB
Rust
662 lines
33 KiB
Rust
//! 回收站(文章管理下的 tab)。
|
||
//!
|
||
//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空,
|
||
//! 以及自动清理配置(启用开关 + 保留天数)。
|
||
//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。
|
||
//!
|
||
//! 回收站原为独立路由 `/admin/trash`,现合并为 `/admin/posts/trash` tab,
|
||
//! 与文章列表共享「管理文章」导航项与 tab 栏(见 `posts.rs::PostsTabs`)。
|
||
|
||
use std::collections::HashSet;
|
||
|
||
use dioxus::prelude::*;
|
||
|
||
// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查),
|
||
// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。
|
||
#[allow(unused_imports)]
|
||
use crate::api::posts::{
|
||
batch_purge_posts, batch_restore_posts, empty_trash, list_deleted_posts, purge_post,
|
||
restore_post, PostListResponse,
|
||
};
|
||
#[allow(unused_imports)]
|
||
use crate::api::settings::{get_trash_settings, update_trash_settings};
|
||
use crate::components::empty_state::EmptyState;
|
||
use crate::components::skeletons::atoms::SkeletonBox;
|
||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||
use crate::components::ui::{
|
||
Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS,
|
||
BTN_DANGER_OUTLINE, BTN_ICON, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED,
|
||
CHECKBOX_CLASS, LoadingButton,
|
||
};
|
||
use crate::hooks::query::use_paginated;
|
||
use crate::models::post::PostListItem;
|
||
use crate::models::settings::TrashSettings;
|
||
use crate::router::Route;
|
||
// 文章管理共享的 tab 栏(同目录 posts 模块)。
|
||
use super::posts::PostsTabs;
|
||
|
||
/// 每页展示的回收站文章数量。
|
||
const TRASH_PER_PAGE: i32 = 20;
|
||
|
||
/// 回收站入口组件,默认展示第 1 页。
|
||
#[component]
|
||
pub fn PostsTrash() -> Element {
|
||
rsx! {
|
||
PostsTrashPage { page: 1 }
|
||
}
|
||
}
|
||
|
||
/// 回收站分页组件。
|
||
///
|
||
/// 支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。
|
||
#[allow(unused_mut, unused_variables)]
|
||
#[component]
|
||
pub fn PostsTrashPage(page: i32) -> Element {
|
||
let current_page = page.max(1);
|
||
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
|
||
|
||
// 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。
|
||
let paginated = use_paginated(
|
||
move || current_page,
|
||
TRASH_PER_PAGE,
|
||
|p, pp| async move {
|
||
list_deleted_posts(p, pp)
|
||
.await
|
||
.map(|PostListResponse { posts, total }| (posts, total))
|
||
},
|
||
);
|
||
let mut posts = paginated.items;
|
||
let mut total = paginated.total;
|
||
let loading = paginated.loading;
|
||
let mut error = paginated.error;
|
||
|
||
// 自动清理配置:由子组件 AutoPurgeSettings 写入(加载/保存),本组件读取
|
||
// retention_days 供 TrashRow 的「剩余天数」展示。
|
||
let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
|
||
|
||
// 本地移除一篇文章(乐观更新)。
|
||
let mut remove_post = move |id: i32| {
|
||
posts.with_mut(|list| list.retain(|p| p.id != id));
|
||
total.with_mut(|t| *t = t.saturating_sub(1));
|
||
selected_ids.with_mut(|s| {
|
||
s.remove(&id);
|
||
});
|
||
};
|
||
|
||
rsx! {
|
||
div { class: "w-full max-w-7xl mx-auto space-y-6",
|
||
// 页面标题
|
||
div { class: "flex items-end justify-between pb-6 border-b border-paper-border mb-6",
|
||
div {
|
||
h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", "回收站" }
|
||
p { class: "text-base text-[var(--color-paper-secondary)] mt-2", "已删除文章 ({total()})" }
|
||
}
|
||
}
|
||
|
||
// tab 栏:文章 / 回收站。URL 驱动,与全部文章页共用(见 posts::PostsTabs)。
|
||
PostsTabs {}
|
||
|
||
// 自动清理配置卡片(抽取为子组件 AutoPurgeSettings,见文件末尾)。
|
||
AutoPurgeSettings { settings }
|
||
|
||
// 批量操作栏(选中时显示)
|
||
if !selected_ids().is_empty() {
|
||
div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
|
||
span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" }
|
||
button {
|
||
class: "{BTN_SOLID_GREEN}",
|
||
onclick: move |_| {
|
||
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||
spawn(async move {
|
||
let _ = batch_restore_posts(ids).await;
|
||
});
|
||
for id in selected_ids() {
|
||
remove_post(id);
|
||
}
|
||
selected_ids.set(HashSet::new());
|
||
},
|
||
"批量恢复"
|
||
}
|
||
button {
|
||
class: "{BTN_SOLID_RED}",
|
||
onclick: move |_| {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if web_sys::window()
|
||
.and_then(|w| {
|
||
w.confirm_with_message(
|
||
"确定要彻底删除选中的文章吗?此操作不可恢复。",
|
||
)
|
||
.ok()
|
||
})
|
||
.unwrap_or(false)
|
||
{
|
||
let ids: Vec<i32> = selected_ids().iter().copied().collect();
|
||
spawn(async move {
|
||
let _ = batch_purge_posts(ids).await;
|
||
});
|
||
for id in selected_ids() {
|
||
remove_post(id);
|
||
}
|
||
selected_ids.set(HashSet::new());
|
||
}
|
||
}
|
||
},
|
||
"批量彻底删除"
|
||
}
|
||
}
|
||
}
|
||
|
||
// 主内容:错误 / 加载骨架 / 空态 / 列表
|
||
{
|
||
if error().is_some() {
|
||
rsx! {
|
||
EmptyState {
|
||
title: "加载失败",
|
||
description: "获取回收站列表时发生错误,请稍后重试。",
|
||
}
|
||
}
|
||
} else if loading() && posts().is_empty() {
|
||
rsx! {
|
||
DelayedSkeleton {
|
||
div { class: "{ADMIN_CARD_CLASS} p-6 space-y-4",
|
||
for _ in 0..5 {
|
||
SkeletonBox { class: "h-10 rounded" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if posts().is_empty() {
|
||
rsx! {
|
||
EmptyState {
|
||
title: "回收站为空",
|
||
description: "当前没有被软删除的文章。",
|
||
}
|
||
}
|
||
} else {
|
||
let list = posts();
|
||
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
|
||
let all_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||
rsx! {
|
||
div { class: "{ADMIN_TABLE_CLASS}",
|
||
div { class: "overflow-x-auto",
|
||
table { class: "w-full text-sm",
|
||
thead {
|
||
tr { class: "border-b border-paper-border text-left text-paper-secondary",
|
||
th { class: "px-4 py-3 font-medium w-10",
|
||
input {
|
||
r#type: "checkbox",
|
||
class: "{CHECKBOX_CLASS}",
|
||
checked: all_selected,
|
||
onchange: {
|
||
move |_| {
|
||
let mut s = selected_ids();
|
||
if all_selected {
|
||
for id in &all_ids {
|
||
s.remove(id);
|
||
}
|
||
} else {
|
||
for id in &all_ids {
|
||
s.insert(*id);
|
||
}
|
||
}
|
||
selected_ids.set(s);
|
||
}
|
||
},
|
||
}
|
||
}
|
||
th { class: "px-4 py-3 font-medium", "标题" }
|
||
th { class: "px-4 py-3 font-medium", "原状态" }
|
||
th { class: "px-4 py-3 font-medium w-28", "删除时间" }
|
||
th { class: "px-4 py-3 font-medium w-24 text-center", "剩余" }
|
||
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" }
|
||
}
|
||
}
|
||
tbody {
|
||
for post in list.iter() {
|
||
TrashRow {
|
||
key: "{post.id}",
|
||
post: post.clone(),
|
||
retention_days: settings().retention_days,
|
||
selected: selected_ids().contains(&post.id),
|
||
on_select: {
|
||
let id = post.id;
|
||
move |checked: bool| {
|
||
let mut s = selected_ids();
|
||
if checked {
|
||
s.insert(id);
|
||
} else {
|
||
s.remove(&id);
|
||
}
|
||
selected_ids.set(s);
|
||
}
|
||
},
|
||
on_restore: {
|
||
let id = post.id;
|
||
move |_| {
|
||
spawn(async move {
|
||
let _ = restore_post(id).await;
|
||
});
|
||
remove_post(id);
|
||
}
|
||
},
|
||
on_purge: {
|
||
let id = post.id;
|
||
move |_| {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if web_sys::window()
|
||
.and_then(|w| {
|
||
w
|
||
.confirm_with_message(
|
||
"确定要彻底删除这篇文章吗?此操作不可恢复。",
|
||
)
|
||
.ok()
|
||
})
|
||
.unwrap_or(false)
|
||
{
|
||
spawn(async move {
|
||
let _ = purge_post(id).await;
|
||
});
|
||
remove_post(id);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 底部:清空回收站 + 分页
|
||
div { class: "flex items-center justify-between mt-4",
|
||
button {
|
||
class: "{BTN_DANGER_OUTLINE}",
|
||
onclick: move |_| {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if web_sys::window()
|
||
.and_then(|w| {
|
||
w.confirm_with_message(
|
||
"确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。",
|
||
)
|
||
.ok()
|
||
})
|
||
.unwrap_or(false)
|
||
{
|
||
spawn(async move {
|
||
let _ = empty_trash().await;
|
||
});
|
||
posts.set(Vec::new());
|
||
total.set(0);
|
||
selected_ids.set(HashSet::new());
|
||
}
|
||
}
|
||
},
|
||
"清空回收站"
|
||
}
|
||
}
|
||
Pagination {
|
||
variant: "admin",
|
||
current_page,
|
||
total: total(),
|
||
per_page: TRASH_PER_PAGE,
|
||
prev_route: if current_page - 1 <= 1 { Route::PostsTrash {} } else { Route::PostsTrashPage {
|
||
page: current_page - 1,
|
||
} },
|
||
next_route: Route::PostsTrashPage {
|
||
page: current_page + 1,
|
||
},
|
||
unit: "篇",
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 自动清理配置子组件:可折叠的设置面板。
|
||
///
|
||
/// 封装自动清理的全部状态:表单草稿(`settings_draft_*`)、面板折叠态、保存态、
|
||
/// 已保存反馈、以及派生的 `dirty` / `chevron_rotate`。配置加载与保存均在组件
|
||
/// 内部完成。`settings`(已保存的服务端配置)由父组件传入双向绑定 signal:
|
||
/// 本组件加载/保存时写入,父组件读取 `retention_days` 供 TrashRow 的「剩余天数」。
|
||
///
|
||
/// 从 `PostsTrashPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
|
||
#[component]
|
||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||
fn AutoPurgeSettings(settings: Signal<TrashSettings>) -> Element {
|
||
let mut settings_draft_days: Signal<String> = use_signal(|| "30".to_string());
|
||
let mut settings_draft_enabled: Signal<bool> = use_signal(|| false);
|
||
let mut settings_panel_open: Signal<bool> = use_signal(|| false);
|
||
let mut saving_settings: Signal<bool> = use_signal(|| false);
|
||
// 保存成功后的短暂反馈标记(用户再次编辑时清除)。
|
||
let mut just_saved: Signal<bool> = use_signal(|| false);
|
||
|
||
// 首次渲染加载服务端配置:本组件挂载即触发一次,无需 settings_loaded 守卫
|
||
//(父组件每次翻页重渲染的是列表 effect,本组件 effect 只在自身首次挂载跑)。
|
||
use_effect(move || {
|
||
#[cfg(target_arch = "wasm32")]
|
||
spawn(async move {
|
||
if let Ok(s) = get_trash_settings().await {
|
||
settings_draft_days.set(s.retention_days.to_string());
|
||
settings_draft_enabled.set(s.auto_purge_enabled);
|
||
settings.set(s);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
|
||
// 派生值用 use_memo:依赖信号不变时不重算(避免每次渲染重复 parse 字符串)。
|
||
let dirty = use_memo(move || {
|
||
settings_draft_enabled() != settings().auto_purge_enabled
|
||
|| settings_draft_days()
|
||
.trim()
|
||
.parse::<i32>()
|
||
.ok()
|
||
.map(|d| d != settings().retention_days)
|
||
.unwrap_or(true)
|
||
});
|
||
// 折叠箭头旋转类(展开时翻转 180°)。
|
||
let chevron_rotate = if settings_panel_open() {
|
||
"rotate-180"
|
||
} else {
|
||
""
|
||
};
|
||
|
||
rsx! {
|
||
div { class: "rounded-2xl border border-paper-border overflow-hidden bg-paper-entry",
|
||
// 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头
|
||
button {
|
||
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",
|
||
onclick: move |_| {
|
||
settings_panel_open.set(!settings_panel_open());
|
||
just_saved.set(false);
|
||
},
|
||
// 状态指示灯
|
||
{
|
||
let dot_class = if settings().auto_purge_enabled {
|
||
"w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(64,160,43,0.15)]"
|
||
} else {
|
||
"w-2 h-2 rounded-full bg-paper-tertiary"
|
||
};
|
||
rsx! {
|
||
div { class: "w-2 flex-shrink-0 flex items-center justify-center",
|
||
div { class: "{dot_class}" }
|
||
}
|
||
}
|
||
}
|
||
// 标题 + 当前状态描述
|
||
div { class: "flex-1 min-w-0",
|
||
div { class: "text-sm font-medium text-paper-primary", "自动清理" }
|
||
div { class: "text-xs text-paper-secondary mt-0.5 truncate",
|
||
if settings().auto_purge_enabled {
|
||
"已开启 · 超过 {settings().retention_days} 天的文章将被自动删除"
|
||
} else {
|
||
"已关闭"
|
||
}
|
||
}
|
||
}
|
||
// 展开箭头(旋转动画)
|
||
svg {
|
||
class: "w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
|
||
view_box: "0 0 24 24",
|
||
fill: "none",
|
||
stroke: "currentColor",
|
||
stroke_width: "2",
|
||
path {
|
||
stroke_linecap: "round",
|
||
stroke_linejoin: "round",
|
||
d: "M19 9l-7 7-7-7",
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设置面板(可折叠带平滑动画)
|
||
div {
|
||
class: "grid transition-all duration-300 ease-in-out",
|
||
style: if settings_panel_open() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
|
||
div { class: "overflow-hidden min-h-0",
|
||
div { class: "border-t border-paper-border p-5 space-y-6",
|
||
// 开关行:启用自动清理
|
||
div { class: "flex items-center justify-between gap-4",
|
||
div { class: "min-w-0",
|
||
div { class: "text-sm font-medium text-paper-primary",
|
||
"启用自动清理"
|
||
}
|
||
div { class: "text-xs text-paper-secondary mt-1",
|
||
"后台任务定期彻底删除超过保留期的文章"
|
||
}
|
||
}
|
||
// 自定义开关(toggle switch)—— 取代原生 checkbox
|
||
button {
|
||
role: "switch",
|
||
aria_checked: "{settings_draft_enabled()}",
|
||
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" },
|
||
onclick: move |_| {
|
||
settings_draft_enabled.set(!settings_draft_enabled());
|
||
just_saved.set(false);
|
||
},
|
||
// 滑块圆点
|
||
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" } }
|
||
}
|
||
}
|
||
|
||
// 保留天数行
|
||
div { class: "space-y-3",
|
||
div { class: "min-w-0",
|
||
div { class: "text-sm font-medium text-paper-primary",
|
||
"保留天数"
|
||
}
|
||
div { class: "text-xs text-paper-secondary mt-1",
|
||
"文章删除后保留的时长,到期后自动彻底清除(1–365)"
|
||
}
|
||
}
|
||
// 数字输入 + 步进按钮 + 单位后缀
|
||
div { class: "flex items-center gap-3",
|
||
div { class: "flex items-center rounded-lg border border-paper-border bg-paper-entry overflow-hidden",
|
||
// 减号
|
||
button {
|
||
class: "{BTN_ICON}",
|
||
r#type: "button",
|
||
aria_label: "减少保留天数",
|
||
onclick: move |_| {
|
||
let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
|
||
let next = cur.saturating_sub(1).max(1);
|
||
settings_draft_days.set(next.to_string());
|
||
just_saved.set(false);
|
||
},
|
||
"−"
|
||
}
|
||
// 数字输入(无边框,衔接步进按钮)
|
||
input {
|
||
r#type: "number",
|
||
min: "1",
|
||
max: "365",
|
||
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",
|
||
value: "{settings_draft_days()}",
|
||
oninput: move |e| {
|
||
settings_draft_days.set(e.value());
|
||
just_saved.set(false);
|
||
},
|
||
}
|
||
// 加号
|
||
button {
|
||
class: "{BTN_ICON}",
|
||
r#type: "button",
|
||
aria_label: "增加保留天数",
|
||
onclick: move |_| {
|
||
let cur: i32 = settings_draft_days().trim().parse().unwrap_or(30);
|
||
let next = cur.saturating_add(1).min(365);
|
||
settings_draft_days.set(next.to_string());
|
||
just_saved.set(false);
|
||
},
|
||
"+"
|
||
}
|
||
}
|
||
span { class: "text-xs text-paper-secondary", "天" }
|
||
}
|
||
}
|
||
|
||
// 底部操作行:未保存提示 + 保存按钮
|
||
div { class: "flex items-center justify-between gap-4 pt-1",
|
||
// 草稿状态提示
|
||
if just_saved() {
|
||
span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent",
|
||
svg {
|
||
class: "w-3.5 h-3.5",
|
||
view_box: "0 0 24 24",
|
||
fill: "none",
|
||
stroke: "currentColor",
|
||
stroke_width: "2.5",
|
||
path {
|
||
stroke_linecap: "round",
|
||
stroke_linejoin: "round",
|
||
d: "M5 13l4 4L19 7",
|
||
}
|
||
}
|
||
"已保存"
|
||
}
|
||
} else if dirty() {
|
||
span { class: "text-xs text-paper-secondary", "有未保存的更改" }
|
||
} else {
|
||
span { class: "text-xs text-transparent select-none",
|
||
"·"
|
||
}
|
||
}
|
||
// 保存按钮:主题绿主操作,saving 态显示 spinner,just_saved/无改动禁用
|
||
LoadingButton {
|
||
label: "保存设置".to_string(),
|
||
loading: saving_settings(),
|
||
disabled: just_saved() || !dirty(),
|
||
variant: "sm",
|
||
onclick: move |_| {
|
||
let days: i32 = settings_draft_days().parse().unwrap_or(30);
|
||
let enabled = settings_draft_enabled();
|
||
saving_settings.set(true);
|
||
spawn(async move {
|
||
if let Ok(s) = update_trash_settings(enabled, days).await {
|
||
settings.set(s);
|
||
just_saved.set(true);
|
||
}
|
||
saving_settings.set(false);
|
||
});
|
||
},
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 计算剩余天数(保留期 - 已删除天数)。
|
||
///
|
||
/// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。
|
||
fn remaining_days(post: &PostListItem, retention_days: i32) -> (i64, bool) {
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if let Some(deleted_at) = post.deleted_at {
|
||
let now_ms = js_sys::Date::now() as i64; // 毫秒
|
||
let deleted_ms = deleted_at.timestamp_millis();
|
||
let elapsed_days = (now_ms - deleted_ms) / 86_400_000;
|
||
let remaining = retention_days as i64 - elapsed_days;
|
||
(remaining, remaining <= 0)
|
||
} else {
|
||
(retention_days as i64, false)
|
||
}
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let _ = post;
|
||
(retention_days as i64, false)
|
||
}
|
||
}
|
||
|
||
/// 回收站表格行组件。
|
||
#[component]
|
||
fn TrashRow(
|
||
post: PostListItem,
|
||
retention_days: i32,
|
||
selected: bool,
|
||
on_select: EventHandler<bool>,
|
||
on_restore: EventHandler,
|
||
on_purge: EventHandler,
|
||
) -> Element {
|
||
let (remaining, expired) = remaining_days(&post, retention_days);
|
||
// 剩余天数徽章配色:>7 天中性,≤7 天鼠尾草绿(主题色),≤0/过期琥珀色。
|
||
let badge_class = if expired {
|
||
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||
} else if remaining <= 7 {
|
||
"bg-paper-accent-soft text-paper-accent"
|
||
} else {
|
||
"bg-paper-tertiary text-paper-secondary"
|
||
};
|
||
let badge_text = if expired {
|
||
"待清理".to_string()
|
||
} else {
|
||
format!("{remaining}天")
|
||
};
|
||
let deleted_str = post
|
||
.deleted_at
|
||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||
.unwrap_or_else(|| "—".to_string());
|
||
|
||
rsx! {
|
||
tr { class: "{ADMIN_ROW_HOVER}",
|
||
td { class: "px-4 py-3",
|
||
input {
|
||
r#type: "checkbox",
|
||
class: "{CHECKBOX_CLASS}",
|
||
checked: selected,
|
||
onchange: move |e| on_select.call(e.checked()),
|
||
}
|
||
}
|
||
td { class: "px-4 py-3",
|
||
div { class: "text-sm font-medium text-paper-primary truncate max-w-xs",
|
||
"{post.title}"
|
||
}
|
||
}
|
||
td { class: "px-4 py-3",
|
||
StatusBadge {
|
||
color_class: post.status_badge_class(),
|
||
label: post.status_label().to_string(),
|
||
}
|
||
}
|
||
td { class: "px-4 py-3 text-sm text-paper-secondary", "{deleted_str}" }
|
||
td { class: "px-4 py-3 text-center",
|
||
StatusBadge { color_class: badge_class, label: badge_text }
|
||
}
|
||
td { class: "px-4 py-3 text-right",
|
||
div { class: "flex justify-end gap-2",
|
||
button {
|
||
class: "{BTN_TEXT_ACCENT}",
|
||
onclick: move |_| on_restore.call(()),
|
||
"恢复"
|
||
}
|
||
button {
|
||
class: "{BTN_TEXT_RED}",
|
||
onclick: move |_| on_purge.call(()),
|
||
"彻底删除"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#[test]
|
||
fn test_auto_purge_settings_has_transition_class() {
|
||
let full_code = include_str!("posts_trash.rs");
|
||
let code = full_code.split("#[cfg(test)]").next().unwrap_or(full_code);
|
||
assert!(code.contains("grid transition-all duration-300 ease-in-out"));
|
||
assert!(code.contains("grid-template-rows: 1fr; opacity: 1;"));
|
||
assert!(code.contains("grid-template-rows: 0fr; opacity: 0;"));
|
||
}
|
||
}
|