Skip to main content

yggdrasil/hooks/
query.rs

1//! 通用客户端数据加载 hook。
2//!
3//! 封装 `Effect + spawn_local + 多个 RwSignal` 的三件套,统一 loading / data / error
4//! 三态与请求世代号竞态保护。后台 CSR 页面用这套;公共 SSR 页面仍用路由
5//! `LazyRoute::data()` 里创建的 `Resource`(带 Suspense 的真 SSR 预取),二者职责区分明确。
6//!
7//! 当前只提供 `use_paginated`(分页列表)。system tab 的"load_once + 定时轮询"形态、
8//! dashboard 的多源聚合形态保持手写——强行套 hook 反而更复杂。
9
10use leptos::prelude::*;
11
12/// 分页列表加载状态。
13///
14/// 各字段是独立 `RwSignal`(`Copy`),调用方可按名读取、传给子组件,
15/// 或对 `items` 做 `update` 乐观更新。
16pub struct PaginatedState<T: Send + Sync + 'static> {
17    /// 是否正在加载(初始为 `true`;服务端不发请求,保持 `true`,与水合首帧一致)。
18    pub loading: RwSignal<bool>,
19    /// 当前页数据。
20    pub items: RwSignal<Vec<T>>,
21    /// 总条数(用于分页器)。
22    pub total: RwSignal<i64>,
23    /// 最近一次加载错误(成功时为 None)。
24    pub error: RwSignal<Option<String>>,
25}
26
27// 手写而非 derive:derive 会给 `T` 加上多余的 `Clone`/`Copy` 约束。
28impl<T: Send + Sync + 'static> Clone for PaginatedState<T> {
29    fn clone(&self) -> Self {
30        *self
31    }
32}
33
34impl<T: Send + Sync + 'static> Copy for PaginatedState<T> {}
35
36/// 分页列表加载 hook。
37///
38/// - `page`:返回当前页码的闭包,在 Effect 内调用,其读取的信号被追踪
39///   (变化时自动重新加载)。
40/// - `per_page`:每页条数。
41/// - `fetch`:接收 `(page, per_page)`,返回 `Future<Output = Result<(Vec<T>, i64), E>>`。
42///   `E: Display` 用于把错误写入 `error` 信号;`ApiError` 请先经
43///   `crate::utils::error::failure_message` 映射成可读文案(UI 不得直接展示
44///   `err.to_string()`)。`fetch` 在 Effect 内**同步**调用(返回 Future 之前的部分),
45///   因此构造请求参数时读取的信号(筛选条件等)同样被追踪;`async` 块内的读取不追踪。
46///   `fetch` 与 `page` 都要求 `Send + Sync + 'static`,捕获的信号都是 `Copy` 句柄,
47///   满足该要求;Future 本身在浏览器本地执行,无需 `Send`。
48///
49/// 仅在浏览器(Effect 运行时)发起请求,服务端不请求;快速翻页/切筛选时,
50/// 只有最后一次请求的响应能落地。
51///
52/// # 例
53///
54/// ```ignore
55/// let state = use_paginated(
56///     move || current_page.get(),
57///     POSTS_PER_PAGE,
58///     move |p, pp| {
59///         let query = build_query(); // 同步读信号:被追踪
60///         async move {
61///             list_posts(p, pp, query).await
62///                 .map(|r| (r.posts, r.total))
63///                 .map_err(|e| failure_message("加载文章", &e))
64///         }
65///     },
66/// );
67/// let (posts, loading) = (state.items, state.loading);
68/// ```
69pub fn use_paginated<T, P, F, Fut, E>(page: P, per_page: i32, fetch: F) -> PaginatedState<T>
70where
71    T: Clone + Send + Sync + 'static,
72    P: Fn() -> i32 + Send + Sync + 'static,
73    F: Fn(i32, i32) -> Fut + Send + Sync + 'static,
74    Fut: std::future::Future<Output = Result<(Vec<T>, i64), E>> + 'static,
75    E: std::fmt::Display + 'static,
76{
77    let loading = RwSignal::new(true);
78    let items = RwSignal::new(Vec::new());
79    let total = RwSignal::new(0_i64);
80    let error: RwSignal<Option<String>> = RwSignal::new(None);
81    // 请求世代号:快速翻页/切筛选时,只有最后一次请求的响应能落地。
82    let generation = StoredValue::new(0_u64);
83
84    Effect::new(move |_| {
85        let p = page();
86        let future = fetch(p, per_page);
87        let Some(id) = generation.try_update_value(|g| {
88            *g = g.wrapping_add(1);
89            *g
90        }) else {
91            return;
92        };
93        loading.set(true);
94        error.set(None);
95        leptos::task::spawn_local(async move {
96            let result = future.await;
97            // 过期响应(或组件已卸载)直接丢弃,不写任何信号(含 loading)。
98            if generation.try_get_value() != Some(id) {
99                return;
100            }
101            match result {
102                Ok((list, t)) => {
103                    items.set(list);
104                    total.set(t);
105                }
106                Err(e) => error.set(Some(e.to_string())),
107            }
108            loading.set(false);
109        });
110    });
111
112    PaginatedState {
113        loading,
114        items,
115        total,
116        error,
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use leptos::reactive::owner::Owner;
124
125    fn assert_copy<S: Copy>() {}
126
127    #[test]
128    fn state_is_copy_even_for_non_copy_items() {
129        assert_copy::<PaginatedState<String>>();
130    }
131
132    #[test]
133    fn server_side_keeps_initial_loading_state_without_fetching() {
134        Owner::new().with(|| {
135            let state = use_paginated(
136                || 1,
137                20,
138                |_, _| async {
139                    panic!("服务端不应发起请求") as Result<(Vec<String>, i64), String>
140                },
141            );
142            assert!(state.loading.get_untracked());
143            assert!(state.items.get_untracked().is_empty());
144            assert_eq!(state.total.get_untracked(), 0);
145            assert!(state.error.get_untracked().is_none());
146        });
147    }
148}