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};
|
pub use read::{get_post_by_id, get_post_by_slug};
|
||||||
/// 重新渲染文章的 Markdown HTML 与目录。
|
/// 重新渲染文章的 Markdown HTML 与目录。
|
||||||
pub use rebuild::rebuild_content_html;
|
pub use rebuild::rebuild_content_html;
|
||||||
|
/// 重新渲染指定文章的 Markdown HTML 与目录(单篇)。
|
||||||
|
pub use rebuild::rebuild_post_content_html;
|
||||||
/// 全文搜索已发布文章。
|
/// 全文搜索已发布文章。
|
||||||
pub use search::search_posts;
|
pub use search::search_posts;
|
||||||
/// 获取文章统计信息。
|
/// 获取文章统计信息。
|
||||||
|
|||||||
@ -11,7 +11,7 @@ use dioxus::prelude::*;
|
|||||||
use super::helpers::get_current_admin_user;
|
use super::helpers::get_current_admin_user;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::api::error::AppError;
|
use crate::api::error::AppError;
|
||||||
use crate::api::posts::RebuildResult;
|
use crate::api::posts::{CreatePostResponse, RebuildResult};
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::db::pool::get_conn;
|
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,
|
// 分页数据接口:list_posts 是 server function,两端都生成(wasm 端为 client stub,
|
||||||
// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
|
// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
|
||||||
use crate::api::posts::{list_posts, PostListResponse};
|
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::empty_state::{EmptyState, EmptyStateAction};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
||||||
use crate::components::ui::{
|
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::hooks::query::use_paginated;
|
||||||
use crate::models::post::PostListItem;
|
use crate::models::post::PostListItem;
|
||||||
@ -56,12 +58,14 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
|
|
||||||
// 删除中 ID、重建缓存状态与结果仍由本组件持有(业务逻辑不归 hook 管)。
|
// 删除中 ID、重建缓存状态与结果仍由本组件持有(业务逻辑不归 hook 管)。
|
||||||
let mut deleting = use_signal(|| None::<i32>);
|
let mut deleting = use_signal(|| None::<i32>);
|
||||||
|
// 单篇重建中 ID:与 deleting 同形,按行禁用按钮并切换文案。
|
||||||
|
let mut rebuilding = use_signal(|| None::<i32>);
|
||||||
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar:结果消息也在本组件
|
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar:结果消息也在本组件
|
||||||
// 渲染(header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
|
// 渲染(header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
|
||||||
// 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。
|
// 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。
|
||||||
// 不加 mut:本组件只读信号并下发,.set() 都在 RebuildCacheBar 的 spawn 块里,
|
// 不加 mut:本组件只读信号并下发,.set() 都在 RebuildCacheBar 的 spawn 块里,
|
||||||
// 走 Signal 的内部可变性;SSR target 下那些 set 不可见,mut 会触发 unused_mut。
|
// 走 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 rebuild_result = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
let get_posts = move || -> Vec<PostListItem> { posts() };
|
let get_posts = move || -> Vec<PostListItem> { posts() };
|
||||||
@ -73,7 +77,7 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
div { class: "flex items-center gap-3",
|
div { class: "flex items-center gap-3",
|
||||||
// 重建缓存工具条(抽取为子组件 RebuildCacheBar,见文件末尾)。
|
// 重建缓存工具条(抽取为子组件 RebuildCacheBar,见文件末尾)。
|
||||||
RebuildCacheBar {
|
RebuildCacheBar {
|
||||||
rebuilding: rebuilding,
|
rebuilding: batch_rebuilding,
|
||||||
rebuild_result: rebuild_result,
|
rebuild_result: rebuild_result,
|
||||||
}
|
}
|
||||||
Link {
|
Link {
|
||||||
@ -125,6 +129,7 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
key: "{post.id}",
|
key: "{post.id}",
|
||||||
post: post.clone(),
|
post: post.clone(),
|
||||||
deleting: deleting() == Some(post.id),
|
deleting: deleting() == Some(post.id),
|
||||||
|
rebuilding: rebuilding() == Some(post.id),
|
||||||
// 删除文章:先乐观更新本地列表,再调用 server function,失败时弹出浏览器提示。
|
// 删除文章:先乐观更新本地列表,再调用 server function,失败时弹出浏览器提示。
|
||||||
on_delete: move |id| {
|
on_delete: move |id| {
|
||||||
deleting.set(Some(id));
|
deleting.set(Some(id));
|
||||||
@ -146,6 +151,31 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
deleting.set(None);
|
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]
|
#[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();
|
let date_str = post.formatted_date();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
@ -281,6 +317,16 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
|
|||||||
to: Route::WriteEdit { id: post.id },
|
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 {
|
button {
|
||||||
class: if deleting { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED },
|
class: if deleting { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED },
|
||||||
disabled: deleting,
|
disabled: deleting,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user