yggdrasil/src/pages/search.rs
xfy 0398cc6c66 perf(render): 列表加显式 key + release 启用 panic=abort
依据 Dioxus 0.7 optimizing 指南做两项改进:

- 为数据驱动列表渲染补全显式 key(13 处,9 个文件):
  home/archives/tags/search 文章与标签列表、post_card/post_footer
  标签、header 桌面/移动导航、admin dashboard/posts/write 列表。
  使用各类型稳定唯一字段(post.id / tag / tag.name / item.label /
  err.id / year_group.year / month_group.month_en)作为 key,
  让 Dioxus diff 正确识别元素身份。骨架屏 for _ in 0..N 静态占位
  循环与已带 key 的评论树/admin 行不动。

- [profile.release] 加 panic = "abort":WASM 去掉 unwind 元数据减小
  体积;server 端错误处理走 Result+?(见 error.rs),不依赖 panic
  unwind,无副作用。

其余文件为本机格式化(单行折叠/多行展开),一并提交。

验证:dx check 通过、cargo test 405 passed。
2026-06-25 18:07:22 +08:00

86 lines
3.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 搜索页面模块。
//!
//! 对应路由 `/search`。
//!
//! 数据获取:用户在输入框中键入关键词并触发搜索后,
//! 通过 Dioxus 的 `spawn` 在本地启动异步任务,调用 `search_posts` server function。
//! 与首页/归档不同,搜索是交互式客户端行为,不在服务端渲染阶段预取数据。
//! 在 `wasm32` 目标下,该 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*;
use crate::api::posts::{search_posts, PostListResponse};
use crate::components::post_card::PostCard;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::search_skeleton::SearchSkeleton;
/// 搜索页面组件,对应路由 `/search`。
///
/// 维护搜索关键词、搜索结果与加载状态,渲染搜索框与结果列表。
/// 结果通过客户端异步请求获取,而非 `use_server_future` 预取。
#[component]
pub fn Search() -> Element {
// 当前输入框中的搜索关键词。
let mut query = use_signal(|| "".to_string());
// 搜索结果None 表示尚未执行搜索或已清空。
let mut search_res = use_signal(|| None::<Result<PostListResponse, ServerFnError>>);
// 是否正在发起搜索请求。
let mut is_searching = use_signal(|| false);
// 触发搜索的回调:校验空查询后启动异步请求。
let mut on_search = move || {
let q = query().trim().to_string();
if q.is_empty() {
return;
}
is_searching.set(true);
search_res.set(None);
spawn(async move {
let res = search_posts(q).await;
search_res.set(Some(res));
is_searching.set(false);
});
};
rsx! {
header { class: "page-header mb-6",
h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", "搜索" }
}
div { class: "mb-8",
div { class: "flex gap-2",
input {
class: "flex-1 px-4 py-2 border border-paper-border rounded-lg bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30",
r#type: "text",
placeholder: "输入关键词搜索文章...",
value: query(),
oninput: move |e| query.set(e.value()),
onkeydown: move |e| {
if e.key() == Key::Enter {
on_search()
}
},
}
button {
class: "px-6 py-2 bg-paper-accent text-white rounded-full font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200",
onclick: move |_| on_search(),
"搜索"
}
}
}
// 根据搜索状态展示骨架屏、结果列表、空状态或错误提示。
if is_searching() {
DelayedSkeleton { SearchSkeleton {} }
} else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() {
if posts.is_empty() {
div { class: "text-center text-paper-secondary py-20", "未找到相关文章" }
} else {
for post in posts.iter() {
PostCard { key: "{post.id}", post: post.clone() }
}
}
} else if search_res().as_ref().map(|r| r.is_err()).unwrap_or(false) {
div { class: "text-center text-red-500 dark:text-red-400 py-20", "搜索失败" }
}
}
}