feat(admin/posts): 文章列表操作列新增「重建内容」按钮

新增 rebuild_post_content_html(post_id) server function,从 DB 读取
content_md(事务内 FOR UPDATE 锁行)重新渲染 content_html/toc_html
并更新 word_count/reading_time,按影响范围精准失效列表、slug 单篇
与搜索缓存。

PostRow 操作列在「编辑」「删除」之间插入「重建内容」按钮(鼠尾草绿
文字样式),按行禁用并切换「重建中...」文案,成功/失败弹出浏览器
提示,与删除按钮的反馈模式一致。

为避免与单篇 rebuilding 信号冲突,将原批量重建工具条信号 renaming
为 batch_rebuilding。
This commit is contained in:
xfy 2026-07-03 10:13:15 +08:00
parent 687c56af19
commit 0fe0387ea3
3 changed files with 151 additions and 6 deletions

View File

@ -36,6 +36,8 @@ pub use list::{get_posts_by_tag, list_published_posts};
pub use read::{get_post_by_id, get_post_by_slug};
/// 重新渲染文章的 Markdown HTML 与目录。
pub use rebuild::rebuild_content_html;
/// 重新渲染指定文章的 Markdown HTML 与目录(单篇)。
pub use rebuild::rebuild_post_content_html;
/// 全文搜索已发布文章。
pub use search::search_posts;
/// 获取文章统计信息。

View File

@ -11,7 +11,7 @@ use dioxus::prelude::*;
use super::helpers::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError;
use crate::api::posts::RebuildResult;
use crate::api::posts::{CreatePostResponse, RebuildResult};
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
@ -150,3 +150,100 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
})
}
}
/// 重新渲染指定文章的 content_html 与 toc_html。
///
/// 从数据库读取 content_md`FOR UPDATE` 锁行,避免并发编辑产生非可重复读),
/// 在阻塞线程池渲染 HTML并更新 content_html / toc_html / word_count / reading_time。
/// 仅 admin 可调用成功后按影响范围失效文章列表、slug 单篇与搜索缓存。
#[server(RebuildPostContentHtml, "/api")]
pub async fn rebuild_post_content_html(
post_id: i32,
) -> Result<CreatePostResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let mut client = get_conn().await.map_err(AppError::db_conn)?;
let tx = client.transaction().await.map_err(AppError::tx)?;
// 在事务内锁行并读取 content_md / slug避免并发更新导致用旧内容覆盖新内容
// 或缓存失效目标过期(与 delete_post 一致的事务策略)。
let row = tx
.query_opt(
"SELECT content_md, slug FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
&[&post_id],
)
.await
.map_err(AppError::query)?;
let Some(row) = row else {
return Ok(CreatePostResponse {
success: false,
message: "文章不存在".to_string(),
post_id: None,
slug: None,
});
};
let content_md: String = row.get(0);
let slug: String = row.get(1);
// Markdown 渲染在阻塞线程池执行CPU 密集)。
let md_for_render = content_md.clone();
let rendered = tokio::task::spawn_blocking(move || {
crate::api::markdown::render_markdown_enhanced(&md_for_render)
})
.await
.map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
let toc_html = if rendered.toc_html.is_empty() {
None::<String>
} else {
Some(rendered.toc_html)
};
let word_count = crate::utils::text::count_words(&content_md);
let reading_time = crate::utils::text::reading_time(word_count);
tx.execute(
"UPDATE posts SET content_html = $1, toc_html = $2, word_count = $3, reading_time = $4 WHERE id = $5",
&[
&rendered.html,
&toc_html,
&(word_count as i32),
&(reading_time as i32),
&post_id,
],
)
.await
.map_err(AppError::query)?;
tx.commit().await.map_err(AppError::tx)?;
// 重建修改了 word_count / reading_time 等列表项字段以及单篇 content_html
// 按影响范围精准失效(标签与统计不受影响)。
crate::cache::invalidate_post_lists();
crate::cache::invalidate_search_results();
crate::cache::invalidate_post_by_slug(&slug).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse {
success: true,
message: "重建成功".to_string(),
post_id: Some(post_id),
slug: Some(slug),
})
}
#[cfg(not(feature = "server"))]
{
Ok(CreatePostResponse {
success: false,
message: "server only".to_string(),
post_id: None,
slug: None,
})
}
}

View File

@ -9,12 +9,14 @@ use dioxus::router::components::Link;
// 分页数据接口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::api::posts::{
delete_post, rebuild_content_html, rebuild_post_content_html, CreatePostResponse, RebuildResult,
};
use crate::components::empty_state::{EmptyState, EmptyStateAction};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::components::ui::{
Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED,
Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_ACCENT, BTN_TEXT_RED,
};
use crate::hooks::query::use_paginated;
use crate::models::post::PostListItem;
@ -56,12 +58,14 @@ pub fn PostsPage(page: i32) -> Element {
// 删除中 ID、重建缓存状态与结果仍由本组件持有业务逻辑不归 hook 管)。
let mut deleting = use_signal(|| None::<i32>);
// 单篇重建中 ID与 deleting 同形,按行禁用按钮并切换文案。
let mut rebuilding = use_signal(|| None::<i32>);
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar结果消息也在本组件
// 渲染header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
// 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。
// 不加 mut本组件只读信号并下发.set() 都在 RebuildCacheBar 的 spawn 块里,
// 走 Signal 的内部可变性SSR target 下那些 set 不可见mut 会触发 unused_mut。
let rebuilding = use_signal(|| false);
let batch_rebuilding = use_signal(|| false);
let rebuild_result = use_signal(|| Option::<String>::None);
let get_posts = move || -> Vec<PostListItem> { posts() };
@ -73,7 +77,7 @@ pub fn PostsPage(page: i32) -> Element {
div { class: "flex items-center gap-3",
// 重建缓存工具条(抽取为子组件 RebuildCacheBar见文件末尾
RebuildCacheBar {
rebuilding: rebuilding,
rebuilding: batch_rebuilding,
rebuild_result: rebuild_result,
}
Link {
@ -125,6 +129,7 @@ pub fn PostsPage(page: i32) -> Element {
key: "{post.id}",
post: post.clone(),
deleting: deleting() == Some(post.id),
rebuilding: rebuilding() == Some(post.id),
// 删除文章:先乐观更新本地列表,再调用 server function失败时弹出浏览器提示。
on_delete: move |id| {
deleting.set(Some(id));
@ -146,6 +151,31 @@ pub fn PostsPage(page: i32) -> Element {
deleting.set(None);
});
},
// 重建单篇文章内容:调用 server function 重新渲染 content_html
// 成功/失败均弹出浏览器提示(与删除一致的非乐观反馈)。
on_rebuild: move |id| {
rebuilding.set(Some(id));
spawn(async move {
match rebuild_post_content_html(id).await {
Ok(CreatePostResponse { success: true, .. }) => {
#[cfg(target_arch = "wasm32")]
web_sys::window()
.map(|w| w.alert_with_message("重建成功").ok());
}
Ok(CreatePostResponse { success: false, message: _message, .. }) => {
#[cfg(target_arch = "wasm32")]
web_sys::window()
.map(|w| w.alert_with_message(&_message).ok());
}
Err(_e) => {
#[cfg(target_arch = "wasm32")]
web_sys::window()
.map(|w| w.alert_with_message("重建失败").ok());
}
}
rebuilding.set(None);
});
},
}
}
}
@ -253,7 +283,13 @@ fn RebuildCacheBar(
/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
#[component]
fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) -> Element {
fn PostRow(
post: PostListItem,
deleting: bool,
rebuilding: bool,
on_delete: EventHandler<i32>,
on_rebuild: EventHandler<i32>,
) -> Element {
let date_str = post.formatted_date();
rsx! {
@ -281,6 +317,16 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
to: Route::WriteEdit { id: post.id },
"编辑"
}
button {
class: if rebuilding { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_ACCENT },
disabled: rebuilding,
onclick: move |_| on_rebuild.call(post.id),
if rebuilding {
"重建中..."
} else {
"重建内容"
}
}
button {
class: if deleting { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED },
disabled: deleting,