Skip to main content

yggdrasil/pages/admin/
posts.rs

1//! 文章管理页面(列表 + 回收站,单一路由 + 客户端 tab 切换)。
2//!
3//! 「全部文章」与「回收站」合并为单一 `/admin/posts` 路由,用顶部 tab 在二者间
4//! 切换。tab 状态与翻页均由客户端 signal 驱动(不走路由、不深链),与 `system.rs`
5//! 的 tab 模式一致:admin 内部页,刷新回到「全部文章」第 1 页即可。
6//! 数据加载与写操作仅在 WASM 前端通过 Dioxus server functions 完成。
7
8use dioxus::prelude::*;
9use dioxus::router::components::Link;
10
11// 分页数据接口:list_posts 是 server function,两端都生成(wasm 端为 client stub,
12// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
13use crate::api::posts::{list_posts, PostListResponse};
14// get_post_stats / PostStatsResponse 仅在 Posts 容器的 wasm 加载路径使用,
15// SSR 下对应 use_effect 分支被裁剪,故允许 unused imports。
16#[allow(unused_imports)]
17use crate::api::posts::{
18    delete_post, get_post_stats, rebuild_content_html, rebuild_post_content_html,
19    CreatePostResponse, PostStatsResponse, RebuildResult,
20};
21use crate::components::empty_state::{EmptyState, EmptyStateAction};
22use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
23use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
24use crate::components::skeletons::posts_skeleton::PostsSkeleton;
25use crate::components::ui::{
26    Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE, BTN_PRIMARY,
27    BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG,
28};
29use crate::hooks::query::use_paginated;
30use crate::models::post::PostListItem;
31use crate::router::Route;
32// 回收站 tab 内容(本容器 match 渲染)。
33use super::posts_trash::PostsTrashPanel;
34
35/// 每页展示的文章数量。
36const POSTS_PER_PAGE: i32 = 20;
37
38/// 文章管理顶部 tab:全部文章 / 回收站。
39///
40/// 用枚举而非裸字符串,保证 tab 切换的类型安全;`as_str()` 提供稳定 key 供
41/// `key` 化重挂载(隔离各 tab 的 `use_paginated` 状态)。
42#[derive(Clone, Copy, PartialEq, Debug)]
43pub(super) enum PostsTab {
44    /// 全部文章(含草稿)。
45    All,
46    /// 回收站(已软删除)。
47    Trash,
48}
49
50impl PostsTab {
51    fn as_str(&self) -> &'static str {
52        match self {
53            PostsTab::All => "all",
54            PostsTab::Trash => "trash",
55        }
56    }
57}
58
59/// 文章管理入口组件:单一路由 + 客户端 tab 切换。
60///
61/// 持有 `active_tab` signal 与回收站数量 `trash_count`(供 header 文案 + tab 角标),
62/// 用 `key` 化 `match` 切换 `AllPostsList` / `PostsTrashPanel`:切 tab 完全卸载旧
63/// 组件、重挂新组件,各 tab 的 `use_paginated` / 选中态等本地 signal 天然隔离,
64/// 无需手动重置。参照 `system.rs` 的 tab 模式。
65#[component]
66#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
67pub fn Posts() -> Element {
68    let mut active_tab = use_signal(|| PostsTab::All);
69    // 回收站数量:仅 WASM 异步拉取一次,供 header 文案「已删除文章 (N)」与 tab 角标。
70    // 回收站 panel 内部维护自己的精确 total(分页计数用),二者解耦——角标是粗略提示。
71    let mut trash_count = use_signal(|| Option::<i64>::None);
72
73    use_effect(move || {
74        #[cfg(target_arch = "wasm32")]
75        spawn(async move {
76            if let Ok(PostStatsResponse { stats }) = get_post_stats().await {
77                trash_count.set(Some(stats.trash));
78            }
79        });
80    });
81
82    rsx! {
83        div { class: "w-full max-w-7xl mx-auto space-y-6",
84            // 共享 header:标题/副标题随 tab 切换文案;右侧操作区仅「全部文章」tab 显示。
85            div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-paper-border mb-6",
86                div {
87                    h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
88                        if active_tab() == PostsTab::All {
89                            "管理文章"
90                        } else {
91                            "回收站"
92                        }
93                    }
94                    p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
95                        if active_tab() == PostsTab::All {
96                            "所有文章及草稿"
97                        } else {
98                            if let Some(count) = trash_count() {
99                                "已删除文章 ({count})"
100                            } else {
101                                "已删除文章"
102                            }
103                        }
104                    }
105                }
106                // 发布文章 + 重建缓存仅在「全部文章」tab 显示。
107                if active_tab() == PostsTab::All {
108                    div { class: "flex items-center gap-3",
109                        RebuildCacheBar {}
110                        Link { class: "{BTN_PRIMARY}", to: Route::Write {}, "发布文章" }
111                    }
112                }
113            }
114
115            // tab 栏:全部文章 / 回收站。signal 驱动(点击切 active_tab,非路由)。
116            PostsTabs {
117                active: active_tab,
118                trash_count,
119                on_change: move |t: PostsTab| active_tab.set(t),
120            }
121
122            // key 化条件渲染:切 tab 完全卸载/重挂,隔离各自 use_paginated 状态。
123            div { key: "{active_tab().as_str()}",
124                match active_tab() {
125                    PostsTab::All => rsx! {
126                        AllPostsList {}
127                    },
128                    PostsTab::Trash => rsx! {
129                        PostsTrashPanel {}
130                    },
131                }
132            }
133        }
134    }
135}
136
137/// 全部文章列表 tab:分页列表、删除单篇、重建 content_html 缓存。
138///
139/// 翻页用客户端 signal 驱动(`current_page` signal + `use_paginated` 的闭包内读取
140/// 建立依赖,页码变化自动重载),不走路由。删除/重建逻辑与旧实现一致。
141#[component]
142fn AllPostsList() -> Element {
143    let mut current_page = use_signal(|| 1);
144    // 搜索输入框实时绑定的文本(每键即更新,但不触发请求)。
145    let mut search_input = use_signal(String::new);
146    // 已提交的搜索词:空串表示不搜索。仅在此值变化时才重新请求,避免逐键打 DB。
147    let mut search_query = use_signal(String::new);
148
149    // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。
150    // page 闭包内同时读取 current_page 与 search_query 建立响应式依赖:
151    // 翻页、或提交新搜索词(即便停留在第 1 页)都会自动重新请求。
152    // fetch 闭包在发起请求时读取 search_query 的当前值传给后端按标题过滤。
153    let paginated = use_paginated(
154        move || {
155            let _ = search_query();
156            current_page.with(|p| *p)
157        },
158        POSTS_PER_PAGE,
159        move |p, pp| {
160            let q = search_query();
161            async move {
162                list_posts(p, pp, if q.is_empty() { None } else { Some(q) })
163                    .await
164                    .map(|PostListResponse { posts, total }| (posts, total))
165                    .map_err(|e| e.to_string())
166            }
167        },
168    );
169    let mut posts = paginated.items;
170    let mut total = paginated.total;
171    let loading = paginated.loading;
172    let error = paginated.error;
173
174    // 删除中 / 重建中文章 ID 集合:均由本组件持有(业务逻辑不归 hook 管)。
175    // 改为非乐观删除后行会保留至请求完成,可并发点多个删除,故用 HashSet
176    // 与 rebuilding 同形,按行通过 contains 判断 loading 态。
177    let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
178    // 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点
179    // 的覆盖先点的,故用 HashSet),按行通过 contains 判断 loading 态。
180    let mut rebuilding = use_signal(std::collections::HashSet::<i32>::new);
181    let get_posts = move || -> Vec<PostListItem> { posts() };
182    // 是否处于搜索结果视图(用于区分空状态文案 / 隐藏「写文章」入口)。
183    let is_searching = move || !search_query().is_empty();
184    // 提交搜索:写入 search_query 并回到第 1 页(搜索结果从首页开始分页)。
185    let mut submit_search = move || {
186        let q = search_input().trim().to_string();
187        search_query.set(q);
188        current_page.set(1);
189    };
190
191    rsx! {
192        // 搜索条:按标题过滤文章(仅管理后台用,覆盖草稿)。
193        div { class: "flex gap-2 mb-4",
194            FormInput {
195                r#type: "search",
196                placeholder: "搜索文章标题...",
197                value: search_input(),
198                class: INPUT_INLINE_CLASS,
199                oninput: move |v: String| search_input.set(v),
200                onkeydown: move |e: KeyboardEvent| {
201                    if e.key() == Key::Enter {
202                        submit_search();
203                    }
204                },
205            }
206            button { class: "{BTN_PRIMARY}", onclick: move |_| submit_search(), "搜索" }
207            if is_searching() {
208                button {
209                    class: "{BTN_OUTLINE}",
210                    onclick: move |_| {
211                        search_input.set(String::new());
212                        search_query.set(String::new());
213                        current_page.set(1);
214                    },
215                    "清除"
216                }
217            }
218        }
219
220        if error().is_some() {
221            EmptyState {
222                title: "加载失败",
223                description: "获取文章列表时发生错误,请稍后重试。",
224            }
225        } else if loading() && posts().is_empty() {
226            DelayedSkeleton { PostsSkeleton {} }
227        } else if posts().is_empty() {
228            if is_searching() {
229                EmptyState {
230                    title: "未找到匹配的文章",
231                    description: "换个标题关键词再试一次。",
232                }
233            } else {
234                EmptyState {
235                    title: "暂无文章",
236                    description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
237                    action: EmptyStateAction {
238                        label: "写文章".to_string(),
239                        to: Route::Write {},
240                    },
241                }
242            }
243        } else {
244            div { class: "{ADMIN_TABLE_CLASS}",
245                table { class: "w-full text-sm",
246                    thead {
247                        tr { class: "border-b border-paper-border text-left text-paper-secondary",
248                            th { class: "px-4 py-3 font-medium", "标题" }
249                            th { class: "px-4 py-3 font-medium w-24 text-center whitespace-nowrap",
250                                "状态"
251                            }
252                            th { class: "px-4 py-3 font-medium w-32 whitespace-nowrap",
253                                "日期"
254                            }
255                            th { class: "px-4 py-3 font-medium w-44 text-right whitespace-nowrap",
256                                "操作"
257                            }
258                        }
259                    }
260                    tbody {
261                        for post in get_posts().iter() {
262                            PostRow {
263                                key: "{post.id}",
264                                post: post.clone(),
265                                deleting: deleting().contains(&post.id),
266                                rebuilding: rebuilding().contains(&post.id),
267                                on_delete: move |id| {
268                                    deleting.write().insert(id);
269                                    spawn(async move {
270                                        match delete_post(id).await {
271                                            Ok(CreatePostResponse { success: true, .. }) => {
272                                                posts.with_mut(|list| list.retain(|p| p.id != id));
273                                                total.with_mut(|t| *t = t.saturating_sub(1));
274                                            }
275                                            Ok(CreatePostResponse { success: false, message: _message, .. }) => {
276                                                #[cfg(target_arch = "wasm32")]
277                                                web_sys::window().map(|w| w.alert_with_message(&_message).ok());
278                                            }
279                                            Err(_e) => {
280                                                #[cfg(target_arch = "wasm32")]
281                                                web_sys::window().map(|w| w.alert_with_message("删除失败").ok());
282                                            }
283                                        }
284                                        deleting.write().remove(&id);
285                                    });
286                                },
287                                on_rebuild: move |id| {
288                                    rebuilding.write().insert(id);
289                                    spawn(async move {
290                                        let _ = rebuild_post_content_html(id).await;
291                                        rebuilding.write().remove(&id);
292                                    });
293                                },
294                            }
295                        }
296                    }
297                }
298            }
299            Pagination {
300                variant: "admin",
301                current_page: current_page(),
302                total: total(),
303                per_page: POSTS_PER_PAGE,
304                unit: "篇",
305                on_prev: {
306                    let mut page = current_page;
307                    move |_| {
308                        page.with_mut(|p| *p = (*p - 1).max(1));
309                    }
310                },
311                on_next: {
312                    let mut page = current_page;
313                    move |_| {
314                        page.with_mut(|p| *p += 1);
315                    }
316                },
317                on_jump: {
318                    let mut page = current_page;
319                    move |p: i32| {
320                        page.set(p);
321                    }
322                },
323            }
324        }
325    }
326}
327
328/// 重建内容缓存工具条子组件。
329///
330/// 封装「重建内容 / 重建全部」两个按钮及其 `do_rebuild` 异步闭包。状态
331/// (`rebuilding` / `rebuild_result`) 由本组件内部持有(从 `PostsPage` 下沉至此,
332/// 因合并后仅 All tab 需要,无需跨层传递)。
333///
334/// 从 `AllPostsList` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
335#[component]
336#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
337fn RebuildCacheBar() -> Element {
338    let mut rebuilding = use_signal(|| false);
339    let mut rebuild_result = use_signal(|| Option::<String>::None);
340
341    // 重建文章渲染缓存:rebuild_all 为 false 时仅重建 content_html 为空的文章,
342    // 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。
343    let mut do_rebuild = move |rebuild_all: bool| {
344        rebuilding.set(true);
345        rebuild_result.set(None);
346        spawn(async move {
347            match rebuild_content_html(rebuild_all).await {
348                Ok(RebuildResult {
349                    rebuilt,
350                    failed,
351                    errors,
352                }) => {
353                    if failed > 0 {
354                        let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed} 篇");
355                        if let Some(first) = errors.first() {
356                            msg.push_str(&format!("\n{first}"));
357                        }
358                        rebuild_result.set(Some(msg));
359                    } else {
360                        rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
361                    }
362                }
363                Err(e) => {
364                    rebuild_result.set(Some(format!("失败: {e}")));
365                }
366            }
367            rebuilding.set(false);
368        });
369    };
370
371    rsx! {
372        // 消息绝对定位到按钮行下方,脱离文档流:出现/消失都不撑高祖先容器,
373        // 避免 header 的 md:items-end 把固定底边转化为按钮上移("按钮被顶上去" bug)。
374        // 自持 rebuilding / rebuild_result state,与父组件零耦合。
375        div { class: "relative flex items-center gap-3",
376            div { class: "flex items-center gap-3",
377                Tooltip {
378                    tip: "重建 content_html 为空的文章渲染缓存".to_string(),
379                    placement: "bottom",
380                    button {
381                        class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
382                        disabled: rebuilding(),
383                        onclick: move |_| do_rebuild(false),
384                        span { class: if rebuilding() { "opacity-40" } else { "" }, "重建内容" }
385                        if rebuilding() {
386                            span {
387                                class: "absolute inset-0 flex items-center justify-center",
388                                dangerous_inner_html: SPINNER_SVG,
389                            }
390                        }
391                    }
392                }
393                Tooltip {
394                    tip: "重建所有文章的渲染缓存(含已有内容)".to_string(),
395                    placement: "bottom",
396                    button {
397                        class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
398                        disabled: rebuilding(),
399                        onclick: move |_| do_rebuild(true),
400                        span { class: if rebuilding() { "opacity-40" } else { "" }, "重建全部" }
401                        if rebuilding() {
402                            span {
403                                class: "absolute inset-0 flex items-center justify-center",
404                                dangerous_inner_html: SPINNER_SVG,
405                            }
406                        }
407                    }
408                }
409            }
410            // 重建结果消息:绝对定位到按钮行正下方,脱离文档流,不影响布局高度。
411            if let Some(msg) = rebuild_result() {
412                div { class: "absolute top-full right-0 mt-1 text-xs text-paper-secondary whitespace-pre-line",
413                    "{msg}"
414                }
415            }
416        }
417    }
418}
419
420/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
421#[component]
422fn PostRow(
423    post: PostListItem,
424    deleting: bool,
425    rebuilding: bool,
426    on_delete: EventHandler<i32>,
427    on_rebuild: EventHandler<i32>,
428) -> Element {
429    let date_str = post.formatted_date();
430
431    rsx! {
432        tr { class: "{ADMIN_ROW_HOVER}",
433            td { class: "px-4 py-3",
434                Link {
435                    class: "text-paper-primary hover:text-paper-accent transition-colors cursor-pointer",
436                    to: Route::PostDetail {
437                        slug: post.slug.clone(),
438                    },
439                    "{post.title}"
440                }
441            }
442            td { class: "px-4 py-3 text-center whitespace-nowrap",
443                StatusBadge {
444                    color_class: post.status_badge_class(),
445                    label: post.status_label().to_string(),
446                }
447            }
448            td { class: "px-4 py-3 text-paper-secondary whitespace-nowrap", "{date_str}" }
449            td { class: "px-4 py-3 text-right whitespace-nowrap",
450                div { class: "flex justify-end items-center gap-3",
451                    Link {
452                        class: "text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
453                        to: Route::WriteEdit { id: post.id },
454                        "编辑"
455                    }
456                    Tooltip { tip: "重新渲染这篇文章的 HTML".to_string(),
457                        button {
458                            class: if rebuilding { "relative inline-flex items-center text-xs text-paper-accent cursor-not-allowed" } else { BTN_TEXT_ACCENT },
459                            disabled: rebuilding,
460                            onclick: move |_| on_rebuild.call(post.id),
461                            span { class: if rebuilding { "opacity-40" } else { "" }, "重建" }
462                            if rebuilding {
463                                span {
464                                    class: "absolute inset-0 flex items-center justify-center",
465                                    dangerous_inner_html: SPINNER_SVG,
466                                }
467                            }
468                        }
469                    }
470                    button {
471                        class: if deleting { "relative inline-flex items-center text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED },
472                        disabled: deleting,
473                        onclick: move |_| on_delete.call(post.id),
474                        span { class: if deleting { "opacity-40" } else { "" }, "删除" }
475                        if deleting {
476                            span {
477                                class: "absolute inset-0 flex items-center justify-center",
478                                dangerous_inner_html: SPINNER_SVG,
479                            }
480                        }
481                    }
482                }
483            }
484        }
485    }
486}
487
488/// tab 组 id 自增计数器:给每处 PostsTabs 实例一个唯一前缀,用于 DOM 测量滑块位置。
489/// (ui.rs 的 FilterTabs 有同款 TAB_GROUP_ID,此处为避免跨模块可见性污染,本地自建。)
490static POSTS_TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
491
492/// 文章管理 tab 栏:「全部文章」与「回收站」。
493///
494/// tab 状态由父组件传入的 `active` signal 驱动(非路由),点击即调用 `on_change`
495/// 切换。回收站 tab 带 `trash_count` 数量角标,便于发现待清理文章。
496///
497/// 底部带**平滑滑动指示器**(绝对定位的滑块 + transition),切换 tab 时滑块从
498/// 一个 tab 平滑滑到另一个,与 FilterTabs(system/comments 页)视觉一致。滑块
499/// 位置通过 WASM 端测量目标 button 的 offsetLeft/offsetWidth 动态计算。
500///
501/// 两个 tab 均用 `inline-flex items-center` 同盒模型,外层容器加 `items-center`,
502/// 根除原先「全部文章」(inline 文本) 与「回收站」(inline-flex 带角标) 盒模型
503/// 不一致导致的垂直错位。
504#[component]
505#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
506pub(super) fn PostsTabs(
507    active: Signal<PostsTab>,
508    trash_count: Signal<Option<i64>>,
509    on_change: EventHandler<PostsTab>,
510) -> Element {
511    let is_trash = active() == PostsTab::Trash;
512    // 滑块样式(left/width/opacity):WASM 端测量目标 button 定位后写入。
513    let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
514    let id_prefix =
515        use_hook(|| POSTS_TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
516
517    // 测量目标 tab 的 offsetLeft/offsetWidth,更新滑块定位。WASM 端异步等待 DOM
518    // 更新后读取;server 端空操作(SSR 不渲染动画)。
519    let update_indicator = move |active_key: &str| {
520        let active_key = active_key.to_string();
521        spawn(async move {
522            #[cfg(target_arch = "wasm32")]
523            {
524                use wasm_bindgen::JsCast;
525                crate::utils::time::sleep_ms(50).await;
526                if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| {
527                    d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}"))
528                }) {
529                    if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
530                        indicator_style.set(format!(
531                            "left: {}px; width: {}px; opacity: 1;",
532                            html_el.offset_left(),
533                            html_el.offset_width()
534                        ));
535                    }
536                }
537            }
538        });
539    };
540
541    // active 变化时(含首次挂载)触发滑块定位。
542    use_effect(move || {
543        update_indicator(active().as_str());
544    });
545
546    rsx! {
547        // relative 容器:承载绝对定位滑块;items-center 让两个 tab 垂直居中对齐。
548        div { class: "relative flex items-center gap-4 border-b border-paper-border",
549            button {
550                id: "posts-tab-{id_prefix}-all",
551                class: if !is_trash { "inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer" } else { "inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer" },
552                onclick: move |_| on_change.call(PostsTab::All),
553                "全部文章"
554            }
555            button {
556                id: "posts-tab-{id_prefix}-trash",
557                class: if is_trash { "inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer" } else { "inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer" },
558                onclick: move |_| on_change.call(PostsTab::Trash),
559                "回收站"
560                // 数量角标:有数据才显示。0 显示中性灰,>0 用主题强调色提醒。
561                if let Some(count) = trash_count() {
562                    span { class: if count > 0 { "inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[0.625rem] font-semibold normal-case tracking-normal bg-paper-accent-soft text-paper-accent" } else { "inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[0.625rem] font-semibold normal-case tracking-normal bg-paper-tertiary text-paper-secondary" },
563                        "{count}"
564                    }
565                }
566            }
567            // 绝对定位的滑动指示器:贴底边(-1px 盖住外层 border-b),transition 驱动滑动动画。
568            div {
569                class: "absolute bottom-[-1px] h-[2px] bg-paper-primary transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
570                style: "{indicator_style}",
571            }
572        }
573    }
574}