From b4e15ecbff02f007a3fda9e982c56bfa6d0c3648 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 28 Jul 2026 17:33:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=87=E7=AB=A0=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E6=8C=89=E6=A0=87=E9=A2=98=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 /admin/posts 的「全部文章」tab 增加按标题搜索能力: - list_posts server fn 增加可选 search: Option 参数,非空时按 title ILIKE 子串匹配(转义 % / _ / \,限长 200 字符)过滤草稿+已发布, 否则走原全量列表路径;COUNT 与分页查询同步带过滤条件。 - AllPostsList 增加搜索输入条(回车 / 搜索按钮提交,清除按钮复位), search_query signal 在 use_paginated 的 page 闭包内同步读取以建立响应式 依赖:提交新词即使停在首页也会重新请求;fetch 闭包请求时读取当前词传后端。 搜索结果为空时展示「未找到匹配的文章」空状态。 - 同步更新 dashboard.rs 与 query.rs 文档示例中的 list_posts 调用签名。 --- src/api/posts/list.rs | 107 +++++++++++++++++++++++++++-------- src/hooks/query.rs | 2 +- src/pages/admin/dashboard.rs | 2 +- src/pages/admin/posts.rs | 88 +++++++++++++++++++++++----- 4 files changed, 159 insertions(+), 40 deletions(-) diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 4246406..be674c3 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -117,7 +117,11 @@ pub async fn list_published_posts( /// /// 需要 admin 权限;结果按创建时间降序,不走缓存。 #[server(ListPosts, "/api")] -pub async fn list_posts(page: i32, per_page: i32) -> Result { +pub async fn list_posts( + page: i32, + per_page: i32, + search: Option, +) -> Result { // 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。 let (page, per_page) = clamp_pagination(page, per_page); let _user = get_current_admin_user().await?; @@ -126,32 +130,87 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result = search + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + let mut esc = String::with_capacity(s.len()); + for ch in s.chars().take(200) { + match ch { + '\\' | '%' | '_' => { + esc.push('\\'); + esc.push(ch); + } + _ => esc.push(ch), + } + } + esc + }); let offset = ((page - 1).max(0) as i64) * (per_page as i64); let limit = per_page as i64; - let rows = client - .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.status, - p.published_at, p.created_at, p.updated_at, p.cover_image, - p.word_count, p.reading_time, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - WHERE p.deleted_at IS NULL - GROUP BY p.id - ORDER BY p.created_at DESC - LIMIT $1 OFFSET $2", - &[&limit, &offset], - ) - .await - .map_err(AppError::query)?; + + // 有搜索词时按 title ILIKE 子串匹配过滤(双侧 %),否则走原全量列表。 + // posts 表对管理后台属低频访问、行数有限,ILIKE 全表扫可接受,靠 LIMIT 兜底。 + let (total, rows) = if let Some(esc) = title_filter { + let pattern = format!("%{esc}%"); + let total: i64 = client + .query_one( + "SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND title ILIKE $1 ESCAPE '\\'", + &[&pattern], + ) + .await + .map_err(AppError::query)? + .get(0); + let rows = client + .query( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.status, + p.published_at, p.created_at, p.updated_at, p.cover_image, + p.word_count, p.reading_time, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.deleted_at IS NULL AND p.title ILIKE $3 ESCAPE '\\' + GROUP BY p.id + ORDER BY p.created_at DESC + LIMIT $1 OFFSET $2", + &[&limit, &offset, &pattern], + ) + .await + .map_err(AppError::query)?; + (total, rows) + } else { + let total: i64 = client + .query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[]) + .await + .map_err(AppError::query)? + .get(0); + let rows = client + .query( + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.status, + p.published_at, p.created_at, p.updated_at, p.cover_image, + p.word_count, p.reading_time, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.deleted_at IS NULL + GROUP BY p.id + ORDER BY p.created_at DESC + LIMIT $1 OFFSET $2", + &[&limit, &offset], + ) + .await + .map_err(AppError::query)?; + (total, rows) + }; let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect(); diff --git a/src/hooks/query.rs b/src/hooks/query.rs index 9a80716..801bddc 100644 --- a/src/hooks/query.rs +++ b/src/hooks/query.rs @@ -40,7 +40,7 @@ pub struct PaginatedState { /// || current_page, /// POSTS_PER_PAGE, /// |p, pp| async move { -/// list_posts(p, pp).await +/// list_posts(p, pp, None).await /// .map(|r| (r.posts, r.total)) /// .map_err(|e| e.to_string()) /// }, diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 416e85f..51e5202 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -37,7 +37,7 @@ pub fn Admin() -> Element { } }); spawn(async move { - if let Ok(PostListResponse { posts, total: _ }) = list_posts(1, 5).await { + if let Ok(PostListResponse { posts, total: _ }) = list_posts(1, 5, None).await { recent_posts.set(Some(posts)); } }); diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index b0e4cb4..16bc195 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -139,17 +139,30 @@ pub fn Posts() -> Element { /// 建立依赖,页码变化自动重载),不走路由。删除/重建逻辑与旧实现一致。 #[component] fn AllPostsList() -> Element { - let current_page = use_signal(|| 1); + let mut current_page = use_signal(|| 1); + // 搜索输入框实时绑定的文本(每键即更新,但不触发请求)。 + let mut search_input = use_signal(String::new); + // 已提交的搜索词:空串表示不搜索。仅在此值变化时才重新请求,避免逐键打 DB。 + let mut search_query = use_signal(String::new); // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。 - // 闭包内读取 current_page(.with)建立 reactive 依赖,翻页时自动重新请求。 + // page 闭包内同时读取 current_page 与 search_query 建立响应式依赖: + // 翻页、或提交新搜索词(即便停留在第 1 页)都会自动重新请求。 + // fetch 闭包在发起请求时读取 search_query 的当前值传给后端按标题过滤。 let paginated = use_paginated( - move || current_page.with(|p| *p), + move || { + let _ = search_query(); + current_page.with(|p| *p) + }, POSTS_PER_PAGE, - |p, pp| async move { - list_posts(p, pp) - .await - .map(|PostListResponse { posts, total }| (posts, total)) + move |p, pp| { + let q = search_query(); + async move { + list_posts(p, pp, if q.is_empty() { None } else { Some(q) }) + .await + .map(|PostListResponse { posts, total }| (posts, total)) + .map_err(|e| e.to_string()) + } }, ); let mut posts = paginated.items; @@ -165,18 +178,65 @@ fn AllPostsList() -> Element { // 的覆盖先点的,故用 HashSet),按行通过 contains 判断 loading 态。 let mut rebuilding = use_signal(std::collections::HashSet::::new); let get_posts = move || -> Vec { posts() }; + // 是否处于搜索结果视图(用于区分空状态文案 / 隐藏「写文章」入口)。 + let is_searching = move || !search_query().is_empty(); + // 提交搜索:写入 search_query 并回到第 1 页(搜索结果从首页开始分页)。 + let mut submit_search = move || { + let q = search_input().trim().to_string(); + search_query.set(q); + current_page.set(1); + }; rsx! { + // 搜索条:按标题过滤文章(仅管理后台用,覆盖草稿)。 + div { class: "flex gap-2 mb-4", + 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: "{search_input()}", + oninput: move |e| search_input.set(e.value()), + onkeydown: move |e| { + if e.key() == Key::Enter { + submit_search(); + } + }, + } + button { + class: "{BTN_PRIMARY}", + onclick: move |_| submit_search(), + "搜索" + } + if is_searching() { + button { + class: "{BTN_OUTLINE}", + onclick: move |_| { + search_input.set(String::new()); + search_query.set(String::new()); + current_page.set(1); + }, + "清除" + } + } + } + if loading() && posts().is_empty() { DelayedSkeleton { PostsSkeleton {} } } else if posts().is_empty() { - EmptyState { - title: "暂无文章", - description: "还没有创建任何文章,开始写下你的第一篇文字吧。", - action: EmptyStateAction { - label: "写文章".to_string(), - to: Route::Write {}, - }, + if is_searching() { + EmptyState { + title: "未找到匹配的文章", + description: "换个标题关键词再试一次。", + } + } else { + EmptyState { + title: "暂无文章", + description: "还没有创建任何文章,开始写下你的第一篇文字吧。", + action: EmptyStateAction { + label: "写文章".to_string(), + to: Route::Write {}, + }, + } } } else { div { class: "{ADMIN_TABLE_CLASS}",