Skip to main content

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    let render_slug = slug.clone();
58    let requested_slug = use_memo(move || match router.current::<Route>() {
59        Route::PostPreview { slug } => slug,
60        _ => slug.clone(),
61    });
62
63    // 非挂起取数(见模块文档「取数不挂起」一节):None=加载中;
64    // Some(None)=未找到/失败;Some(Some(post))=成功。错误与未命中同视图。
65    #[allow(unused_mut)]
66    let mut post = use_signal(|| None::<Option<Post>>);
67    #[allow(unused_mut)]
68    let mut loaded_slug = use_signal(String::new);
69
70    // 仅订阅实际 slug 变化;Router 在 VT 提交前对旧路由的通知不能清空旧快照。
71    use_effect(move || {
72        let current_slug = requested_slug();
73        // native 构建下 spawn 被编译掉;显式引用避免未用告警(dashboard 同款语义)。
74        #[cfg(not(target_arch = "wasm32"))]
75        let _ = &current_slug;
76        // SSR 不取数(与仪表盘等 admin 页一致):直接 URL 访问首屏为骨架屏,
77        // 客户端水合后再拉取。
78        #[cfg(target_arch = "wasm32")]
79        {
80            post.set(None);
81            spawn(async move {
82                let resp = get_post_preview(current_slug.clone()).await;
83                // 竞态守卫:仅当结果返回时仍停留在本 slug 才写回,
84                // 避免快速切换时慢的旧响应覆盖新文章。
85                let still_here = matches!(router.current::<Route>(),
86                Route::PostPreview { slug: s } if s == current_slug);
87                if still_here {
88                    loaded_slug.set(current_slug);
89                    post.set(Some(resp.ok().and_then(|SinglePostResponse { post }| post)));
90                }
91            });
92        }
93    });
94
95    // 同路由实例复用时,effect 尚未执行的一帧也不能暴露旧文章的共享标记。
96    if loaded_slug() != render_slug {
97        return rsx! { DelayedSkeleton { PostPreviewSkeleton {} } };
98    }
99
100    // admin nest 内无 ErrorBoundary:错误/未命中就地渲染,不向上抛。
101    // None(pending)→ 骨架屏;Some(None)(Err / post=None)→ 居中提示。
102    let post = match post.read().as_ref() {
103        None => {
104            return rsx! {
105                DelayedSkeleton { PostPreviewSkeleton {} }
106            };
107        }
108        Some(None) => {
109            return rsx! {
110                div { class: "flex flex-col items-center justify-center text-center py-20 px-4 animate-page-enter",
111                    p { class: "text-sm text-paper-secondary",
112                        "未找到该文章(可能已被删除)。"
113                    }
114                    Link { class: "mt-6 {BTN_OUTLINE}", "data-vt-return": "true", to: Route::Posts {}, "返回文章列表" }
115                }
116            };
117        }
118        Some(Some(post)) => post.clone(),
119    };
120
121    rsx! {
122        article { class: "post-single animate-page-enter", key: "{post.slug}", "data-vt-detail": "{post.id}",
123            // 预览横幅:状态徽章 + 继续编辑 / 返回列表。
124            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)]",
125                div { class: "flex items-center gap-2 text-sm text-paper-secondary",
126                    span { "预览模式" }
127                    span { class: "px-2 py-0.5 rounded-full text-xs font-medium {post.status.badge_class()}",
128                        "{post.status.label()}"
129                    }
130                }
131                div { class: "flex items-center gap-2",
132                    Link {
133                        class: "{BTN_PRIMARY}",
134                        to: Route::WriteEdit { id: post.id },
135                        "继续编辑"
136                    }
137                    Link { class: "{BTN_OUTLINE}", "data-vt-return": "true", to: Route::Posts {}, "返回列表" }
138                }
139            }
140
141            PostHeader { post: post.clone(), full_reload: true }
142
143            // 如果文章设置了封面图,则渲染封面组件。
144            if let Some(cover) = &post.cover_image {
145                PostCover { src: cover.clone(), post_id: post.id }
146            }
147
148            // 与公开详情页一致:按 slug 强制 remount,重新绑定新文章标题的 scroll-spy。
149            if let Some(toc) = &post.toc_html {
150                for toc_slug in std::iter::once(post.slug.clone()) {
151                    PostToc { key: "{toc_slug}", toc_html: toc.clone() }
152                }
153            }
154
155            // 用单元素 keyed 列表包裹 PostContent,key 绑定 slug。
156            // 与 post_detail.rs 同理:slug 变化时强制 remount,让正文内的脚本/
157            // 编辑器随文章切换重新初始化(详见 post_detail.rs:95-115 注释)。
158            for post_slug in std::iter::once(post.slug.clone()) {
159                PostContent {
160                    key: "{post_slug}",
161                    content_html: post.content_html.clone().unwrap_or_default(),
162                }
163            }
164            PostFooter { post: post.clone(), full_reload: true }
165        }
166    }
167}