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:
parent
687c56af19
commit
0fe0387ea3
@ -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;
|
||||
/// 获取文章统计信息。
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user