From 25299d266f171532128d8f050b2da30b3bddb96f Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 10:21:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20=E5=90=88=E5=B9=B6=20/admin/?= =?UTF-8?q?posts=20=E4=B8=8E=20/admin/posts/trash=20=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20+=20tab=20=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将「全部文章」与「回收站」从两个独立路由(及各自的分页变体 PostsPage / PostsTrashPage)合并为单一 /admin/posts 路由,用顶部 tab 在二者间切换。 tab 状态与翻页均改为客户端 signal 驱动(不走路由、不深链),与 system.rs 的 tab 模式一致:admin 内部页,刷新回到「全部文章」第 1 页。 路由瘦身:删除 PostsTrash / PostsTrashPage / PostsPage 三个变体,Router 不再 需要「静态段 trash 优先于 :page」的排序技巧。 主要改动: - posts.rs: Posts 改为 tab 容器(PostsTab 枚举 + active_tab signal + key 化 match 渲染),header 文案随 tab 切换;PostsPage 主体下沉为 AllPostsList, 翻页改 current_page signal + use_paginated 回调式 Pagination。 - posts_trash.rs: PostsTrashPage → PostsTrashPanel(无参,signal 翻页), 删除入口组件与 PostsTabs import(header/tab 由容器统一提供)。 - PostsTabs: 从路由驱动(use_route)改为 signal 驱动(on_change 回调)。 - 修复 tab 水平对齐 bug:两个 tab 统一 inline-flex items-center 同盒模型 + 外层容器加 items-center,根除原先「全部文章」(inline 文本) 与「回收站」 (inline-flex 带角标) 盒模型不一致导致的垂直错位。 - admin_layout.rs: is_posts_route 简化为只匹配 Route::Posts{}。 - RebuildCacheBar 改为完全自持 state(rebuilding + result),结果消息行随组件 内联渲染,与父组件零耦合。 --- src/components/admin_layout.rs | 10 +- src/pages/admin/mod.rs | 6 +- src/pages/admin/posts.rs | 457 ++++++++++++++++++--------------- src/pages/admin/posts_trash.rs | 65 ++--- src/router.rs | 16 +- 5 files changed, 289 insertions(+), 265 deletions(-) diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index bdc100e..49b1c66 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -51,14 +51,8 @@ pub fn AdminLayout() -> Element { 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 { .. } - ); + // 「管理文章」高亮:单路由 /admin/posts(列表 + 回收站 tab 均归此项)。 + let is_posts_route = matches!(route, Route::Posts {}); // 所有 admin 页面共用同一 shell:外层圆角卡片(滚动容器) + 内部 main 负责居中限宽。 // write 路由例外:卡片不滚动(overflow-hidden),main 作为 flex 容器不带头尾 padding, diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index ecf067e..45bb1f2 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -21,10 +21,8 @@ pub mod write; pub use comments::{AdminComments, AdminCommentsPage}; /// 管理后台仪表盘组件。 pub use dashboard::Admin; -/// 文章管理列表组件(带默认分页)。 -pub use posts::{Posts, PostsPage}; -/// 回收站管理组件(带默认分页,文章管理下的 tab)。 -pub use posts_trash::{PostsTrash, PostsTrashPage}; +/// 文章管理入口组件(列表 + 回收站,单路由 + 客户端 tab)。 +pub use posts::Posts; /// 代码试运行沙箱组件。 pub use runner::Runner; /// 系统管理入口组件。 diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index f64fc2a..229f643 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -1,6 +1,8 @@ -//! 文章管理列表页面。 +//! 文章管理页面(列表 + 回收站,单一路由 + 客户端 tab 切换)。 //! -//! 提供文章分页列表、删除单篇文章、重建 content_html 缓存,以及跳转到编辑器的能力。 +//! 「全部文章」与「回收站」合并为单一 `/admin/posts` 路由,用顶部 tab 在二者间 +//! 切换。tab 状态与翻页均由客户端 signal 驱动(不走路由、不深链),与 `system.rs` +//! 的 tab 模式一致:admin 内部页,刷新回到「全部文章」第 1 页即可。 //! 数据加载与写操作仅在 WASM 前端通过 Dioxus server functions 完成。 use dioxus::prelude::*; @@ -9,7 +11,7 @@ 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 加载路径使用, +// get_post_stats / PostStatsResponse 仅在 Posts 容器的 wasm 加载路径使用, // SSR 下对应 use_effect 分支被裁剪,故允许 unused imports。 #[allow(unused_imports)] use crate::api::posts::{ @@ -26,29 +28,123 @@ use crate::components::ui::{ use crate::hooks::query::use_paginated; use crate::models::post::PostListItem; use crate::router::Route; +// 回收站 tab 内容(本容器 match 渲染)。 +use super::posts_trash::PostsTrashPanel; /// 每页展示的文章数量。 const POSTS_PER_PAGE: i32 = 20; -/// 文章管理入口组件,默认展示第 1 页。 -#[component] -pub fn Posts() -> Element { - rsx! { - PostsPage { page: 1 } +/// 文章管理顶部 tab:全部文章 / 回收站。 +/// +/// 用枚举而非裸字符串,保证 tab 切换的类型安全;`as_str()` 提供稳定 key 供 +/// `key` 化重挂载(隔离各 tab 的 `use_paginated` 状态)。 +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum PostsTab { + /// 全部文章(含草稿)。 + All, + /// 回收站(已软删除)。 + Trash, +} + +impl PostsTab { + fn as_str(&self) -> &'static str { + match self { + PostsTab::All => "all", + PostsTab::Trash => "trash", + } } } -/// 文章管理分页组件。 +/// 文章管理入口组件:单一路由 + 客户端 tab 切换。 /// -/// 根据 `page` 参数加载对应页的文章列表,支持删除单篇文章与重建文章 HTML 缓存。 +/// 持有 `active_tab` signal 与回收站数量 `trash_count`(供 header 文案 + tab 角标), +/// 用 `key` 化 `match` 切换 `AllPostsList` / `PostsTrashPanel`:切 tab 完全卸载旧 +/// 组件、重挂新组件,各 tab 的 `use_paginated` / 选中态等本地 signal 天然隔离, +/// 无需手动重置。参照 `system.rs` 的 tab 模式。 #[component] -pub fn PostsPage(page: i32) -> Element { - let current_page = page.max(1); +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] +pub fn Posts() -> Element { + let mut active_tab = use_signal(|| PostsTab::All); + // 回收站数量:仅 WASM 异步拉取一次,供 header 文案「已删除文章 (N)」与 tab 角标。 + // 回收站 panel 内部维护自己的精确 total(分页计数用),二者解耦——角标是粗略提示。 + let mut trash_count = use_signal(|| Option::::None); - // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理; - // 原先吞掉 Err,现向 trash 看齐暴露 error signal(保持一致性)。 + 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: "w-full max-w-7xl mx-auto space-y-6", + // 共享 header:标题/副标题随 tab 切换文案;右侧操作区仅「全部文章」tab 显示。 + div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-paper-border mb-6", + div { + h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", + if active_tab() == PostsTab::All { + "管理文章" + } else { + "回收站" + } + } + p { class: "text-base text-[var(--color-paper-secondary)] mt-2", + if active_tab() == PostsTab::All { + "所有文章及草稿" + } else { + if let Some(count) = trash_count() { + "已删除文章 ({count})" + } else { + "已删除文章" + } + } + } + } + // 发布文章 + 重建缓存仅在「全部文章」tab 显示。 + if active_tab() == PostsTab::All { + div { class: "flex items-center gap-3", + RebuildCacheBar {} + Link { + class: "{BTN_PRIMARY}", + to: Route::Write {}, + "发布文章" + } + } + } + } + + // tab 栏:全部文章 / 回收站。signal 驱动(点击切 active_tab,非路由)。 + PostsTabs { + active: active_tab, + trash_count, + on_change: move |t: PostsTab| active_tab.set(t), + } + + // key 化条件渲染:切 tab 完全卸载/重挂,隔离各自 use_paginated 状态。 + div { key: "{active_tab().as_str()}", + match active_tab() { + PostsTab::All => rsx! { AllPostsList {} }, + PostsTab::Trash => rsx! { PostsTrashPanel {} }, + } + } + } + } +} + +/// 全部文章列表 tab:分页列表、删除单篇、重建 content_html 缓存。 +/// +/// 翻页用客户端 signal 驱动(`current_page` signal + `use_paginated` 的闭包内读取 +/// 建立依赖,页码变化自动重载),不走路由。删除/重建逻辑与旧实现一致。 +#[component] +fn AllPostsList() -> Element { + let current_page = use_signal(|| 1); + + // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。 + // 闭包内读取 current_page(.with)建立 reactive 依赖,翻页时自动重新请求。 let paginated = use_paginated( - move || current_page, + move || current_page.with(|p| *p), POSTS_PER_PAGE, |p, pp| async move { list_posts(p, pp) @@ -68,132 +164,92 @@ pub fn PostsPage(page: i32) -> Element { // 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点 // 的覆盖先点的,故用 HashSet),按行通过 contains 判断 loading 态。 let mut rebuilding = use_signal(std::collections::HashSet::::new); - // 重建缓存的状态由本组件持有并下发给 RebuildCacheBar:结果消息也在本组件 - // 渲染(header 与表格之间的独立行),既不撑高 header 触发 items-center 重排, - // 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。 - // 不加 mut:本组件只读信号并下发,.set() 都在 RebuildCacheBar 的 spawn 块里, - // 走 Signal 的内部可变性;SSR target 下那些 set 不可见,mut 会触发 unused_mut。 - let batch_rebuilding = use_signal(|| false); - let rebuild_result = use_signal(|| Option::::None); - let get_posts = move || -> Vec { posts() }; rsx! { - div { class: "w-full max-w-7xl mx-auto space-y-6", - div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 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", "所有文章及草稿" } - } - div { class: "flex items-center gap-3", - // 重建缓存工具条(抽取为子组件 RebuildCacheBar,见文件末尾)。 - RebuildCacheBar { - rebuilding: batch_rebuilding, - rebuild_result: rebuild_result, - } - Link { - class: "{BTN_PRIMARY}", - to: Route::Write {}, - "发布文章" - } - } + if loading() && posts().is_empty() { + DelayedSkeleton { PostsSkeleton {} } + } else if posts().is_empty() { + EmptyState { + title: "暂无文章", + description: "还没有创建任何文章,开始写下你的第一篇文字吧。", + action: EmptyStateAction { + label: "写文章".to_string(), + to: Route::Write {}, + }, } - - // tab 栏:文章 / 回收站。URL 驱动(Link 切换路由),回收站带数量角标。 - PostsTabs {} - - // 重建结果消息:独立成行,进入文档流,吃 space-y-6 的正常间距。 - // 既不撑高 header(不在 header 内)触发 items-center 重排,也不脱离流 - // 溢进表格(曾用 absolute top-full mt-2,因 28px > 24px 间距溢出 4px)。 - if let Some(msg) = rebuild_result() { - div { class: "text-sm text-paper-secondary", - "{msg}" - } - } - - if loading() && posts().is_empty() { - DelayedSkeleton { PostsSkeleton {} } - } else if posts().is_empty() { - EmptyState { - title: "暂无文章", - description: "还没有创建任何文章,开始写下你的第一篇文字吧。", - action: EmptyStateAction { - label: "写文章".to_string(), - to: Route::Write {}, - }, - } - } else { - div { class: "{ADMIN_TABLE_CLASS}", - 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", "标题" } - th { class: "px-4 py-3 font-medium w-24 text-center", - "状态" - } - th { class: "px-4 py-3 font-medium w-32", "日期" } - th { class: "px-4 py-3 font-medium w-44 text-right", - "操作" - } + } else { + div { class: "{ADMIN_TABLE_CLASS}", + 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", "标题" } + th { class: "px-4 py-3 font-medium w-24 text-center", + "状态" + } + th { class: "px-4 py-3 font-medium w-32", "日期" } + th { class: "px-4 py-3 font-medium w-44 text-right", + "操作" } } - tbody { - for post in get_posts().iter() { - PostRow { - key: "{post.id}", - post: post.clone(), - deleting: deleting().contains(&post.id), - rebuilding: rebuilding().contains(&post.id), - // 删除文章:非乐观——先标记 deleting 让按钮显示 loading, - // 服务端成功后才移除行并减总数,失败则保留行并弹出浏览器提示。 - on_delete: move |id| { - deleting.write().insert(id); - spawn(async move { - match delete_post(id).await { - Ok(CreatePostResponse { success: true, .. }) => { - posts.with_mut(|list| list.retain(|p| p.id != id)); - total.with_mut(|t| *t = t.saturating_sub(1)); - } - Ok(CreatePostResponse { success: false, message: _message, .. }) => { - #[cfg(target_arch = "wasm32")] - web_sys::window().map(|w| w.alert_with_message(&_message).ok()); - } - Err(_e) => { - #[cfg(target_arch = "wasm32")] - web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); - } + } + tbody { + for post in get_posts().iter() { + PostRow { + key: "{post.id}", + post: post.clone(), + deleting: deleting().contains(&post.id), + rebuilding: rebuilding().contains(&post.id), + on_delete: move |id| { + deleting.write().insert(id); + spawn(async move { + match delete_post(id).await { + Ok(CreatePostResponse { success: true, .. }) => { + posts.with_mut(|list| list.retain(|p| p.id != id)); + total.with_mut(|t| *t = t.saturating_sub(1)); } - deleting.write().remove(&id); - }); - }, - // 重建单篇文章内容:调用 server function 重新渲染 content_html。 - // 静默执行,仅按行切换 rebuilding 按钮态,不弹窗。 - // 用 HashSet 记录在途 ID,支持多篇并发重建。 - on_rebuild: move |id| { - rebuilding.write().insert(id); - spawn(async move { - let _ = rebuild_post_content_html(id).await; - rebuilding.write().remove(&id); - }); - }, - } + Ok(CreatePostResponse { success: false, message: _message, .. }) => { + #[cfg(target_arch = "wasm32")] + web_sys::window().map(|w| w.alert_with_message(&_message).ok()); + } + Err(_e) => { + #[cfg(target_arch = "wasm32")] + web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); + } + } + deleting.write().remove(&id); + }); + }, + on_rebuild: move |id| { + rebuilding.write().insert(id); + spawn(async move { + let _ = rebuild_post_content_html(id).await; + rebuilding.write().remove(&id); + }); + }, } } } } - Pagination { - variant: "admin", - current_page, - total: total(), - per_page: POSTS_PER_PAGE, - prev_route: if current_page - 1 <= 1 { Route::Posts {} } else { Route::PostsPage { - page: current_page - 1, - } }, - next_route: Route::PostsPage { - page: current_page + 1, - }, - unit: "篇", - } + } + Pagination { + variant: "admin", + current_page: current_page(), + total: total(), + per_page: POSTS_PER_PAGE, + unit: "篇", + on_prev: { + let mut page = current_page; + move |_| { + page.with_mut(|p| *p = (*p - 1).max(1)); + } + }, + on_next: { + let mut page = current_page; + move |_| { + page.with_mut(|p| *p += 1); + } + }, } } } @@ -202,18 +258,16 @@ pub fn PostsPage(page: i32) -> Element { /// 重建内容缓存工具条子组件。 /// /// 封装「重建内容 / 重建全部」两个按钮及其 `do_rebuild` 异步闭包。状态 -/// (`rebuilding` / `rebuild_result`) 由父组件 `PostsPage` 持有并下发: -/// 结果消息在父组件渲染为 header 与表格之间的独立行(进入文档流,吃 -/// `space-y-6` 的正常间距),既不撑高 header 触发 `items-center` 重排, -/// 也不脱离文档流溢进表格。 +/// (`rebuilding` / `rebuild_result`) 由本组件内部持有(从 `PostsPage` 下沉至此, +/// 因合并后仅 All tab 需要,无需跨层传递)。 /// -/// 从 `PostsPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。 +/// 从 `AllPostsList` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。 #[component] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -fn RebuildCacheBar( - rebuilding: Signal, - rebuild_result: Signal>, -) -> Element { +fn RebuildCacheBar() -> Element { + let mut rebuilding = use_signal(|| false); + let mut rebuild_result = use_signal(|| Option::::None); + // 重建文章渲染缓存:rebuild_all 为 false 时仅重建 content_html 为空的文章, // 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。 let mut do_rebuild = move |rebuild_all: bool| { @@ -245,44 +299,53 @@ fn RebuildCacheBar( }; rsx! { - // 仅渲染按钮行本身:结果消息已上提到 PostsPage,作为独立状态行进入文档流。 - div { class: "flex items-center gap-3", - Tooltip { - tip: "重建 content_html 为空的文章渲染缓存".to_string(), - placement: "bottom", - button { - class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE }, - disabled: rebuilding(), - onclick: move |_| do_rebuild(false), - span { - class: if rebuilding() { "opacity-40" } else { "" }, - "重建内容" - } - if rebuilding() { + // 竖向布局:按钮行 + 结果消息行(右对齐,消息行不撑高按钮行)。 + // 自持 rebuilding / rebuild_result state,与父组件零耦合。 + div { class: "flex flex-col items-end gap-1", + div { class: "flex items-center gap-3", + Tooltip { + tip: "重建 content_html 为空的文章渲染缓存".to_string(), + placement: "bottom", + button { + class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE }, + disabled: rebuilding(), + onclick: move |_| do_rebuild(false), span { - class: "absolute inset-0 flex items-center justify-center", - dangerous_inner_html: SPINNER_SVG, + class: if rebuilding() { "opacity-40" } else { "" }, + "重建内容" + } + if rebuilding() { + span { + class: "absolute inset-0 flex items-center justify-center", + dangerous_inner_html: SPINNER_SVG, + } + } + } + } + Tooltip { + tip: "重建所有文章的渲染缓存(含已有内容)".to_string(), + placement: "bottom", + button { + class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE }, + disabled: rebuilding(), + onclick: move |_| do_rebuild(true), + span { + class: if rebuilding() { "opacity-40" } else { "" }, + "重建全部" + } + if rebuilding() { + span { + class: "absolute inset-0 flex items-center justify-center", + dangerous_inner_html: SPINNER_SVG, + } } } } } - Tooltip { - tip: "重建所有文章的渲染缓存(含已有内容)".to_string(), - placement: "bottom", - button { - class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE }, - disabled: rebuilding(), - onclick: move |_| do_rebuild(true), - span { - class: if rebuilding() { "opacity-40" } else { "" }, - "重建全部" - } - if rebuilding() { - span { - class: "absolute inset-0 flex items-center justify-center", - dangerous_inner_html: SPINNER_SVG, - } - } + // 重建结果消息:独立成行,右对齐,与按钮行同属本组件。 + if let Some(msg) = rebuild_result() { + div { class: "text-xs text-paper-secondary whitespace-pre-line", + "{msg}" } } } @@ -374,48 +437,40 @@ fn PostRow( /// 文章管理 tab 栏:「全部文章」与「回收站」。 /// -/// tab 状态由 URL(当前路由)驱动而非本地 signal:点击即跳转路由,刷新/深链均可直达 -/// 对应 tab。回收站 tab 带 `get_post_stats().stats.trash` 数量角标,便于发现待清理文章。 -/// 本组件在 `PostsPage`(全部文章)与 `PostsTrashPage`(回收站)共用,故设为 pub。 +/// tab 状态由父组件传入的 `active` signal 驱动(非路由),点击即调用 `on_change` +/// 切换。回收站 tab 带 `trash_count` 数量角标,便于发现待清理文章。 +/// +/// 对齐修复:两个 tab 均用 `inline-flex items-center` 同盒模型,外层容器加 +/// `items-center`,根除原先「全部文章」(inline 文本) 与「回收站」(inline-flex +/// 带角标) 盒模型不一致导致的垂直错位。 #[component] -#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -pub fn PostsTabs() -> Element { - let route = use_route::(); - // 当前 tab:true=回收站(PostsTrash/PostsTrashPage),false=全部文章(Posts/PostsPage)。 - let is_trash = matches!( - route, - Route::PostsTrash {} | Route::PostsTrashPage { .. } - ); - // 回收站数量:仅 WASM 异步拉取,供角标展示。 - let mut trash_count = use_signal(|| Option::::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)); - } - }); - }); +pub(super) fn PostsTabs( + active: Signal, + trash_count: Signal>, + on_change: EventHandler, +) -> Element { + let is_trash = active() == PostsTab::Trash; rsx! { - div { class: "flex gap-4 border-b border-paper-border", - Link { - to: Route::Posts {}, + // items-center 让两个 tab 垂直居中对齐;两个 button 同为 inline-flex items-center, + // 盒模型一致,根除基线/盒高错位。 + div { class: "flex items-center gap-4 border-b border-paper-border", + button { 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" + "inline-flex items-center 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" + "inline-flex items-center 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" }, + onclick: move |_| on_change.call(PostsTab::All), "全部文章" } - Link { - to: Route::PostsTrash {}, + button { 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" }, + onclick: move |_| on_change.call(PostsTab::Trash), "回收站" // 数量角标:有数据才显示。0 显示中性灰,>0 用主题强调色提醒。 if let Some(count) = trash_count() { diff --git a/src/pages/admin/posts_trash.rs b/src/pages/admin/posts_trash.rs index e5fba53..cfe97dd 100644 --- a/src/pages/admin/posts_trash.rs +++ b/src/pages/admin/posts_trash.rs @@ -1,11 +1,13 @@ -//! 回收站(文章管理下的 tab)。 +//! 回收站(文章管理下的 tab,由 `posts.rs::Posts` 容器渲染)。 //! //! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空, //! 以及自动清理配置(启用开关 + 保留天数)。 //! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。 //! -//! 回收站原为独立路由 `/admin/trash`,现合并为 `/admin/posts/trash` tab, -//! 与文章列表共享「管理文章」导航项与 tab 栏(见 `posts.rs::PostsTabs`)。 +//! 本组件作为 `PostsTrashPanel` 被 `/admin/posts` 单路由下的 tab 容器(见 +//! `posts.rs::Posts`)渲染:header 与 tab 栏由容器统一提供,本组件只负责 +//! 回收站列表内容(自动清理配置 + 批量栏 + 列表 + 分页)。翻页走客户端 signal, +//! 与全部文章 tab 状态通过容器层的 key 化挂载隔离。 use std::collections::HashSet; @@ -31,33 +33,25 @@ use crate::components::ui::{ 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 } - } -} - -/// 回收站分页组件。 +/// 回收站 tab 内容:列表 + 批量操作 + 自动清理配置。 /// -/// 支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。 +/// 作为 `PostsTrashPanel` 被 `posts.rs::Posts` 容器的 tab match 渲染。翻页用客户端 +/// signal 驱动(`current_page` signal + `use_paginated` 闭包内读取建立依赖), +/// 不走路由。支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。 #[allow(unused_mut, unused_variables)] #[component] -pub fn PostsTrashPage(page: i32) -> Element { - let current_page = page.max(1); +pub(super) fn PostsTrashPanel() -> Element { + let current_page = use_signal(|| 1); let mut selected_ids: Signal> = use_signal(HashSet::new); // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。 + // 闭包内读取 current_page(.with)建立 reactive 依赖,翻页时自动重新请求。 let paginated = use_paginated( - move || current_page, + move || current_page.with(|p| *p), TRASH_PER_PAGE, |p, pp| async move { list_deleted_posts(p, pp) @@ -84,18 +78,7 @@ pub fn PostsTrashPage(page: i32) -> Element { }; 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 {} - + div { class: "space-y-6", // 自动清理配置卡片(抽取为子组件 AutoPurgeSettings,见文件末尾)。 AutoPurgeSettings { settings } @@ -299,16 +282,22 @@ pub fn PostsTrashPage(page: i32) -> Element { } Pagination { variant: "admin", - current_page, + current_page: 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: "篇", + on_prev: { + let mut page = current_page; + move |_| { + page.with_mut(|p| *p = (*p - 1).max(1)); + } + }, + on_next: { + let mut page = current_page; + move |_| { + page.with_mut(|p| *p += 1); + } + }, } } } diff --git a/src/router.rs b/src/router.rs index 3e21d6e..b49948d 100644 --- a/src/router.rs +++ b/src/router.rs @@ -11,10 +11,7 @@ use crate::components::admin_layout::AdminLayout; use crate::components::frontend_layout::FrontendLayout; use crate::context::UserContext; use crate::pages::about::About; -use crate::pages::admin::{ - Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, PostsTrash, PostsTrashPage, Runner, - System, Write, WriteEdit, -}; +use crate::pages::admin::{Admin, AdminComments, AdminCommentsPage, Posts, Runner, System, Write, WriteEdit}; use crate::pages::archives::Archives; use crate::pages::home::{Home, HomePage}; use crate::pages::login::Login; @@ -75,18 +72,9 @@ pub enum Route { /// 编辑文章页 #[route("/write/:id")] WriteEdit { id: i32 }, - /// 文章管理列表 + /// 文章管理(列表 + 回收站,客户端 tab 切换,单一路由) #[route("/posts")] Posts {}, - /// 回收站(文章管理下的 tab,静态段优先于 :page) - #[route("/posts/trash")] - PostsTrash {}, - /// 回收站分页 - #[route("/posts/trash/:page")] - PostsTrashPage { page: i32 }, - /// 文章管理列表分页 - #[route("/posts/:page")] - PostsPage { page: i32 }, /// 评论管理 #[route("/comments")] AdminComments {},