Skip to main content

yggdrasil/pages/
changelog.rs

1//! 更新日志页面模块。
2//!
3//! 对应路由 `/changelog`(`SsrMode::Async`:增强脚本声明位于 `<Suspense>` 内,InOrder 流式
4//! 输出开始后无法再写入 `<head>`,直达访问会丢失 `yggdrasil-enhance.js`)。
5//!
6//! 数据获取:`LazyRoute::data` 创建 `Resource` 调用 `get_changelog` server function,
7//! 取回编译期内嵌 CHANGELOG.md 的**结构化解析结果**(版本 → 分类 → 条目 HTML)。
8//! 内容随二进制版本固定,只受 SSR 页面缓存 TTL 约束,无需任何主动失效。
9//!
10//! # 布局
11//! 左侧 200px 版本目录卡片(独立滚动、两端渐隐、滑动胶囊标出当前版本、阅读位置),
12//! 小屏改为横向版本条;右侧按版本分节,每节按新增 / 改进 / 修复等分组。
13//! 不展示 `## [Unreleased]`:部署即发版,解析结果里没有这一节。
14//! 目录的阅读跟随由 yggdrasil-core 的 `__initChangelogNav` 维护。
15
16use leptos::prelude::*;
17use leptos_meta::Title;
18use leptos_router::{lazy_route, LazyRoute};
19
20use crate::api::changelog::{get_changelog, ChangeGroup, ChangelogData, VersionEntry};
21use crate::components::skeletons::changelog_skeleton::ChangelogSkeleton;
22use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
23use crate::components::ui::{Icon, IconName, StatusBadge};
24use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
25use crate::pages::not_found::{NotFound, PageLoadFailed};
26use crate::routes::Route;
27use crate::utils::error::is_not_found;
28
29/// 更新日志路由,对应 `/changelog`。
30pub struct ChangelogRoute {
31    changelog: RouteData<ChangelogData>,
32}
33
34#[leptos::lazy]
35async fn fetch_changelog() -> PageJson<ChangelogData> {
36    PageJson::new(get_changelog().await)
37}
38
39#[lazy_route]
40impl LazyRoute for ChangelogRoute {
41    fn data() -> Self {
42        Self {
43            changelog: route_data(|| (), unit(fetch_changelog)),
44        }
45    }
46
47    fn view(this: Self) -> AnyView {
48        let changelog = this.changelog;
49        view! {
50            <Suspense fallback=|| {
51                view! {
52                    <DelayedSkeleton>
53                        <ChangelogSkeleton/>
54                    </DelayedSkeleton>
55                }
56            }>
57                {move || Suspend::new(async move {
58                    match changelog.await {
59                        Ok(ChangelogData { versions }) => {
60                            view! { <Changelog versions=versions/> }.into_any()
61                        }
62                        Err(err) if is_not_found(&err) => view! { <NotFound/> }.into_any(),
63                        Err(_) => view! { <PageLoadFailed/> }.into_any(),
64                    }
65                })}
66            </Suspense>
67        }
68        .into_any()
69    }
70}
71
72impl ChangelogRoute {
73    /// 悬停预取:与 `data()` 相同的取数函数与 source。
74    pub fn prefetch_data() {
75        prefetch_route_data((), unit(fetch_changelog));
76    }
77}
78
79#[component]
80fn Changelog(versions: Vec<VersionEntry>) -> impl IntoView {
81    let total = versions.len();
82    // 最早一个有日期的版本:「2026.06 起」。
83    let since = versions
84        .iter()
85        .rev()
86        .find_map(|v| v.date.as_deref())
87        .map(month_label);
88
89    let nav = (versions.len() > 1).then(|| view! { <VersionNav versions=versions.clone()/> });
90    let cards = versions
91        .into_iter()
92        .map(|version| view! { <VersionCard version=version/> })
93        .collect_view();
94
95    view! {
96        <Title text="更新日志 · Yggdrasil"/>
97        <div class="changelog">
98            <ChangelogHeader total=total since=since/>
99            <div class="changelog-layout">
100                {nav} <div class="changelog-releases">{cards}</div>
101            </div>
102        </div>
103    }
104}
105
106/// 「2026-06-02」→「2026.06」。
107fn month_label(date: &str) -> String {
108    date.get(..7).unwrap_or(date).replace('-', ".")
109}
110
111/// 「2026-10-20」→「2026.10.20」。
112fn date_label(date: &str) -> String {
113    date.replace('-', ".")
114}
115
116/// 页头:返回关于、标题、说明与版本数。骨架屏复用,数据到达前后不跳动。
117///
118/// `total` / `since` 缺省时不渲染版本数行(骨架屏);可传值、`Option` 或信号。
119#[component]
120pub fn ChangelogHeader(
121    #[prop(optional, into)] total: MaybeProp<usize>,
122    #[prop(optional, into)] since: MaybeProp<String>,
123) -> impl IntoView {
124    view! {
125        <a class="changelog-back" href=Route::About.to_string()>
126            <Icon name=IconName::ArrowLeft size=14/>
127            "关于"
128        </a>
129        <header class="changelog-header page-enter">
130            <div>
131                <h1 class="changelog-title">"更新日志"</h1>
132                <p class="changelog-intro">"每个版本新增、改进和修复了什么。"</p>
133            </div>
134            {move || {
135                total
136                    .get()
137                    .map(|total| {
138                        view! {
139                            <p class="changelog-count tabular-nums">
140                                {format!("{total} 个版本")}
141                                {move || since.get().map(|since| format!(" · {since} 起"))}
142                            </p>
143                        }
144                    })
145            }}
146        </header>
147    }
148}
149
150/// 有界版本目录:桌面纵向滚动,小屏横向滚动。
151/// 浏览器增强负责阅读位置、滑动指示器和目录自身滚动,卸载时清理监听。
152#[component]
153fn VersionNav(versions: Vec<VersionEntry>) -> impl IntoView {
154    #[cfg(target_arch = "wasm32")]
155    {
156        Effect::new(move |_| {
157            crate::bridges::globals::init_changelog_nav();
158        });
159        on_cleanup(move || {
160            crate::bridges::globals::dispose_changelog_nav();
161        });
162    }
163
164    let count = versions.len();
165    let progress = 1.0 / count as f64;
166    let items = versions
167        .iter()
168        .enumerate()
169        .map(|(index, v)| {
170            view! {
171                <VersionNavItem
172                    version=v.version.clone()
173                    date=v.date.as_deref().map(|d| d.get(5..).unwrap_or(d).replace('-', "."))
174                    active=index == 0
175                />
176            }
177        })
178        .collect_view();
179
180    view! {
181        // P5 §8.2:changelog 导航增强在 yggdrasil-enhance.js,按需注入。SSR 直达烤进
182        // head 带 defer,先于 hydration;SPA 首跳由增强调用队列兜底。
183        <crate::components::enhance_assets::EnhanceScript/>
184        <nav class="changelog-nav" aria-label="版本目录">
185            <div class="changelog-nav-panel" style=format!("--nav-progress: {progress}")>
186                <div class="changelog-nav-head">
187                    <span>"版本"</span>
188                    <span class="tabular-nums" data-nav-position="">
189                        {format!("01 / {count:02}")}
190                    </span>
191                </div>
192                <div class="changelog-nav-scroll" tabindex="0" aria-label="滚动浏览所有版本">
193                    <div class="changelog-nav-items">
194                        <div class="changelog-nav-indicator" aria-hidden="true"></div>
195                        {items}
196                    </div>
197                </div>
198                <div class="changelog-nav-progress" aria-hidden="true">
199                    <span></span>
200                </div>
201            </div>
202        </nav>
203    }
204}
205
206/// 原生锚点链接,SSR 和未水合时也能导航。
207#[component]
208fn VersionNavItem(version: String, date: Option<String>, active: bool) -> impl IntoView {
209    let href = format!("#v{version}");
210    view! {
211        <a
212            href=href
213            class="changelog-nav-link"
214            aria-current=active.then_some("location")
215        >
216            <span class="changelog-nav-label">{version}</span>
217            {date.map(|date| view! { <span class="changelog-nav-date tabular-nums">{date}</span> })}
218        </a>
219    }
220}
221
222/// 一个版本:等宽版本号、日期与「最新」,intro(如有),再按分类分组列出条目。
223#[component]
224fn VersionCard(version: VersionEntry) -> impl IntoView {
225    let VersionEntry {
226        version: ver,
227        date,
228        is_latest,
229        intro_html,
230        groups,
231    } = version;
232
233    let date = date.map(|d| {
234        view! {
235            <time class="changelog-version-date tabular-nums" datetime=d.clone()>
236                {date_label(&d)}
237            </time>
238        }
239    });
240    let latest = is_latest.then(|| view! { <StatusBadge color_class="badge-clay" label="最新"/> });
241    let intro = (!intro_html.is_empty())
242        .then(|| view! { <div class="changelog-intro-html" inner_html=intro_html></div> });
243    let groups = groups
244        .into_iter()
245        .map(|group| view! { <ChangeGroupView group=group/> })
246        .collect_view();
247
248    view! {
249        <section id=format!("v{ver}") class="changelog-version" aria-labelledby=format!("v{ver}-title")>
250            <div class="changelog-version-head">
251                <h2 id=format!("v{ver}-title") class="changelog-version-title">
252                    {ver.clone()}
253                </h2>
254                {date} {latest}
255            </div>
256            {intro} {groups}
257        </section>
258    }
259}
260
261/// 单个分类组:分类小标签与条目列表。新增用陶土底,其余为中性灰。
262#[component]
263fn ChangeGroupView(group: ChangeGroup) -> impl IntoView {
264    let ChangeGroup {
265        category,
266        items_html,
267    } = group;
268
269    view! {
270        <div class="changelog-group">
271            <h3 class="changelog-category" data-category=category.css_class()>
272                {category.label()}
273            </h3>
274            <div class="changelog-items" inner_html=items_html></div>
275        </div>
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn dates_use_dots() {
285        assert_eq!(month_label("2026-06-02"), "2026.06");
286        assert_eq!(date_label("2026-10-20"), "2026.10.20");
287    }
288}