refactor(hooks): 新增 use_paginated 并迁移 posts/trash 分页加载

按 Dioxus 0.7 custom hooks 范式,把后台分页列表页反复出现的
use_effect + spawn + (loading/items/total/error) 三件套收口到
use_paginated 组合式 hook。统一非 wasm cfg 处理(SSR 不发请求、
loading 立即置 false),调用方只关心 fetch 闭包与页码依赖。

迁移范围(按降级预案,只做最规整的两处):
- pages/admin/posts.rs:顺手补 error signal(原先吞掉 Err),向
  trash 看齐,消除一致性债。
- pages/admin/trash.rs:直接替换,error 已有。

保留手写的形态(强行套 hook 反而更复杂):
- comments.rs:依赖 (page, status) 双 signal,status 是额外维度,
  套不进 (page, per_page) 模型;改 hook 会为单一调用点变复杂。
- system.rs 三个 tab:load_once + use_future 轮询形态,与 hook 的
  单次加载模型不契合。
- dashboard.rs:多源聚合(3 个并发 spawn 分别 set 不同 signal)。

list_posts / list_deleted_posts 是 server function,两端都生成
(wasm 端 client stub、server 端真实实现),故去掉原 cfg import。
This commit is contained in:
xfy 2026-07-02 16:30:47 +08:00
parent 759bbab7ee
commit 59359b18ce
4 changed files with 144 additions and 75 deletions

View File

@ -3,6 +3,7 @@
//! 该模块集中管理可在组件树中复用的自定义 Hook包括
//! - 骨架屏延迟加载状态
//! - 通用 DOM 事件监听(注册 + 自动卸载清理)
//! - 客户端分页数据加载
//!
//! 评论草稿持久化localStorage 读写)已迁至 `crate::utils::comment_storage`
//! 因为它是纯工具函数而非渲染期 Hook。
@ -12,3 +13,6 @@ pub mod delayed_loading;
/// 通用 DOM 事件监听 Hookadd/remove_event_listener 生命周期封装)。
pub mod event_listener;
/// 客户端数据加载 Hookuse_paginated
pub mod query;

99
src/hooks/query.rs Normal file
View File

@ -0,0 +1,99 @@
//! 通用客户端数据加载 hook。
//!
//! 封装 `use_effect + spawn + 多个 signal` 的三件套,统一 loading / data / error
//! 三态与非 wasm cfg 处理。后台 CSR 页面用这套;公共 SSR 页面仍用
//! `use_server_future`(带 suspend 的真 SSR 预取),二者职责区分明确。
//!
//! 当前只提供 `use_paginated`分页列表。system tab 的"load_once + use_future
//! 轮询"形态、dashboard 的多源聚合形态保持手写——强行套 hook 反而更复杂。
use dioxus::prelude::*;
/// 分页列表加载状态。
///
/// 各字段是独立 `Signal`,调用方可按名读取、传给子组件,或对 `items` 做
/// `with_mut` 乐观更新。
pub struct PaginatedState<T> {
/// 是否正在加载。
pub loading: Signal<bool>,
/// 当前页数据。
pub items: Signal<Vec<T>>,
/// 总条数(用于分页器)。
pub total: Signal<i64>,
/// 最近一次加载错误(成功时为 None
pub error: Signal<Option<String>>,
}
/// 分页列表加载 hook。
///
/// - `page`:返回当前页码的信号读取闭包(返回值变化时自动重新加载)。
/// - `per_page`:每页条数。
/// - `fetch`:接收 `(page, per_page)`,返回 `Future<Output = Result<(Vec<T>, i64), E>>`。
/// `E: Display` 用于把错误写入 `error` signal。
///
/// 非 wasm32 下 `loading` 立即置 falseSSR 不发起请求)。
///
/// # 例
///
/// ```ignore
/// let state = use_paginated(
/// || current_page,
/// POSTS_PER_PAGE,
/// |p, pp| async move {
/// list_posts(p, pp).await
/// .map(|r| (r.posts, r.total))
/// .map_err(|e| e.to_string())
/// },
/// );
/// ```
#[allow(unused_variables)]
pub fn use_paginated<T, P, F, Fut, E>(
page: P,
per_page: i32,
fetch: F,
) -> PaginatedState<T>
where
T: PartialEq + Clone + 'static,
P: Fn() -> i32 + Copy + 'static,
F: Fn(i32, i32) -> Fut + Copy + 'static,
Fut: std::future::Future<Output = Result<(Vec<T>, i64), E>> + 'static,
E: std::fmt::Display + 'static,
{
let mut loading = use_signal(|| true);
let items = use_signal(Vec::new);
let total = use_signal(|| 0_i64);
let mut error: Signal<Option<String>> = use_signal(|| None);
use_effect(move || {
let p = page();
loading.set(true);
error.set(None);
#[cfg(target_arch = "wasm32")]
{
let f = fetch;
spawn(async move {
match f(p, per_page).await {
Ok((list, t)) => {
items.set(list);
total.set(t);
}
Err(e) => error.set(Some(e.to_string())),
}
loading.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
loading.set(false);
// 抚平非 wasm 下未使用的变量p / per_page / fetch
let _ = (p, per_page, fetch);
}
});
PaginatedState {
loading,
items,
total,
error,
}
}

View File

@ -6,11 +6,9 @@
use dioxus::prelude::*;
use dioxus::router::components::Link;
// 仅在 WASM 前端使用的分页数据接口。
#[cfg(target_arch = "wasm32")]
use crate::api::posts::list_posts;
#[cfg(target_arch = "wasm32")]
use crate::api::posts::PostListResponse;
// 分页数据接口list_posts 是 server function两端都生成wasm 端为 client stub
// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
use crate::api::posts::{list_posts, PostListResponse};
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
use crate::components::empty_state::{EmptyState, EmptyStateAction};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
@ -18,6 +16,7 @@ use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::components::ui::{
Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED,
};
use crate::hooks::query::use_paginated;
use crate::models::post::PostListItem;
use crate::router::Route;
@ -38,10 +37,24 @@ pub fn Posts() -> Element {
#[component]
pub fn PostsPage(page: i32) -> Element {
let current_page = page.max(1);
// 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。
let mut posts = use_signal(Vec::<PostListItem>::new);
let mut total = use_signal(|| 0_i64);
let mut loading = use_signal(|| true);
// 分页列表加载loading / posts / total / error由 use_paginated 统一管理;
// 原先吞掉 Err现向 trash 看齐暴露 error signal保持一致性
let paginated = use_paginated(
move || current_page,
POSTS_PER_PAGE,
|p, pp| async move {
list_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 _error = paginated.error;
// 删除中 ID、重建缓存状态与结果仍由本组件持有业务逻辑不归 hook 管)。
let mut deleting = use_signal(|| None::<i32>);
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar结果消息也在本组件
// 渲染header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
@ -51,35 +64,6 @@ pub fn PostsPage(page: i32) -> Element {
let rebuilding = use_signal(|| false);
let rebuild_result = use_signal(|| Option::<String>::None);
// 页码变化时加载分页数据WASM 前端请求接口SSR 直接结束加载。
use_effect(move || {
let _ = current_page;
loading.set(true);
// 仅在 WASM 前端发起分页请求。
#[cfg(target_arch = "wasm32")]
{
let p = current_page;
spawn(async move {
match list_posts(p, POSTS_PER_PAGE).await {
Ok(PostListResponse {
posts: list,
total: t,
}) => {
posts.set(list);
total.set(t);
}
Err(_) => {}
}
loading.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
loading.set(false);
}
});
let get_posts = move || -> Vec<PostListItem> { posts() };
rsx! {

View File

@ -12,10 +12,9 @@ use dioxus::prelude::*;
// 但部分仅用于 WASM 代码路径SSR 下触发 unused imports按项目惯例放行。
#[allow(unused_imports)]
use crate::api::posts::{
batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post,
batch_purge_posts, batch_restore_posts, empty_trash, list_deleted_posts, purge_post,
restore_post, PostListResponse,
};
#[cfg(target_arch = "wasm32")]
use crate::api::posts::{list_deleted_posts, PostListResponse};
#[allow(unused_imports)]
use crate::api::settings::{get_trash_settings, update_trash_settings};
use crate::components::empty_state::EmptyState;
@ -25,6 +24,7 @@ use crate::components::ui::{
Pagination, StatusBadge, ADMIN_CARD_CLASS, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS,
};
use crate::hooks::query::use_paginated;
use crate::models::post::PostListItem;
use crate::models::settings::TrashSettings;
use crate::router::Route;
@ -48,44 +48,26 @@ pub fn Trash() -> Element {
pub fn TrashPage(page: i32) -> Element {
let current_page = page.max(1);
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
let mut posts: Signal<Vec<PostListItem>> = use_signal(Vec::new);
let mut total: Signal<i64> = use_signal(|| 0);
#[allow(unused_mut)]
let mut loading: Signal<bool> = use_signal(|| false);
#[allow(unused_mut)]
let mut error: Signal<Option<String>> = use_signal(|| None);
// 分页列表加载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);
// 加载回收站列表(自动清理配置由子组件 AutoPurgeSettings 自行加载)。
use_effect(move || {
let _ = current_page;
loading.set(true);
error.set(None);
#[cfg(target_arch = "wasm32")]
{
let page = current_page;
spawn(async move {
match list_deleted_posts(page, TRASH_PER_PAGE).await {
Ok(PostListResponse {
posts: list,
total: t,
}) => {
posts.set(list);
total.set(t);
}
Err(e) => error.set(Some(e.to_string())),
}
loading.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
loading.set(false);
}
});
// 本地移除一篇文章(乐观更新)。
let mut remove_post = move |id: i32| {
posts.with_mut(|list| list.retain(|p| p.id != id));