refactor(admin): merge trash into /admin/posts as a URL-driven tab

回收站从独立路由 /admin/trash 合并为 /admin/posts 下的一个 tab,
消除「回收站」导航项,让文章管理(列表 + 回收站 + 自动清理配置)集中在
同一页面,符合「相关功能就近聚合」的组织原则。

路由(router.rs)
- 删除 Route::Trash / Route::TrashPage
- 新增 Route::PostsTrash (/posts/trash) 与 PostsTrashPage (/posts/trash/:page)
- 静态段声明在 :page 之前::page 为 i32 类型,天然排斥 'trash' 文本,
  路由匹配安全且遵循文件内 static-first 约定

导航(admin_layout.rs)
- 移除「回收站」导航项
- 「管理文章」高亮覆盖其下所有子路由(列表/分页/回收站 tab),
  顺带修掉原 PostsPage 分页不高亮的小 bug

页面(posts.rs / posts_trash.rs)
- 新增 PostsTabs:URL 驱动的 tab 栏(Link 切换路由,非本地 signal),
  回收站 tab 带 get_post_stats().trash 数量角标,>0 用主题强调色提醒
- trash.rs → posts_trash.rs:TrashPage 重命名为 PostsTrashPage,
  分页路由改指向 PostsTrash/PostsTrashPage,内容(列表/批量操作/
  清空回收站/AutoPurgeSettings)原样迁移;include_str 测试同步更新
- posts.rs 的 PostsPage 渲染 PostsTabs,列表逻辑不变

无后端契约变更:所有 server function(list/delete/restore/purge/
batch/empty_trash/get_trash_settings)签名不变,Pagination 组件
接收具体 Route 变体,给 trash tab 传 PostsTrash 即可,无需改组件。
This commit is contained in:
xfy 2026-07-08 16:52:02 +08:00
parent 9c3f2242ba
commit d72495c002
5 changed files with 108 additions and 23 deletions

View File

@ -45,13 +45,20 @@ pub fn AdminLayout() -> Element {
(Route::Admin {}, "仪表盘"),
(Route::Write {}, "写文章"),
(Route::Posts {}, "管理文章"),
(Route::Trash {}, "回收站"),
(Route::Runner {}, "试运行"),
(Route::System {}, "系统"),
];
let is_write_route =
matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
// 「管理文章」高亮覆盖其下所有子路由:列表、分页、回收站 tab。
let is_posts_route = matches!(
route,
Route::Posts {}
| Route::PostsPage { .. }
| Route::PostsTrash {}
| Route::PostsTrashPage { .. }
);
let main_class = if is_write_route {
"flex-1 flex flex-col relative w-full h-full overflow-y-auto px-10 py-12 rounded-[2rem] bg-[var(--color-paper-theme)] shadow-sm border border-[var(--color-paper-border)]"
@ -75,7 +82,9 @@ pub fn AdminLayout() -> Element {
nav { class: "flex-1 flex flex-col gap-2",
for (dest, label) in admin_nav_items {
{
let is_active = route == dest || (label == "写文章" && is_write_route) || (label == "回收站" && matches!(route, Route::TrashPage { .. }));
let is_active = route == dest
|| (label == "写文章" && is_write_route)
|| (label == "管理文章" && is_posts_route);
let base_class = "flex items-center px-4 py-3 rounded-2xl text-sm font-medium transition-all";
let text_class = if is_active {
"bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"

View File

@ -8,12 +8,12 @@ pub mod comments;
pub mod dashboard;
/// 文章管理列表页面模块。
pub mod posts;
/// 回收站页面模块(文章管理下的 tab
pub mod posts_trash;
/// 代码试运行沙箱页面模块。
pub mod runner;
/// 系统管理页面模块(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。
pub mod system;
/// 回收站管理页面模块。
pub mod trash;
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
pub mod write;
@ -23,11 +23,11 @@ pub use comments::{AdminComments, AdminCommentsPage};
pub use dashboard::Admin;
/// 文章管理列表组件(带默认分页)。
pub use posts::{Posts, PostsPage};
/// 回收站管理组件(带默认分页,文章管理下的 tab
pub use posts_trash::{PostsTrash, PostsTrashPage};
/// 代码试运行沙箱组件。
pub use runner::Runner;
/// 系统管理入口组件。
pub use system::System;
/// 回收站管理组件(带默认分页)。
pub use trash::{Trash, TrashPage};
/// 文章编辑器组件(新建与编辑模式)。
pub use write::{Write, WriteEdit};

View File

@ -9,8 +9,12 @@ use dioxus::router::components::Link;
// 分页数据接口list_posts 是 server function两端都生成wasm 端为 client stub
// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
use crate::api::posts::{list_posts, PostListResponse};
// get_post_stats / PostStatsResponse 仅在 PostsTabs 的 wasm 加载路径使用,
// SSR 下对应 use_effect 分支被裁剪,故允许 unused imports。
#[allow(unused_imports)]
use crate::api::posts::{
delete_post, rebuild_content_html, rebuild_post_content_html, CreatePostResponse, RebuildResult,
delete_post, get_post_stats, rebuild_content_html, rebuild_post_content_html,
CreatePostResponse, PostStatsResponse, RebuildResult,
};
use crate::components::empty_state::{EmptyState, EmptyStateAction};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
@ -95,6 +99,9 @@ pub fn PostsPage(page: i32) -> Element {
}
}
// tab 栏:文章 / 回收站。URL 驱动Link 切换路由),回收站带数量角标。
PostsTabs {}
// 重建结果消息:独立成行,进入文档流,吃 space-y-6 的正常间距。
// 既不撑高 header不在 header 内)触发 items-center 重排,也不脱离流
// 溢进表格(曾用 absolute top-full mt-2因 28px > 24px 间距溢出 4px
@ -364,3 +371,64 @@ fn PostRow(
}
}
}
/// 文章管理 tab 栏:「全部文章」与「回收站」。
///
/// tab 状态由 URL当前路由驱动而非本地 signal点击即跳转路由刷新/深链均可直达
/// 对应 tab。回收站 tab 带 `get_post_stats().stats.trash` 数量角标,便于发现待清理文章。
/// 本组件在 `PostsPage`(全部文章)与 `PostsTrashPage`(回收站)共用,故设为 pub。
#[component]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub fn PostsTabs() -> Element {
let route = use_route::<Route>();
// 当前 tabtrue=回收站PostsTrash/PostsTrashPagefalse=全部文章Posts/PostsPage
let is_trash = matches!(
route,
Route::PostsTrash {} | Route::PostsTrashPage { .. }
);
// 回收站数量:仅 WASM 异步拉取,供角标展示。
let mut trash_count = use_signal(|| Option::<i64>::None);
use_effect(move || {
#[cfg(target_arch = "wasm32")]
spawn(async move {
if let Ok(PostStatsResponse { stats }) = get_post_stats().await {
trash_count.set(Some(stats.trash));
}
});
});
rsx! {
div { class: "flex gap-4 border-b border-paper-border",
Link {
to: Route::Posts {},
class: if !is_trash {
"px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer border-b-2 border-paper-primary -mb-px"
} else {
"px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer border-b-2 border-transparent -mb-px"
},
"全部文章"
}
Link {
to: Route::PostsTrash {},
class: if is_trash {
"inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer border-b-2 border-paper-primary -mb-px"
} else {
"inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer border-b-2 border-transparent -mb-px"
},
"回收站"
// 数量角标有数据才显示。0 显示中性灰,>0 用主题强调色提醒。
if let Some(count) = trash_count() {
span {
class: if count > 0 {
"inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[0.625rem] font-semibold normal-case tracking-normal bg-paper-accent-soft text-paper-accent"
} else {
"inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[0.625rem] font-semibold normal-case tracking-normal bg-paper-tertiary text-paper-secondary"
},
"{count}"
}
}
}
}
}
}

View File

@ -1,8 +1,11 @@
//! 回收站管理页面
//! 回收站(文章管理下的 tab
//!
//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空,
//! 以及自动清理配置(启用开关 + 保留天数)。
//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。
//!
//! 回收站原为独立路由 `/admin/trash`,现合并为 `/admin/posts/trash` tab
//! 与文章列表共享「管理文章」导航项与 tab 栏(见 `posts.rs::PostsTabs`)。
use std::collections::HashSet;
@ -29,15 +32,17 @@ 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 Trash() -> Element {
pub fn PostsTrash() -> Element {
rsx! {
TrashPage { page: 1 }
PostsTrashPage { page: 1 }
}
}
@ -46,7 +51,7 @@ pub fn Trash() -> Element {
/// 支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。
#[allow(unused_mut, unused_variables)]
#[component]
pub fn TrashPage(page: i32) -> Element {
pub fn PostsTrashPage(page: i32) -> Element {
let current_page = page.max(1);
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
@ -88,6 +93,9 @@ pub fn TrashPage(page: i32) -> Element {
}
}
// tab 栏:文章 / 回收站。URL 驱动,与全部文章页共用(见 posts::PostsTabs
PostsTabs {}
// 自动清理配置卡片(抽取为子组件 AutoPurgeSettings见文件末尾
AutoPurgeSettings { settings }
@ -294,10 +302,10 @@ pub fn TrashPage(page: i32) -> Element {
current_page,
total: total(),
per_page: TRASH_PER_PAGE,
prev_route: if current_page - 1 <= 1 { Route::Trash {} } else { Route::TrashPage {
prev_route: if current_page - 1 <= 1 { Route::PostsTrash {} } else { Route::PostsTrashPage {
page: current_page - 1,
} },
next_route: Route::TrashPage {
next_route: Route::PostsTrashPage {
page: current_page + 1,
},
unit: "",
@ -316,7 +324,7 @@ pub fn TrashPage(page: i32) -> Element {
/// 内部完成。`settings`(已保存的服务端配置)由父组件传入双向绑定 signal
/// 本组件加载/保存时写入,父组件读取 `retention_days` 供 TrashRow 的「剩余天数」。
///
/// 从 `TrashPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill
/// 从 `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 {
@ -644,7 +652,7 @@ fn TrashRow(
mod tests {
#[test]
fn test_auto_purge_settings_has_transition_class() {
let full_code = include_str!("trash.rs");
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;"));

View File

@ -12,8 +12,8 @@ use crate::components::frontend_layout::FrontendLayout;
use crate::context::UserContext;
use crate::pages::about::About;
use crate::pages::admin::{
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Runner, System, Trash, TrashPage,
Write, WriteEdit,
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, PostsTrash, PostsTrashPage, Runner,
System, Write, WriteEdit,
};
use crate::pages::archives::Archives;
use crate::pages::home::{Home, HomePage};
@ -76,6 +76,12 @@ pub enum Route {
/// 文章管理列表
#[route("/posts")]
Posts {},
/// 回收站(文章管理下的 tab静态段优先于 :page
#[route("/posts/trash")]
PostsTrash {},
/// 回收站分页
#[route("/posts/trash/:page")]
PostsTrashPage { page: i32 },
/// 文章管理列表分页
#[route("/posts/:page")]
PostsPage { page: i32 },
@ -85,12 +91,6 @@ pub enum Route {
/// 评论管理分页
#[route("/comments/:page")]
AdminCommentsPage { page: i32 },
/// 回收站
#[route("/trash")]
Trash {},
/// 回收站分页
#[route("/trash/:page")]
TrashPage { page: i32 },
/// 系统管理(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)
#[route("/system")]
System {},