Skip to main content

yggdrasil/pages/admin/
preview.rs

1//! 草稿/文章预览页面模块(管理员只读)。
2//!
3//! 对应路由 `/admin/preview/:slug`。
4//!
5//! 与公开详情页 [`crate::pages::post_detail`] 的关键差异:
6//! - 取数走 admin-only [`crate::api::posts::get_post_preview`],不做 `status = 'published'` 过滤,
7//!   故草稿可预览;草稿绝不进公开缓存(`cache::get/set_post_by_slug`)。
8//! - 顶部浮起预览条(状态、桌面 / 手机宽度切换、返回编辑、草稿可直接发布),
9//!   手机档把正文放进 390px 画框。
10//! - 文末说明评论区在预览中隐藏。
11//! - 错误/未命中**就地渲染**,不向错误边界上抛。
12//! - 不渲染评论区(草稿无评论)。
13//!
14//! # 为何预览放在 `/admin/*` 下
15//! SSR HTML 缓存按 URL 落盘/入内存,不区分用户身份。若管理员在 `/post/<draft-slug>`
16//! 看到草稿,渲染出的草稿 HTML 会被缓存,匿名访客再请求同一 URL 即命中缓存、直接拿到
17//! 草稿正文——草稿属保密内容,泄漏不可接受。`/admin/preview/<slug>` 受 `admin_guard`
18//! 中间件保护:匿名在 SSR 渲染前就被 302 跳走,不产生可泄漏的缓存。
19//!
20//! # 取数
21//! 后台数据是私有的,SSR 不取数:直接访问首屏为骨架屏,水合后由 Effect 拉取(数据加载
22//! 规则 2)。以路由参数 `slug` 为键,带请求世代号;`slug` 变化时先清空旧文章再取新文章,
23//! 发布后原地刷新(不清空)。
24
25use crate::api::posts::{get_post_preview, SinglePostResponse};
26use crate::components::empty_state::{EmptyState, EmptyStateAction};
27use crate::components::post::post_content::PostContent;
28use crate::components::post::post_cover::PostCover;
29use crate::components::post::post_footer::PostFooter;
30use crate::components::post::post_header::PostHeader;
31use crate::components::post::post_toc::PostToc;
32use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
33use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
34use crate::components::ui::{
35    button_class, use_toast, ButtonSize, ButtonVariant, Icon, IconName, SegmentedControl,
36    SegmentedItem, SegmentedKind, SegmentedSize,
37};
38use crate::models::post::{Post, PostFooterData, PostHeaderData, PostStatus};
39use crate::routes::Route;
40use crate::utils::error::failure_message;
41use leptos::prelude::*;
42use leptos_router::hooks::use_params_map;
43use leptos_router::{lazy_route, LazyRoute};
44
45/// 预览路由:纯客户端数据,无 SSR 预取。
46pub struct PreviewRoute;
47
48#[lazy_route]
49impl LazyRoute for PreviewRoute {
50    fn data() -> Self {
51        Self
52    }
53
54    fn view(_this: Self) -> AnyView {
55        view! { <PostPreview/> }.into_any()
56    }
57}
58
59/// 页面主体的互斥状态;用 `Memo` 去重,同一篇文章内的状态刷新不会重建整个预览。
60#[derive(Clone, PartialEq)]
61enum Branch {
62    /// 取数中,或结果还不属于当前 slug。
63    Loading,
64    /// 文章不存在、已进回收站或加载失败。
65    Missing,
66    /// 当前 slug 的文章已就绪。
67    Ready,
68}
69
70/// 草稿/文章预览页面组件(管理员只读),对应路由 `/admin/preview/:slug`。
71///
72/// 渲染与公开详情页一致的正文(头部、封面、目录、正文、页脚),顶部加预览横幅。
73/// 加载中显示骨架屏;文章不存在或加载失败时就地渲染提示,不向上抛错。
74#[component]
75pub fn PostPreview() -> impl IntoView {
76    let params = use_params_map();
77    let slug = Memo::new(move |_| params.read().get("slug").unwrap_or_default());
78
79    let toast = use_toast();
80    let mobile = RwSignal::new(false);
81    let publishing = RwSignal::new(false);
82    // 发布后原地刷新状态。
83    let reload = RwSignal::new(0_u32);
84    // None=加载中;Some(None)=未找到/失败;Some(Some(post))=成功。错误与未命中同视图。
85    let post = RwSignal::new(None::<Option<Post>>);
86    // 当前 `post` 对应的 slug:路由参数变了而新结果未到时,不能暴露旧文章。
87    let loaded_slug = RwSignal::new(String::new());
88
89    // 请求世代号:slug 变化、发布刷新、卸载都会递增,旧响应据此丢弃。
90    let generation = StoredValue::new(0_u64);
91    let bump_generation = move || {
92        generation
93            .try_update_value(|g| {
94                *g = g.wrapping_add(1);
95                *g
96            })
97            .unwrap_or(0)
98    };
99    on_cleanup(move || {
100        bump_generation();
101    });
102
103    Effect::new(move |_| {
104        let current_slug = slug.get();
105        let _ = reload.get();
106        if loaded_slug.with_untracked(|s| s != &current_slug) {
107            post.set(None);
108        }
109        let request_id = bump_generation();
110        leptos::task::spawn_local(async move {
111            let resp = get_post_preview(current_slug.clone()).await;
112            if generation.try_get_value() != Some(request_id) {
113                return;
114            }
115            post.try_set(Some(resp.ok().and_then(|SinglePostResponse { post }| post)));
116            loaded_slug.try_set(current_slug);
117        });
118    });
119
120    let branch = Memo::new(move |_| {
121        if loaded_slug.with(|l| l != &slug.get()) {
122            return Branch::Loading;
123        }
124        match post.with(|p| p.as_ref().map(Option::is_some)) {
125            None => Branch::Loading,
126            Some(false) => Branch::Missing,
127            Some(true) => Branch::Ready,
128        }
129    });
130
131    move || match branch.get() {
132        Branch::Loading => view! {
133            <DelayedSkeleton>
134                <PostPreviewSkeleton/>
135            </DelayedSkeleton>
136        }
137        .into_any(),
138        Branch::Missing => view! {
139            <div class="admin-panel">
140                <EmptyState
141                    icon=IconName::FileX
142                    title="找不到这篇文章"
143                    description="它可能已被移到回收站或彻底删除。"
144                    action=EmptyStateAction::new(
145                        "返回文章",
146                        move |_| crate::navigation::push(&Route::Posts.to_string()),
147                    )
148                />
149            </div>
150        }
151        .into_any(),
152        Branch::Ready => {
153            let Some((post_id, post_slug)) = post.with_untracked(|p| {
154                p.as_ref()
155                    .and_then(Option::as_ref)
156                    .map(|p| (p.id, p.slug.clone()))
157            }) else {
158                return ().into_any();
159            };
160            let draft = Memo::new(move |_| {
161                post.with(|p| {
162                    p.as_ref()
163                        .and_then(Option::as_ref)
164                        .is_none_or(|p| p.status != PostStatus::Published)
165                })
166            });
167            let publish = move |_| {
168                if publishing.get_untracked() {
169                    return;
170                }
171                publishing.set(true);
172                leptos::task::spawn_local(async move {
173                    match crate::api::posts::batch_posts(
174                        vec![post_id],
175                        crate::api::posts::BatchPostAction::Publish,
176                    )
177                    .await
178                    {
179                        Ok(res) if res.done == 1 => {
180                            toast.success("已发布");
181                            reload.try_update(|n| *n += 1);
182                        }
183                        Ok(_) => toast.error("发布失败,请回到编辑页检查后重试"),
184                        Err(e) => toast.error(failure_message("发布失败", &e)),
185                    }
186                    publishing.try_set(false);
187                });
188            };
189
190            view! {
191                <div
192                    class="post-preview"
193                    data-width=move || if mobile.get() { "mobile" } else { "desktop" }
194                >
195                    <div class="preview-bar-slot">
196                        <div class="preview-bar" role="region" aria-label="预览状态">
197                            <span class="preview-bar-state">
198                                <span
199                                    class="preview-bar-dot"
200                                    data-draft=move || draft.get().then_some("true")
201                                    aria-hidden="true"
202                                ></span>
203                                <strong>{move || if draft.get() { "草稿预览" } else { "文章预览" }}</strong>
204                                <span class="preview-bar-hint">
205                                    {move || {
206                                        if draft.get() {
207                                            "仅管理员可见 · 显示最近一次保存的内容"
208                                        } else {
209                                            "已发布 · 显示最近一次保存的内容"
210                                        }
211                                    }}
212                                </span>
213                            </span>
214                            <span class="preview-bar-sep" aria-hidden="true"></span>
215                            <div class="preview-bar-actions">
216                                <SegmentedControl
217                                    items=vec![
218                                        SegmentedItem::new(false, "桌面"),
219                                        SegmentedItem::new(true, "手机"),
220                                    ]
221                                    value=mobile
222                                    on_change=move |v| mobile.set(v)
223                                    aria_label="预览宽度"
224                                    kind=SegmentedKind::Radio
225                                    size=SegmentedSize::Sm
226                                    class="preview-viewport-switch"
227                                />
228                                <a
229                                    class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
230                                    href=Route::WriteEdit { id: post_id }.to_string()
231                                >
232                                    "返回编辑"
233                                </a>
234                                {move || {
235                                    if draft.get() {
236                                        view! {
237                                            <button
238                                                class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
239                                                type="button"
240                                                disabled=move || publishing.get()
241                                                on:click=publish
242                                            >
243                                                {move || if publishing.get() { "发布中…" } else { "发布" }}
244                                            </button>
245                                        }
246                                            .into_any()
247                                    } else {
248                                        view! {
249                                            <a
250                                                class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
251                                                href=format!("/post/{post_slug}")
252                                                target="_blank"
253                                                rel="noopener"
254                                            >
255                                                "查看文章"
256                                                <Icon name=IconName::ArrowUpRight size=13/>
257                                            </a>
258                                        }
259                                            .into_any()
260                                    }
261                                }}
262                            </div>
263                        </div>
264                    </div>
265
266                    <div class="preview-frame">
267                        <article
268                            class="post-single animate-page-enter"
269                            data-vt-detail=post_id.to_string()
270                        >
271                            {move || post.with(|p| p.as_ref().and_then(Option::as_ref).map(post_body))}
272                        </article>
273                    </div>
274                </div>
275            }
276            .into_any()
277        }
278    }
279}
280
281/// 文章正文区:头部、封面、目录、正文、页脚与评论区说明。随文章数据变化(发布后)重建。
282fn post_body(post: &Post) -> impl IntoView {
283    view! {
284        <PostHeader post=PostHeaderData::from(post)/>
285
286        // 如果文章设置了封面图,则渲染封面组件。
287        {post
288            .cover_image
289            .clone()
290            .map(|cover| view! { <PostCover src=cover post_id=post.id/> })}
291
292        {post.toc_html.clone().map(|toc| view! { <PostToc toc_html=toc/> })}
293
294        <PostContent content_html=post.content_html.clone().unwrap_or_default()/>
295        <PostFooter post=PostFooterData::from(post)/>
296        <p class="preview-note">"评论区在预览中隐藏。发布后读者看到的版式与这里一致。"</p>
297    }
298}