- runner.rs: 把含函数调用表达式的 rustdoc 链接 `[crate::codemirror_bridge::get_module().create]` 改为普通代码片段;get_module 是 WASM 专属符号,server target rustdoc 解析不到 - sql_result_table.rs: 模块级 doc 里裸用 `[SqlResult]` 但该类型来自 server-gated 模块不在作用域,改为纯代码片段并补全路径
168 lines
7.5 KiB
Rust
168 lines
7.5 KiB
Rust
//! SQL 查询结果表格组件。
|
||
//!
|
||
//! 消费后端 `SqlResult`(`crate::api::database::sql_console::SqlResult`),
|
||
//! 按单元格的 `serde_json::Value` variant 渲染:
|
||
//! NULL 显示为斜体灰色字面量,布尔显示为彩色小徽章,数字右对齐 + 等宽数字,
|
||
//! 文本截断显示。长文本可通过点击行在行下方展开跨列详情区查看完整内容。
|
||
//! 表头 sticky,列宽有界(`max-width`),避免长文本撑爆横向布局。
|
||
|
||
use dioxus::prelude::*;
|
||
|
||
use crate::api::database::sql_console::SqlResult;
|
||
use crate::components::ui::ADMIN_TABLE_CLASS;
|
||
|
||
/// 一行展开详情区容器的最大高度(超出纵向滚动,避免大 jsonb 撑爆页面)。
|
||
const EXPAND_MAX_HEIGHT_CLASS: &str = "max-h-80";
|
||
/// 文本单元格的列宽上限(Tailwind 任意值,约束长文本不无限拉伸)。
|
||
const TEXT_CELL_MAX_WIDTH_CLASS: &str = "max-w-[24rem]";
|
||
|
||
/// 渲染数据行内的单个单元格(截断态)。
|
||
///
|
||
/// 按 `serde_json::Value` variant 分发样式:NULL 斜体灰、布尔彩色徽章、
|
||
/// 数字右对齐等宽、文本截断省略。类型信息直接来自后端 `col_to_json` 的编码,
|
||
/// 前端无需依赖 PG 类型名。
|
||
fn render_cell(value: &serde_json::Value) -> Element {
|
||
use serde_json::Value;
|
||
match value {
|
||
Value::Null => rsx! {
|
||
span { class: "italic text-[var(--color-paper-tertiary)]", "NULL" }
|
||
},
|
||
Value::Bool(true) => rsx! {
|
||
span {
|
||
class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono",
|
||
style: "background-color: var(--color-paper-accent-soft); color: var(--color-paper-accent);",
|
||
"true"
|
||
}
|
||
},
|
||
Value::Bool(false) => rsx! {
|
||
span {
|
||
class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono",
|
||
style: "background-color: rgb(254 243 199); color: rgb(180 83 9);",
|
||
"false"
|
||
}
|
||
},
|
||
Value::Number(n) => rsx! {
|
||
span { class: "block text-right tabular-nums text-[var(--color-paper-primary)]",
|
||
"{n}"
|
||
}
|
||
},
|
||
Value::String(s) => rsx! {
|
||
span {
|
||
class: "block font-mono text-xs text-[var(--color-paper-secondary)] truncate {TEXT_CELL_MAX_WIDTH_CLASS}",
|
||
"{s}"
|
||
}
|
||
},
|
||
// Array / Object(理论不会出现,col_to_json 不产生复合类型,防御性兜底)
|
||
other => rsx! {
|
||
span { class: "block font-mono text-xs text-[var(--color-paper-secondary)] truncate {TEXT_CELL_MAX_WIDTH_CLASS}",
|
||
"{other}"
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 渲染展开详情区中的「列名: 完整值」单行。
|
||
///
|
||
/// 与 `render_cell` 不同:值不做截断,长文本换行由外层展开区容器
|
||
/// (`whitespace-pre-wrap break-all` + `max-h-80 overflow-y-auto`)承载。
|
||
fn render_expanded_value(col: &str, value: &serde_json::Value) -> Element {
|
||
use serde_json::Value;
|
||
let display = match value {
|
||
Value::Null => "NULL".to_string(),
|
||
Value::Bool(b) => b.to_string(),
|
||
Value::Number(n) => n.to_string(),
|
||
Value::String(s) => s.clone(),
|
||
other => other.to_string(),
|
||
};
|
||
rsx! {
|
||
div { class: "flex gap-2 py-0.5",
|
||
span { class: "shrink-0 font-mono text-xs text-[var(--color-paper-tertiary)] min-w-[6rem]",
|
||
"{col}"
|
||
}
|
||
span { class: "font-mono text-xs text-[var(--color-paper-secondary)]",
|
||
"{display}"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// SQL 查询结果表格。
|
||
#[derive(Props, Clone, PartialEq)]
|
||
pub struct SqlResultTableProps {
|
||
pub result: SqlResult,
|
||
}
|
||
|
||
/// 渲染 SQL 查询结果表格。
|
||
///
|
||
/// 一次只允许展开一行(`expanded_row` 信号记录行索引)。
|
||
/// `mut` 信号仅在 WASM 端被 `.set()`;server 构建下 `.set()` 调用在 cfg 门控块内被
|
||
/// strip,故加 `cfg_attr` 抑制 server 目标的 `unused_mut` 警告。
|
||
#[component]
|
||
#[allow(non_snake_case)]
|
||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
|
||
pub fn SqlResultTable(props: SqlResultTableProps) -> Element {
|
||
let mut expanded_row: Signal<Option<usize>> = use_signal(|| None);
|
||
let cols_len = props.result.columns.len();
|
||
// colspan = 列数 + 行头箭头列
|
||
let expand_colspan = cols_len + 1;
|
||
|
||
rsx! {
|
||
div { class: "{ADMIN_TABLE_CLASS}",
|
||
div { class: "overflow-auto max-h-[70vh]",
|
||
table { class: "w-full text-sm border-collapse",
|
||
thead {
|
||
tr { class: "border-b border-[var(--color-paper-border)] sticky top-0 bg-[var(--color-paper-entry)] z-10",
|
||
th { class: "w-8 px-2 py-2", "" }
|
||
for col in props.result.columns.iter() {
|
||
th {
|
||
class: "px-4 py-2 text-left font-medium whitespace-nowrap text-[var(--color-paper-secondary)]",
|
||
"{col}"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
tbody {
|
||
for (row_idx, row) in props.result.rows.iter().enumerate() {
|
||
// 数据行:点击切换展开
|
||
tr {
|
||
class: "border-b border-[var(--color-paper-border)] last:border-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors cursor-pointer",
|
||
onclick: move |_| {
|
||
let cur = expanded_row();
|
||
if cur == Some(row_idx) {
|
||
expanded_row.set(None);
|
||
} else {
|
||
expanded_row.set(Some(row_idx));
|
||
}
|
||
},
|
||
td { class: "px-2 py-2 text-center text-[var(--color-paper-tertiary)] text-xs select-none",
|
||
if expanded_row() == Some(row_idx) { "▾" } else { "▸" }
|
||
}
|
||
for cell in row.iter() {
|
||
td {
|
||
class: "px-4 py-2 align-top",
|
||
{render_cell(cell)}
|
||
}
|
||
}
|
||
}
|
||
// 展开详情行(跨列)
|
||
if expanded_row() == Some(row_idx) {
|
||
tr {
|
||
td { colspan: "{expand_colspan}",
|
||
class: "px-4 py-3 bg-[var(--color-paper-theme)]",
|
||
div {
|
||
class: "{EXPAND_MAX_HEIGHT_CLASS} overflow-y-auto space-y-0.5 whitespace-pre-wrap break-all",
|
||
for (ci, col) in props.result.columns.iter().enumerate() {
|
||
{render_expanded_value(col, row.get(ci).unwrap_or(&serde_json::Value::Null))}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|