Skip to main content

yggdrasil/components/
sql_result_table.rs

1//! SQL 查询结果表格组件。
2//!
3//! 消费后端 `SqlResult`(`crate::api::database::sql_console::SqlResult`),
4//! 按单元格的 `serde_json::Value` variant 渲染:
5//! NULL 显示为斜体灰色字面量,布尔显示为彩色小徽章,数字右对齐 + 等宽数字,
6//! 文本截断显示。长文本可通过点击行在行下方展开跨列详情区查看完整内容。
7//! 表头 sticky,列宽有界(`max-width`),避免长文本撑爆横向布局。
8
9use dioxus::prelude::*;
10
11use crate::api::database::sql_console::SqlResult;
12use crate::components::ui::ADMIN_TABLE_CLASS;
13
14/// 一行展开详情区容器的最大高度(超出纵向滚动,避免大 jsonb 撑爆页面)。
15const EXPAND_MAX_HEIGHT_CLASS: &str = "max-h-80";
16/// 文本单元格的列宽上限(Tailwind 任意值,约束长文本不无限拉伸)。
17const TEXT_CELL_MAX_WIDTH_CLASS: &str = "max-w-[24rem]";
18
19/// 渲染数据行内的单个单元格(截断态)。
20///
21/// 按 `serde_json::Value` variant 分发样式:NULL 斜体灰、布尔彩色徽章、
22/// 数字右对齐等宽、文本截断省略。类型信息直接来自后端 `col_to_json` 的编码,
23/// 前端无需依赖 PG 类型名。
24fn render_cell(value: &serde_json::Value) -> Element {
25    use serde_json::Value;
26    match value {
27        Value::Null => rsx! {
28            span { class: "italic text-[var(--color-paper-tertiary)] select-none", "NULL" }
29        },
30        Value::Bool(true) => rsx! {
31            span {
32                class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium",
33                style: "background-color: var(--color-paper-accent-soft); color: var(--color-paper-accent);",
34                "true"
35            }
36        },
37        Value::Bool(false) => rsx! {
38            span {
39                class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-amber-500/15 text-amber-700 dark:text-amber-400",
40                "false"
41            }
42        },
43        Value::Number(n) => rsx! {
44            span { class: "block text-right tabular-nums font-mono text-xs text-[var(--color-paper-primary)]",
45                "{n}"
46            }
47        },
48        Value::String(s) => rsx! {
49            span { class: "block font-mono text-xs text-[var(--color-paper-secondary)] truncate {TEXT_CELL_MAX_WIDTH_CLASS}",
50                "{s}"
51            }
52        },
53        // Array / Object(理论不会出现,col_to_json 不产生复合类型,防御性兜底)
54        other => rsx! {
55            span { class: "block font-mono text-xs text-[var(--color-paper-secondary)] truncate {TEXT_CELL_MAX_WIDTH_CLASS}",
56                "{other}"
57            }
58        },
59    }
60}
61
62/// 渲染展开详情区中的「列名: 完整值」单行。
63///
64/// 与 `render_cell` 不同:值不做截断,长文本换行由外层展开区容器
65/// (`whitespace-pre-wrap break-all` + `max-h-80 overflow-y-auto`)承载。
66fn render_expanded_value(col: &str, value: &serde_json::Value) -> Element {
67    use serde_json::Value;
68    let (display, is_null) = match value {
69        Value::Null => ("NULL".to_string(), true),
70        Value::Bool(b) => (b.to_string(), false),
71        Value::Number(n) => (n.to_string(), false),
72        Value::String(s) => (s.clone(), false),
73        other => (other.to_string(), false),
74    };
75    rsx! {
76        div { key: "{col}", class: "flex items-baseline gap-3 py-1 border-b border-[var(--color-paper-border)]/30 last:border-0",
77            span { class: "shrink-0 font-mono text-xs font-medium text-[var(--color-paper-tertiary)] min-w-[7rem] select-none",
78                "{col}"
79            }
80            if is_null {
81                span { class: "font-mono text-xs italic text-[var(--color-paper-tertiary)] select-none", "NULL" }
82            } else {
83                span { class: "font-mono text-xs text-[var(--color-paper-primary)] select-all", "{display}" }
84            }
85        }
86    }
87}
88
89/// SQL 查询结果表格。
90#[derive(Props, Clone, PartialEq)]
91pub struct SqlResultTableProps {
92    pub result: SqlResult,
93    #[props(default)]
94    pub initial_expanded: Option<usize>,
95}
96
97/// 渲染 SQL 查询结果表格。
98///
99/// 一次只允许展开一行(`expanded_row` 信号记录行索引)。
100/// `mut` 信号仅在 WASM 端被 `.set()`;server 构建下 `.set()` 调用在 cfg 门控块内被
101/// strip,故加 `cfg_attr` 抑制 server 目标的 `unused_mut` 警告。
102#[component]
103#[allow(non_snake_case)]
104#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
105pub fn SqlResultTable(props: SqlResultTableProps) -> Element {
106    let initial = props.initial_expanded;
107    let mut expanded_row: Signal<Option<usize>> = use_signal(move || initial);
108    let cols_len = props.result.columns.len();
109    // colspan = 列数 + 行头箭头列
110    let expand_colspan = cols_len + 1;
111
112    rsx! {
113        div { class: "{ADMIN_TABLE_CLASS}",
114            div { class: "overflow-auto max-h-[70vh]",
115                table { class: "w-full text-sm border-collapse",
116                    thead {
117                        tr { class: "border-b border-[var(--color-paper-border)] sticky top-0 bg-[var(--color-paper-entry)] z-10",
118                            th { class: "w-8 px-2 py-2.5 text-center", "" }
119                            for col in props.result.columns.iter() {
120                                th {
121                                    key: "{col}",
122                                    class: "px-4 py-2.5 text-left font-mono text-xs font-semibold tracking-wider text-[var(--color-paper-secondary)] whitespace-nowrap",
123                                    "{col}"
124                                }
125                            }
126                        }
127                    }
128                    tbody {
129                        for (row_idx, row) in props.result.rows.iter().enumerate() {
130                            // 数据行:点击切换展开
131                            tr {
132                                key: "row-{row_idx}",
133                                class: "group border-b border-[var(--color-paper-border)] last:border-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors cursor-pointer",
134                                onclick: move |_| {
135                                    let cur = expanded_row();
136                                    if cur == Some(row_idx) {
137                                        expanded_row.set(None);
138                                    } else {
139                                        expanded_row.set(Some(row_idx));
140                                    }
141                                },
142                                td { class: "w-8 px-2 py-2.5 text-center text-[var(--color-paper-tertiary)] text-xs select-none",
143                                    span {
144                                        class: format!(
145                                            "inline-block transition-transform duration-150 text-[10px] {}",
146                                            if expanded_row() == Some(row_idx) { "rotate-90 text-[var(--color-paper-accent)]" } else { "text-[var(--color-paper-tertiary)] group-hover:text-[var(--color-paper-secondary)]" }
147                                        ),
148                                        "▶"
149                                    }
150                                }
151                                for (ci, cell) in row.iter().enumerate() {
152                                    td { key: "{ci}", class: "px-4 py-2.5 align-middle", {render_cell(cell)} }
153                                }
154                            }
155                            // 展开详情行(跨列)
156                            if expanded_row() == Some(row_idx) {
157                                tr {
158                                    key: "row-{row_idx}-detail",
159                                    td {
160                                        colspan: "{expand_colspan}",
161                                        class: "px-5 py-3.5 bg-[var(--color-paper-theme)]/80 border-b border-[var(--color-paper-border)] shadow-inner",
162                                        div { class: "{EXPAND_MAX_HEIGHT_CLASS} overflow-y-auto space-y-1 whitespace-pre-wrap break-all",
163                                            for (ci, col) in props.result.columns.iter().enumerate() {
164                                                {render_expanded_value(col, row.get(ci).unwrap_or(&serde_json::Value::Null))}
165                                            }
166                                        }
167                                    }
168                                }
169                            }
170                        }
171                    }
172                }
173            }
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn props_default_initial_expanded_is_none() {
184        let result = SqlResult {
185            columns: vec!["id".to_string()],
186            rows: vec![vec![serde_json::json!(1)]],
187            statement_type: "SELECT".to_string(),
188            ..Default::default()
189        };
190        let props = SqlResultTableProps {
191            result,
192            initial_expanded: None,
193        };
194        assert_eq!(props.initial_expanded, None);
195    }
196}