Skip to main content

yggdrasil/components/skeletons/
atoms.rs

1//! 骨架屏原子组件
2//!
3//! 提供通用的脉冲动画占位块,供各页面骨架屏组合使用。
4
5use dioxus::prelude::*;
6
7/// 通用骨架占位块。
8///
9/// Props:
10/// - `class`:Tailwind CSS 类,控制尺寸与形状
11/// - `style`:可选的内联样式字符串
12/// - `animate`:是否带 `animate-pulse` 呼吸动画(缺省 `true`)。少数骨架屏在
13///   切换动画/骨架屏截断场景需要关闭,避免动画被截断显得突兀。
14///
15/// 默认带有 `animate-pulse` 动画与半透明的占位背景。
16#[component]
17pub fn SkeletonBox(
18    class: &'static str,
19    style: Option<&'static str>,
20    #[props(default = true)] animate: bool,
21) -> Element {
22    let pulse = if animate { " animate-pulse" } else { "" };
23    rsx! {
24        div {
25            class: "bg-paper-tertiary/30 dark:bg-gray-600{pulse} {class}",
26            style: style.unwrap_or(""),
27        }
28    }
29}
30
31/// 通用骨架「幽灵卡片」外壳:半透明背景 + 圆角 + 描边,供各页面骨架屏包裹
32/// 内容占位(列表行、表格、日志区等)。收敛此前散落在各骨架屏文件里手打的
33/// 同一段 `bg-[var(--color-paper-entry)]/40 rounded-2xl border ...` 前缀。
34///
35/// Props:
36/// - `class`:追加类(内边距、间距、`overflow-hidden`、`shadow-xs` 等按调用方需要传入)
37/// - `children`:卡片内的占位内容
38#[component]
39pub fn SkeletonCard(#[props(default)] class: Option<&'static str>, children: Element) -> Element {
40    let extra = class.unwrap_or_default();
41    rsx! {
42        div {
43            class: "bg-[var(--color-paper-entry)]/40 rounded-2xl border border-[var(--color-paper-border)]/70 {extra}",
44            {children}
45        }
46    }
47}
48
49/// 表格骨架屏单元格占位形状:不同列的表体内容并非全部是单个占位块,
50/// 部分列(如「用户」列)需要头像+两行文字堆叠,因此用枚举描述形状而非
51/// 单一 `&'static str`。
52#[derive(Clone, Copy, PartialEq)]
53pub enum SkeletonCellShape {
54    /// 单个占位块,`SkeletonBox` 的 `class`。
55    Single(&'static str),
56    /// 两行堆叠占位(如标题 + 副标题),依次为两行 `SkeletonBox` 的 `class`。
57    Stacked(&'static str, &'static str),
58    /// 圆形头像占位 + 两行堆叠占位(如「用户」列:头像 + 昵称/邮箱)。
59    AvatarStacked {
60        avatar: &'static str,
61        line1: &'static str,
62        line2: &'static str,
63    },
64}
65
66/// 表格骨架屏一列的规格:表头单元格类名/占位块类名 + 表体单元格类名/占位形状。
67#[derive(Clone, PartialEq)]
68pub struct SkeletonTableColumn {
69    /// 表头 `th` 的 class(含列宽、对齐、响应式隐藏等)
70    pub header_class: &'static str,
71    /// 表头占位块(`SkeletonBox`)的 class
72    pub header_box_class: &'static str,
73    /// 表体 `td` 的 class
74    pub cell_class: &'static str,
75    /// 表体单元格占位形状
76    pub cell_shape: SkeletonCellShape,
77}
78
79/// 通用表格骨架屏:渲染 `<table>` 本身(表头一行 + N 行占位表体),不含外层
80/// 卡片包裹——各调用方的外层卡片形态并不完全一致(有的独立成卡,有的与标题栏
81/// 共享同一张卡,有的嵌套在已带阴影的父卡片内无需再叠一层阴影),故由调用方
82/// 自行用 [`SkeletonCard`] 或既有容器包裹。收敛此前散落在多个后台列表页骨架屏
83/// 里几乎逐字重复的 `thead`/`tbody` 结构。
84///
85/// Props:
86/// - `columns`:每列的表头/表体占位规格
87/// - `rows`:占位行数
88#[component]
89pub fn SkeletonTable(columns: Vec<SkeletonTableColumn>, rows: usize) -> Element {
90    rsx! {
91        table { class: "w-full text-sm",
92            thead {
93                tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70",
94                    for (i, col) in columns.iter().enumerate() {
95                        th { key: "{i}", class: col.header_class,
96                            SkeletonBox { class: col.header_box_class }
97                        }
98                    }
99                }
100            }
101            tbody {
102                for r in 0..rows {
103                    tr { key: "{r}", class: "border-b border-[var(--color-paper-border)]/60 last:border-0",
104                        for (i, col) in columns.iter().enumerate() {
105                            td { key: "{i}", class: col.cell_class,
106                                match col.cell_shape {
107                                    SkeletonCellShape::Single(w) => rsx! {
108                                        SkeletonBox { class: w }
109                                    },
110                                    SkeletonCellShape::Stacked(w1, w2) => rsx! {
111                                        div { class: "space-y-1.5",
112                                            SkeletonBox { class: w1 }
113                                            SkeletonBox { class: w2 }
114                                        }
115                                    },
116                                    SkeletonCellShape::AvatarStacked { avatar, line1, line2 } => rsx! {
117                                        div { class: "flex items-center gap-2.5",
118                                            SkeletonBox { class: avatar }
119                                            div { class: "space-y-1 min-w-0 flex-1",
120                                                SkeletonBox { class: line1 }
121                                                SkeletonBox { class: line2 }
122                                            }
123                                        }
124                                    },
125                                }
126                            }
127                        }
128                    }
129                }
130            }
131        }
132    }
133}