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//! 表格沿用 `.admin-table`,在自身滚动区内吸顶;样式见 `admin-system.css`「SQL 结果」。
8
9use leptos::prelude::*;
10
11use crate::api::database::sql_console::SqlResult;
12use crate::components::ui::{Icon, IconName};
13
14/// 渲染数据行内的单个单元格(截断态)。
15///
16/// 按 `serde_json::Value` variant 分发样式:NULL 斜体灰、布尔彩色徽章、
17/// 数字右对齐等宽、文本截断省略。类型信息直接来自后端 `col_to_json` 的编码,
18/// 前端无需依赖 PG 类型名。
19fn render_cell(value: &serde_json::Value) -> AnyView {
20    use serde_json::Value;
21    match value {
22        Value::Null => view! { <span class="sql-null">"NULL"</span> }.into_any(),
23        Value::Bool(b) => {
24            let text = b.to_string();
25            let value = text.clone();
26            view! { <span class="sql-bool" data-value=value>{text}</span> }.into_any()
27        }
28        Value::Number(n) => view! { <span class="sql-num">{n.to_string()}</span> }.into_any(),
29        Value::String(s) => view! { <span class="sql-text">{s.clone()}</span> }.into_any(),
30        // Array / Object(理论不会出现,col_to_json 不产生复合类型,防御性兜底)
31        other => view! { <span class="sql-text">{other.to_string()}</span> }.into_any(),
32    }
33}
34
35/// 渲染展开详情区中的「列名: 完整值」单行。
36///
37/// 与 `render_cell` 不同:值不做截断,长文本在展开区内换行,超高时纵向滚动。
38fn render_expanded_value(col: &str, value: &serde_json::Value) -> impl IntoView {
39    use serde_json::Value;
40    let (display, is_null) = match value {
41        Value::Null => ("NULL".to_string(), true),
42        Value::Bool(b) => (b.to_string(), false),
43        Value::Number(n) => (n.to_string(), false),
44        Value::String(s) => (s.clone(), false),
45        other => (other.to_string(), false),
46    };
47    view! {
48        <div class="sql-detail-row">
49            <span class="sql-detail-key">{col.to_string()}</span>
50            {if is_null {
51                view! { <span class="sql-null">"NULL"</span> }.into_any()
52            } else {
53                view! { <span class="sql-detail-value">{display}</span> }.into_any()
54            }}
55        </div>
56    }
57}
58
59/// 渲染 SQL 查询结果表格。
60///
61/// Props:
62/// - `result`:后端返回的结果集(列名 + 每格 JSON 值)。
63/// - `initial_expanded`:初始展开的行索引,缺省全部收起(图鉴预览用)。
64///
65/// 一次只允许展开一行(`expanded_row` 信号记录行索引);点击已展开的行收起。
66#[component]
67pub fn SqlResultTable(
68    result: SqlResult,
69    #[prop(optional, into)] initial_expanded: MaybeProp<usize>,
70) -> impl IntoView {
71    let expanded_row = RwSignal::new(initial_expanded.get_untracked());
72    // colspan = 列数 + 行头箭头列
73    let expand_colspan = (result.columns.len() + 1).to_string();
74    let SqlResult { columns, rows, .. } = result;
75
76    let head_cells = columns
77        .iter()
78        .map(|col| view! { <th>{col.clone()}</th> })
79        .collect_view();
80
81    let body_rows = rows
82        .into_iter()
83        .enumerate()
84        .map(|(row_idx, row)| {
85            let cells = row
86                .iter()
87                .map(|cell| view! { <td>{render_cell(cell)}</td> })
88                .collect_view();
89            let columns = columns.clone();
90            let expand_colspan = expand_colspan.clone();
91            view! {
92                // 数据行:点击切换展开
93                <tr
94                    class="sql-row"
95                    data-expanded=move || {
96                        if expanded_row.get() == Some(row_idx) { "true" } else { "false" }
97                    }
98                    on:click=move |_| {
99                        let cur = expanded_row.get_untracked();
100                        expanded_row.set(if cur == Some(row_idx) { None } else { Some(row_idx) });
101                    }
102                >
103                    <td class="sql-toggle-cell">
104                        <Icon name=IconName::ChevronRight size=14/>
105                    </td>
106                    {cells}
107                </tr>
108                // 展开详情行(跨列)
109                {move || {
110                    (expanded_row.get() == Some(row_idx))
111                        .then(|| {
112                            let details = columns
113                                .iter()
114                                .enumerate()
115                                .map(|(ci, col)| {
116                                    render_expanded_value(
117                                        col,
118                                        row.get(ci).unwrap_or(&serde_json::Value::Null),
119                                    )
120                                })
121                                .collect_view();
122                            view! {
123                                <tr class="sql-detail">
124                                    <td colspan=expand_colspan.clone()>
125                                        <div class="sql-detail-body">{details}</div>
126                                    </td>
127                                </tr>
128                            }
129                        })
130                }}
131            }
132        })
133        .collect_view();
134
135    view! {
136        <div class="sql-result">
137            <table class="admin-table">
138                <thead>
139                    <tr>
140                        <th class="sql-toggle-cell">
141                            <span class="sr-only">"展开"</span>
142                        </th>
143                        {head_cells}
144                    </tr>
145                </thead>
146                <tbody>{body_rows}</tbody>
147            </table>
148        </div>
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn sample() -> SqlResult {
157        SqlResult {
158            columns: vec!["id".to_string(), "name".to_string(), "ok".to_string()],
159            rows: vec![
160                vec![
161                    serde_json::json!(1),
162                    serde_json::json!("alpha"),
163                    serde_json::json!(true),
164                ],
165                vec![
166                    serde_json::json!(2),
167                    serde_json::Value::Null,
168                    serde_json::json!(false),
169                ],
170            ],
171            statement_type: "SELECT".to_string(),
172            ..Default::default()
173        }
174    }
175
176    #[test]
177    fn renders_cells_by_value_variant_and_collapsed_rows() {
178        let html = Owner::new().with(|| view! { <SqlResultTable result=sample()/> }.to_html());
179        assert!(html.contains("class=\"sql-num\""), "{html}");
180        assert!(html.contains("class=\"sql-null\""), "{html}");
181        assert!(html.contains("data-value=\"true\""), "{html}");
182        assert!(html.contains("data-expanded=\"false\""), "{html}");
183        assert!(!html.contains("sql-detail"), "{html}");
184    }
185
186    #[test]
187    fn initial_expanded_row_renders_spanning_detail() {
188        let html = Owner::new()
189            .with(|| view! { <SqlResultTable result=sample() initial_expanded=1/> }.to_html());
190        assert!(html.contains("data-expanded=\"true\""), "{html}");
191        // 3 列 + 行头箭头列
192        assert!(html.contains("colspan=\"4\""), "{html}");
193        assert!(html.contains("sql-detail-key"), "{html}");
194    }
195}