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