Skip to main content

yggdrasil/pages/
changelog.rs

1//! 更新日志页面模块。
2//!
3//! 对应路由 `/changelog`。
4//!
5//! 数据获取:`use_server_future` 调用 `get_changelog` server function,取回
6//! 编译期内嵌 CHANGELOG.md 的**结构化解析结果**(版本 → 分类 → 条目 HTML)。
7//! 内容随二进制版本固定,只受 SSR 页面缓存 TTL 约束,无需任何主动失效。
8//! 页面无路由参数、future 不会重跑,因此无需 `router().current()` 订阅
9//! (该陷阱详见 `post_detail.rs` 头文档)。
10//!
11//! # 布局
12//! 双栏:左侧 sticky 版本导航(桌面端)+ 右侧版本卡片列表。
13//! 每个版本卡片内按分类(Added / Fixed / Security …)分组,每组带色标 badge。
14//! 配色遵循全站 Catppuccin 双强调色约束(详见 `changelog.rs` 模块文档)。
15
16use dioxus::prelude::*;
17
18use crate::api::changelog::{get_changelog, ChangeGroup, ChangelogData, VersionEntry};
19use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
20use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
21
22/// 更新日志页面组件,对应路由 `/changelog`。
23///
24/// 结构:页头 → 统计栏 → 双栏(版本导航 + 版本卡片列表)。
25#[component]
26pub fn Changelog() -> Element {
27    let response = use_server_future(get_changelog)?;
28
29    // 与 post_detail 同一约定:None(加载中)→ 骨架屏;Err → 抛给错误边界。
30    let data = response.read().as_ref().map(|r| match r {
31        Ok(resp) => Ok(resp.clone()),
32        Err(e) => Err(e.clone()),
33    });
34
35    let ChangelogData { versions } = match data {
36        Some(Ok(resp)) => resp,
37        Some(Err(err)) => return Err(err.into()),
38        None => {
39            return rsx! {
40                DelayedSkeleton { PostDetailSkeleton {} }
41            };
42        }
43    };
44
45    let total = versions
46        .iter()
47        .filter(|v| v.version != "Unreleased")
48        .count();
49    let latest = versions.iter().find(|v| v.is_latest);
50
51    rsx! {
52        div { class: "animate-page-enter",
53            header { class: "page-header mb-6",
54                h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
55                    "更新日志"
56                }
57            }
58
59            // 统计栏:最新版本 + 版本总数。镜像 post-meta 的安静感。
60            div { class: "flex flex-wrap items-center gap-x-4 gap-y-1 mb-8 text-sm text-paper-tertiary",
61                if let Some(v) = latest {
62                    div { class: "flex items-center gap-1.5",
63                        span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" }
64                        span { "最新版本 " }
65                        span { class: "font-medium text-paper-primary", "v{v.version}" }
66                    }
67                }
68                span { "共 {total} 个版本" }
69            }
70
71            // 双栏布局
72            div { class: "flex gap-8",
73                // 版本导航:桌面端 sticky 侧栏(scroll-spy 高亮当前版本)
74                if versions.len() > 1 {
75                    VersionNav { versions: versions.clone() }
76                }
77
78                // 版本卡片列表
79                div { class: "flex-1 min-w-0 space-y-6",
80                    for v in versions.iter() {
81                        VersionCard { key: "{v.version}", version: v.clone() }
82                    }
83                }
84            }
85        }
86    }
87}
88
89/// 版本导航侧栏(桌面端 sticky)。
90///
91/// 高亮语义:accent 标记「当前视口顶部命中的版本卡片」(scroll-spy),而非固定
92/// 标记最新版本——否则带 #hash 访问或点击导航跳转旧版本时,侧栏高亮仍停在最新
93/// 版本上,与右侧内容区脱节。
94#[component]
95fn VersionNav(versions: Vec<VersionEntry>) -> Element {
96    // 当前命中的版本号。None = SSR/首帧尚未计算,渲染时回退首项——页面顶端
97    // 即最新版本,与修复前的常驻高亮表现一致,也无 hydration mismatch。
98    let active = use_signal(|| None::<String>);
99
100    // scroll-spy:window scroll 监听 + getBoundingClientRect 判定。一版本一张
101    // 卡片,数量有限,每次事件十几次 rect 读取成本可忽略,无需 IntersectionObserver
102    // 那套可见性集合管理。
103    #[cfg(target_arch = "wasm32")]
104    {
105        use dioxus::prelude::{use_drop, use_effect, use_hook};
106        use std::cell::RefCell;
107        use std::rc::Rc;
108        use wasm_bindgen::JsCast;
109
110        let mut active = active;
111
112        // 手写监听而非复用 hooks/event_listener.rs:那里 handler 只在事件触发时
113        // 运行,这里挂载后要先算一次初始命中(直接带 #hash 访问时浏览器原生锚点
114        // 滚动已完成,首帧即应高亮正确版本)。模式镜像 ui.rs 的 Escape 监听:
115        // use_hook 持有 Closure,use_drop 移除监听防泄漏。
116        type ScrollState = Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut()>>>>;
117        let state: ScrollState = use_hook(|| Rc::new(RefCell::new(None)));
118        let state_for_effect = state.clone();
119        let state_for_drop = state.clone();
120
121        // effect 体内不读任何 signal(compute 里只有 peek/set),无依赖 → 只跑一次。
122        use_effect(move || {
123            let Some(window) = web_sys::window() else {
124                return;
125            };
126            let Some(document) = window.document() else {
127                return;
128            };
129            let Ok(list) = document.query_selector_all("article.changelog-version") else {
130                return;
131            };
132            let mut cards: Vec<web_sys::Element> = Vec::with_capacity(list.length() as usize);
133            for i in 0..list.length() {
134                if let Some(el) = list
135                    .item(i)
136                    .and_then(|n| n.dyn_into::<web_sys::Element>().ok())
137                {
138                    cards.push(el);
139                }
140            }
141            if cards.is_empty() {
142                return;
143            }
144
145            // compute 捕获 window 副本,原件留给下方注册监听。
146            let window_for_compute = window.clone();
147            let mut compute = move || {
148                // 视口顶部判定线:sticky header + 卡片 scroll-mt-20(80px)落点余量。
149                const THRESHOLD_PX: f64 = 120.0;
150
151                // 顶部已越过判定线的最后一张卡片 = 当前阅读位置。
152                // 卡片 id 为 "v{version}",strip 掉渲染时加的前缀还原版本号。
153                let mut current: Option<String> = None;
154                for el in &cards {
155                    if el.get_bounding_client_rect().top() <= THRESHOLD_PX {
156                        current = el.id().strip_prefix('v').map(str::to_owned);
157                    } else {
158                        break;
159                    }
160                }
161                // 页面顶端没有任何卡片越过判定线 → 回退首项(最新版本)。
162                let mut ver = current.or_else(|| {
163                    cards
164                        .first()
165                        .and_then(|e| e.id().strip_prefix('v').map(str::to_owned))
166                });
167
168                // 滚到页面底部 → 强制末项:末卡片内容短时永远到不了判定线。
169                let at_bottom = window_for_compute
170                    .inner_height()
171                    .ok()
172                    .and_then(|v| v.as_f64())
173                    .zip(window_for_compute.scroll_y().ok())
174                    .zip(
175                        document
176                            .document_element()
177                            .map(|e| f64::from(e.scroll_height())),
178                    )
179                    .is_some_and(|((vh, sy), sh)| vh + sy >= sh - 4.0);
180                if at_bottom {
181                    ver = cards
182                        .last()
183                        .and_then(|e| e.id().strip_prefix('v').map(str::to_owned));
184                }
185
186                if let Some(ver) = ver {
187                    if active.peek().as_deref() != Some(ver.as_str()) {
188                        active.set(Some(ver));
189                    }
190                }
191            };
192
193            compute();
194
195            let closure =
196                wasm_bindgen::prelude::Closure::wrap(Box::new(compute) as Box<dyn FnMut()>);
197            let _ =
198                window.add_event_listener_with_callback("scroll", closure.as_ref().unchecked_ref());
199            *state_for_effect.borrow_mut() = Some(closure);
200        });
201
202        use_drop(move || {
203            if let Some(closure) = state_for_drop.borrow_mut().take() {
204                if let Some(window) = web_sys::window() {
205                    let _ = window.remove_event_listener_with_callback(
206                        "scroll",
207                        closure.as_ref().unchecked_ref(),
208                    );
209                }
210            }
211        });
212    }
213
214    // 回退首项:SSR 与 WASM 首帧一致(active 均为 None)。
215    let fallback = versions.first().map(|v| v.version.clone());
216    let active_ver = active.read().clone().or(fallback);
217
218    rsx! {
219        nav { class: "hidden lg:block w-40 shrink-0",
220            div { class: "sticky top-20",
221                for v in versions.iter() {
222                    VersionNavItem {
223                        key: "{v.version}",
224                        version: v.version.clone(),
225                        active: active_ver.as_deref() == Some(v.version.as_str()),
226                    }
227                }
228            }
229        }
230    }
231}
232
233/// 版本导航侧栏中的单条链接。
234///
235/// `active` = scroll-spy 命中的当前版本(见 [`VersionNav`]),用 accent 高亮。
236#[component]
237fn VersionNavItem(version: String, active: bool) -> Element {
238    let base = "group flex items-center gap-2 py-1.5 text-sm transition-colors";
239    let text_class = if active {
240        "font-medium text-paper-accent"
241    } else {
242        "text-paper-secondary group-hover:text-paper-primary"
243    };
244    let dot_class = if active {
245        "bg-[var(--color-paper-accent)]"
246    } else {
247        "bg-[var(--color-paper-border)]"
248    };
249
250    rsx! {
251        a { href: "#v{version}", class: "{base}",
252            span { class: "w-1.5 h-1.5 rounded-full shrink-0 {dot_class}" }
253            span { class: "{text_class}", "{version}" }
254        }
255    }
256}
257
258/// 单个版本卡片。
259///
260/// 结构:版本头(版本号 + 最新标记 + 日期)→ intro(如有)→ 分类组列表。
261/// 每个分类组带色标 badge + 条目列表。
262#[component]
263fn VersionCard(version: VersionEntry) -> Element {
264    let VersionEntry {
265        version: ver,
266        date,
267        is_latest,
268        intro_html,
269        groups,
270    } = version;
271
272    rsx! {
273        article {
274            id: "v{ver}",
275            class: "changelog-version scroll-mt-20 rounded-[2rem] bg-[var(--color-paper-entry)] border border-transparent hover:border-[var(--color-paper-border)] transition-colors p-6 md:p-8",
276
277            // 版本头
278            div { class: "flex items-baseline gap-3",
279                h2 { class: "text-xl md:text-2xl font-bold text-paper-primary tracking-tight",
280                    "v{ver}"
281                }
282                if is_latest {
283                    span { class: "changelog-badge changelog-badge--latest", "最新" }
284                }
285                if let Some(d) = &date {
286                    span { class: "text-sm text-paper-tertiary ml-auto", "{d}" }
287                }
288            }
289
290            // intro(Unreleased 占位文字等)
291            if !intro_html.is_empty() {
292                div {
293                    class: "md-content text-sm text-paper-secondary mt-2",
294                    dangerous_inner_html: "{intro_html}",
295                }
296            }
297
298            // 分类组
299            if !groups.is_empty() {
300                div { class: "mt-4 space-y-5",
301                    for group in groups.iter() {
302                        ChangeGroupView { key: "{group.category:?}", group: group.clone() }
303                    }
304                }
305            }
306        }
307    }
308}
309
310/// 单个分类组视图:badge + 条目列表。
311#[component]
312fn ChangeGroupView(group: ChangeGroup) -> Element {
313    let ChangeGroup {
314        category,
315        items_html,
316    } = group;
317    let badge_class = format!("changelog-badge changelog-badge--{}", category.css_class());
318
319    rsx! {
320        div {
321            // badge 行
322            div { class: "mb-2",
323                span { class: "{badge_class}", "{category.label()}" }
324            }
325            // 条目列表(复用 md-content 内联格式 + changelog-items 列表样式覆盖)
326            div {
327                class: "md-content changelog-items",
328                dangerous_inner_html: "{items_html}",
329            }
330        }
331    }
332}