From 0fe0387ea3a0867dd4f3d1ea9214b34ee0c9606c Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 3 Jul 2026 10:13:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin/posts):=20=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=93=8D=E4=BD=9C=E5=88=97=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E9=87=8D=E5=BB=BA=E5=86=85=E5=AE=B9=E3=80=8D=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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。 --- src/api/posts/mod.rs | 2 + src/api/posts/rebuild.rs | 99 +++++++++++++++++++++++++++++++++++++++- src/pages/admin/posts.rs | 56 +++++++++++++++++++++-- 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/src/api/posts/mod.rs b/src/api/posts/mod.rs index 0ee592b..6afe2e5 100644 --- a/src/api/posts/mod.rs +++ b/src/api/posts/mod.rs @@ -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; /// 获取文章统计信息。 diff --git a/src/api/posts/rebuild.rs b/src/api/posts/rebuild.rs index 169092d..39d5de6 100644 --- a/src/api/posts/rebuild.rs +++ b/src/api/posts/rebuild.rs @@ -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 Result { + 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:: + } 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, + }) + } +} diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index 3b104bf..6c96e34 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -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::); + // 单篇重建中 ID:与 deleting 同形,按行禁用按钮并切换文案。 + let mut rebuilding = use_signal(|| None::); // 重建缓存的状态由本组件持有并下发给 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::::None); let get_posts = move || -> Vec { 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) -> Element { +fn PostRow( + post: PostListItem, + deleting: bool, + rebuilding: bool, + on_delete: EventHandler, + on_rebuild: EventHandler, +) -> Element { let date_str = post.formatted_date(); rsx! { @@ -281,6 +317,16 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler) -> 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,