yggdrasil/pages/admin/preview.rs
1//! 草稿/文章预览页面模块(管理员只读)。
2//!
3//! 对应路由 `/admin/preview/:slug`。
4//!
5//! 与公开详情页 [`crate::pages::post_detail::PostDetail`] 的关键差异:
6//! - 取数走 admin-only [`crate::api::posts::get_post_preview`],不做 `status = 'published'` 过滤,
7//! 故草稿可预览;草稿绝不进公开缓存(`cache::get/set_post_by_slug`)。
8//! - 顶部渲染预览横幅(状态徽章 + 继续编辑 / 返回列表),便于从预览直达编辑。
9//! - 错误/未命中**就地渲染**(admin nest 内无 ErrorBoundary),不向错误边界上抛。
10//! - 不渲染评论区(草稿无评论)。
11//!
12//! # 为何预览放在 `/admin/*` 下
13//! Dioxus IncrementalRenderer 的 SSR 磁盘缓存按 URL(`path_and_query()`)落盘,
14//! 不区分用户身份。若管理员在 `/post/<draft-slug>` 看到草稿,渲染出的草稿 HTML
15//! 会被缓存到 `static/post/<draft-slug>/`,匿名访客再请求同一 URL 即命中缓存、
16//! 直接拿到草稿正文——草稿属保密内容,泄漏不可接受。`/admin/preview/<slug>` 受
17//! `admin_guard` 中间件保护:匿名在 SSR 渲染前就被 302 跳走,不产生可泄漏的缓存。
18//!
19//! # 取数不挂起(与 post_detail.rs 的关键差异,勿"改回去")
20//! 本页**不用** `use_server_future(...)?`(挂起式取数),而是 `use_signal` +
21//! `spawn` 的非挂起模式(与仪表盘等所有 admin 页一致)。原因:dioxus 0.7.10
22//! 存在 vdom 状态腐蚀 bug——当挂起中的 SuspenseBoundary 连同其后台子树一起
23//! 卸载(本页从 `/admin/preview/*` 客户端导航回前台时正是此路径:
24//! AdminLayout 的 SuspenseBoundary 在 PostPreview future 尚未 resolve 时被整体
25//! 替换),dioxus-core 会对同一元素双重回收(console 报 `cannot reclaim
26//! ElementId(N)`),进而令 interpreter 的节点表失步(`RawInterpreter.run` 崩溃
27//! `Cannot read properties of undefined (reading 'listening')`),此后所有点击
28//! 事件处理失效(表现为"列表点不动")。前台页面虽同样挂起,但前台内部导航
29//! 不卸载 FrontendLayout 的 boundary,触发不了该路径;admin 侧只有本页挂起。
30//! 上游 master 的 suspense 卸载逻辑与 0.7.10 相同(未修复);若日后升级
31//! dioxus 修复了此 bug,可考虑改回挂起式取数以恢复 SSR 数据内嵌。
32
33#[cfg(target_arch = "wasm32")]
34use crate::api::posts::get_post_preview;
35#[cfg(target_arch = "wasm32")]
36use crate::api::posts::SinglePostResponse;
37use crate::components::post::post_content::PostContent;
38use crate::components::post::post_cover::PostCover;
39use crate::components::post::post_footer::PostFooter;
40use crate::components::post::post_header::PostHeader;
41use crate::components::post::post_toc::PostToc;
42use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
43use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
44use crate::components::ui::{BTN_OUTLINE, BTN_PRIMARY};
45use crate::models::post::Post;
46use crate::router::Route;
47use dioxus::prelude::*;
48use dioxus::router::components::Link;
49
50/// 草稿/文章预览页面组件(管理员只读),对应路由 `/admin/preview/:slug`。
51///
52/// 渲染与公开详情页一致的正文(头部、封面、目录、正文、页脚),顶部加预览横幅。
53/// 加载中显示骨架屏;文章不存在或加载失败时就地渲染提示,不向上抛错。
54#[component]
55pub fn PostPreview(slug: String) -> Element {
56 let router = dioxus::router::router();
57
58 // 非挂起取数(见模块文档「取数不挂起」一节):None=加载中;
59 // Some(None)=未找到/失败;Some(Some(post))=成功。错误与未命中同视图。
60 #[allow(unused_mut)]
61 let mut post = use_signal(|| None::<Option<Post>>);
62
63 // 在 effect 内读取当前路由 slug 建立订阅:slug 变化(同为 PostPreview
64 // 变体复用组件实例)时重新拉取,并先回骨架屏。
65 use_effect(move || {
66 let current_slug = match router.current::<Route>() {
67 Route::PostPreview { slug } => slug,
68 // 组件卸载/路由切走的瞬间可能命中其它变体,退回用 prop 值兜底。
69 _ => slug.clone(),
70 };
71 // native 构建下 spawn 被编译掉;显式引用避免未用告警(dashboard 同款语义)。
72 #[cfg(not(target_arch = "wasm32"))]
73 let _ = ¤t_slug;
74 // SSR 不取数(与仪表盘等 admin 页一致):直接 URL 访问首屏为骨架屏,
75 // 客户端水合后再拉取。
76 #[cfg(target_arch = "wasm32")]
77 spawn(async move {
78 let resp = get_post_preview(current_slug.clone()).await;
79 // 竞态守卫:仅当结果返回时仍停留在本 slug 才写回,
80 // 避免快速切换时慢的旧响应覆盖新文章。
81 let still_here = matches!(router.current::<Route>(),
82 Route::PostPreview { slug: s } if s == current_slug);
83 if still_here {
84 post.set(Some(resp.ok().and_then(|SinglePostResponse { post }| post)));
85 }
86 });
87 });
88
89 // admin nest 内无 ErrorBoundary:错误/未命中就地渲染,不向上抛。
90 // None(pending)→ 骨架屏;Some(None)(Err / post=None)→ 居中提示。
91 let post = match post.read().as_ref() {
92 None => {
93 return rsx! {
94 DelayedSkeleton { PostPreviewSkeleton {} }
95 };
96 }
97 Some(None) => {
98 return rsx! {
99 div { class: "flex flex-col items-center justify-center text-center py-20 px-4 animate-page-enter",
100 p { class: "text-sm text-paper-secondary",
101 "未找到该文章(可能已被删除)。"
102 }
103 Link { class: "mt-6 {BTN_OUTLINE}", to: Route::Posts {}, "返回文章列表" }
104 }
105 };
106 }
107 Some(Some(post)) => post.clone(),
108 };
109
110 rsx! {
111 article { class: "post-single animate-page-enter", key: "{post.slug}",
112 // 预览横幅:状态徽章 + 继续编辑 / 返回列表。
113 div { class: "flex flex-wrap items-center justify-between gap-3 mb-6 p-3 rounded-2xl bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)]",
114 div { class: "flex items-center gap-2 text-sm text-paper-secondary",
115 span { "预览模式" }
116 span { class: "px-2 py-0.5 rounded-full text-xs font-medium {post.status.badge_class()}",
117 "{post.status.label()}"
118 }
119 }
120 div { class: "flex items-center gap-2",
121 Link {
122 class: "{BTN_PRIMARY}",
123 to: Route::WriteEdit { id: post.id },
124 "继续编辑"
125 }
126 Link { class: "{BTN_OUTLINE}", to: Route::Posts {}, "返回列表" }
127 }
128 }
129
130 PostHeader { post: post.clone(), full_reload: true }
131
132 // 如果文章设置了封面图,则渲染封面组件。
133 if let Some(cover) = &post.cover_image {
134 PostCover { src: cover.clone() }
135 }
136
137 // 如果文章生成了目录 HTML,则渲染目录组件。
138 if let Some(toc) = &post.toc_html {
139 PostToc { toc_html: toc.clone() }
140 }
141
142 // 用单元素 keyed 列表包裹 PostContent,key 绑定 slug。
143 // 与 post_detail.rs 同理:slug 变化时强制 remount,让正文内的脚本/
144 // 编辑器随文章切换重新初始化(详见 post_detail.rs:95-115 注释)。
145 for post_slug in std::iter::once(post.slug.clone()) {
146 PostContent {
147 key: "{post_slug}",
148 content_html: post.content_html.clone().unwrap_or_default(),
149 }
150 }
151 PostFooter { post: post.clone(), full_reload: true }
152 }
153 }
154}