feat(admin): 文章列表支持按标题搜索
为 /admin/posts 的「全部文章」tab 增加按标题搜索能力: - list_posts server fn 增加可选 search: Option<String> 参数,非空时按 title ILIKE 子串匹配(转义 % / _ / \,限长 200 字符)过滤草稿+已发布, 否则走原全量列表路径;COUNT 与分页查询同步带过滤条件。 - AllPostsList 增加搜索输入条(回车 / 搜索按钮提交,清除按钮复位), search_query signal 在 use_paginated 的 page 闭包内同步读取以建立响应式 依赖:提交新词即使停在首页也会重新请求;fetch 闭包请求时读取当前词传后端。 搜索结果为空时展示「未找到匹配的文章」空状态。 - 同步更新 dashboard.rs 与 query.rs 文档示例中的 list_posts 调用签名。
This commit is contained in:
parent
8ea51cab7b
commit
b4e15ecbff
@ -117,7 +117,11 @@ pub async fn list_published_posts(
|
|||||||
///
|
///
|
||||||
/// 需要 admin 权限;结果按创建时间降序,不走缓存。
|
/// 需要 admin 权限;结果按创建时间降序,不走缓存。
|
||||||
#[server(ListPosts, "/api")]
|
#[server(ListPosts, "/api")]
|
||||||
pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, ServerFnError> {
|
pub async fn list_posts(
|
||||||
|
page: i32,
|
||||||
|
per_page: i32,
|
||||||
|
search: Option<String>,
|
||||||
|
) -> Result<PostListResponse, ServerFnError> {
|
||||||
// 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
|
// 与公开接口保持一致的分页钳制,避免单次请求拉取过多记录。
|
||||||
let (page, per_page) = clamp_pagination(page, per_page);
|
let (page, per_page) = clamp_pagination(page, per_page);
|
||||||
let _user = get_current_admin_user().await?;
|
let _user = get_current_admin_user().await?;
|
||||||
@ -126,32 +130,87 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
|
|||||||
{
|
{
|
||||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
let count_row = client
|
// 归一化标题搜索词:trim 后为空则视为不搜索;限长 200 字符并转义 SQL
|
||||||
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
|
// LIKE 通配符(% / _ / \),避免用户输入导致模式错配或全表误匹配。
|
||||||
.await
|
// 与 search.rs 的全文检索不同:管理后台需覆盖草稿,且仅按标题匹配。
|
||||||
.map_err(AppError::query)?;
|
let title_filter: Option<String> = search
|
||||||
let total: i64 = count_row.get(0);
|
.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 offset = ((page - 1).max(0) as i64) * (per_page as i64);
|
||||||
let limit = per_page as i64;
|
let limit = per_page as i64;
|
||||||
let rows = client
|
|
||||||
.query(
|
// 有搜索词时按 title ILIKE 子串匹配过滤(双侧 %),否则走原全量列表。
|
||||||
"SELECT
|
// posts 表对管理后台属低频访问、行数有限,ILIKE 全表扫可接受,靠 LIMIT 兜底。
|
||||||
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
let (total, rows) = if let Some(esc) = title_filter {
|
||||||
p.published_at, p.created_at, p.updated_at, p.cover_image,
|
let pattern = format!("%{esc}%");
|
||||||
p.word_count, p.reading_time,
|
let total: i64 = client
|
||||||
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
|
.query_one(
|
||||||
FROM posts p
|
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL AND title ILIKE $1 ESCAPE '\\'",
|
||||||
LEFT JOIN post_tags pt ON p.id = pt.post_id
|
&[&pattern],
|
||||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
)
|
||||||
WHERE p.deleted_at IS NULL
|
.await
|
||||||
GROUP BY p.id
|
.map_err(AppError::query)?
|
||||||
ORDER BY p.created_at DESC
|
.get(0);
|
||||||
LIMIT $1 OFFSET $2",
|
let rows = client
|
||||||
&[&limit, &offset],
|
.query(
|
||||||
)
|
"SELECT
|
||||||
.await
|
p.id, p.author_id, p.title, p.slug, p.summary, p.status,
|
||||||
.map_err(AppError::query)?;
|
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();
|
let posts: Vec<_> = rows.iter().map(row_to_post_list_item).collect();
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,7 @@ pub struct PaginatedState<T> {
|
|||||||
/// || current_page,
|
/// || current_page,
|
||||||
/// POSTS_PER_PAGE,
|
/// POSTS_PER_PAGE,
|
||||||
/// |p, pp| async move {
|
/// |p, pp| async move {
|
||||||
/// list_posts(p, pp).await
|
/// list_posts(p, pp, None).await
|
||||||
/// .map(|r| (r.posts, r.total))
|
/// .map(|r| (r.posts, r.total))
|
||||||
/// .map_err(|e| e.to_string())
|
/// .map_err(|e| e.to_string())
|
||||||
/// },
|
/// },
|
||||||
|
|||||||
@ -37,7 +37,7 @@ pub fn Admin() -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
spawn(async move {
|
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));
|
recent_posts.set(Some(posts));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -139,17 +139,30 @@ pub fn Posts() -> Element {
|
|||||||
/// 建立依赖,页码变化自动重载),不走路由。删除/重建逻辑与旧实现一致。
|
/// 建立依赖,页码变化自动重载),不走路由。删除/重建逻辑与旧实现一致。
|
||||||
#[component]
|
#[component]
|
||||||
fn AllPostsList() -> Element {
|
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 统一管理。
|
// 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。
|
||||||
// 闭包内读取 current_page(.with)建立 reactive 依赖,翻页时自动重新请求。
|
// page 闭包内同时读取 current_page 与 search_query 建立响应式依赖:
|
||||||
|
// 翻页、或提交新搜索词(即便停留在第 1 页)都会自动重新请求。
|
||||||
|
// fetch 闭包在发起请求时读取 search_query 的当前值传给后端按标题过滤。
|
||||||
let paginated = use_paginated(
|
let paginated = use_paginated(
|
||||||
move || current_page.with(|p| *p),
|
move || {
|
||||||
|
let _ = search_query();
|
||||||
|
current_page.with(|p| *p)
|
||||||
|
},
|
||||||
POSTS_PER_PAGE,
|
POSTS_PER_PAGE,
|
||||||
|p, pp| async move {
|
move |p, pp| {
|
||||||
list_posts(p, pp)
|
let q = search_query();
|
||||||
.await
|
async move {
|
||||||
.map(|PostListResponse { posts, total }| (posts, total))
|
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;
|
let mut posts = paginated.items;
|
||||||
@ -165,18 +178,65 @@ fn AllPostsList() -> Element {
|
|||||||
// 的覆盖先点的,故用 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);
|
||||||
let get_posts = move || -> Vec<PostListItem> { posts() };
|
let get_posts = move || -> Vec<PostListItem> { 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! {
|
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() {
|
if loading() && posts().is_empty() {
|
||||||
DelayedSkeleton { PostsSkeleton {} }
|
DelayedSkeleton { PostsSkeleton {} }
|
||||||
} else if posts().is_empty() {
|
} else if posts().is_empty() {
|
||||||
EmptyState {
|
if is_searching() {
|
||||||
title: "暂无文章",
|
EmptyState {
|
||||||
description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
|
title: "未找到匹配的文章",
|
||||||
action: EmptyStateAction {
|
description: "换个标题关键词再试一次。",
|
||||||
label: "写文章".to_string(),
|
}
|
||||||
to: Route::Write {},
|
} else {
|
||||||
},
|
EmptyState {
|
||||||
|
title: "暂无文章",
|
||||||
|
description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
|
||||||
|
action: EmptyStateAction {
|
||||||
|
label: "写文章".to_string(),
|
||||||
|
to: Route::Write {},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
div { class: "{ADMIN_TABLE_CLASS}",
|
div { class: "{ADMIN_TABLE_CLASS}",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user