From fc7eeb3696b01cadb342393e07abb3dc07b1a137 Mon Sep 17 00:00:00 2001 From: bourdon Date: Sun, 12 Oct 2025 06:15:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20table=20=E4=B8=8E?= =?UTF-8?q?=20WebUI=20=E9=9D=99=E6=80=81=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- table.go | 58 ++++--- webui/static/css/styles.css | 33 +++- webui/static/index.html | 1 + webui/static/js/components/data-view.js | 218 ++++++++++++++++++++++-- 4 files changed, 277 insertions(+), 33 deletions(-) diff --git a/table.go b/table.go index ac66404..04783d2 100644 --- a/table.go +++ b/table.go @@ -268,12 +268,15 @@ func (t *Table) normalizeInsertData(data any) ([]map[string]any, error) { typ = val.Type() } + // 获取解引用后的实际值 + actualData := val.Interface() + switch typ.Kind() { case reflect.Map: // map[string]any - 单条 - m, ok := data.(map[string]any) + m, ok := actualData.(map[string]any) if !ok { - return nil, fmt.Errorf("expected map[string]any, got %T", data) + return nil, fmt.Errorf("expected map[string]any, got %T", actualData) } return []map[string]any{m}, nil @@ -283,9 +286,9 @@ func (t *Table) normalizeInsertData(data any) ([]map[string]any, error) { // []map[string]any if elemType.Kind() == reflect.Map { - maps, ok := data.([]map[string]any) + maps, ok := actualData.([]map[string]any) if !ok { - return nil, fmt.Errorf("expected []map[string]any, got %T", data) + return nil, fmt.Errorf("expected []map[string]any, got %T", actualData) } return maps, nil } @@ -321,14 +324,14 @@ func (t *Table) normalizeInsertData(data any) ([]map[string]any, error) { case reflect.Struct: // struct{} - 单个结构体 - m, err := t.structToMap(data) + m, err := t.structToMap(actualData) if err != nil { return nil, err } return []map[string]any{m}, nil default: - return nil, fmt.Errorf("unsupported data type: %T (kind: %s)", data, typ.Kind()) + return nil, fmt.Errorf("unsupported data type: %T (kind: %s)", actualData, typ.Kind()) } } @@ -356,32 +359,47 @@ func (t *Table) structToMap(v any) (map[string]any, error) { continue } - // 获取字段名 - fieldName := field.Name + // 解析 srdb tag tag := field.Tag.Get("srdb") - - // 跳过忽略的字段 if tag == "-" { + // 忽略该字段 continue } - // 解析 tag 获取字段名 + // 默认使用 snake_case 转换字段名 + fieldName := camelToSnake(field.Name) + + // 解析 tag(与 StructToFields 保持一致) if tag != "" { parts := strings.Split(tag, ";") - if parts[0] != "" { - fieldName = parts[0] - } else { - // 使用 snake_case 转换 - fieldName = camelToSnake(field.Name) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // 检查是否为 field:xxx 格式 + if strings.HasPrefix(part, "field:") { + fieldName = strings.TrimPrefix(part, "field:") + break // 找到字段名,停止解析 + } + // 忽略其他标记(indexed, nullable, comment:xxx) } - } else { - // 没有 tag,使用 snake_case 转换 - fieldName = camelToSnake(field.Name) } // 获取字段值 fieldVal := val.Field(i) - result[fieldName] = fieldVal.Interface() + + // 处理指针类型:如果是指针,解引用(nil 保持为 nil) + if fieldVal.Kind() == reflect.Pointer { + if fieldVal.IsNil() { + result[fieldName] = nil + } else { + result[fieldName] = fieldVal.Elem().Interface() + } + } else { + result[fieldName] = fieldVal.Interface() + } } return result, nil diff --git a/webui/static/css/styles.css b/webui/static/css/styles.css index 580fbd1..a0020f2 100644 --- a/webui/static/css/styles.css +++ b/webui/static/css/styles.css @@ -7,18 +7,18 @@ --primary-light: #818cf8; --primary-bg: rgba(99, 102, 241, 0.1); - /* 背景色 */ + /* 背景色 - 深色主题(默认)*/ --bg-main: #0f0f1a; --bg-surface: #1a1a2e; --bg-elevated: #222236; --bg-hover: #2a2a3e; - /* 文字颜色 */ + /* 文字颜色 - 深色主题 */ --text-primary: #ffffff; --text-secondary: #a0a0b0; --text-tertiary: #6b6b7b; - /* 边框和分隔线 */ + /* 边框和分隔线 - 深色主题 */ --border-color: rgba(255, 255, 255, 0.1); --border-hover: rgba(255, 255, 255, 0.2); @@ -47,6 +47,33 @@ --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } +/* 浅色主题 */ +:root[data-theme="light"] { + /* 背景色 - 浅色主题 */ + --bg-main: #f5f5f7; + --bg-surface: #ffffff; + --bg-elevated: #ffffff; + --bg-hover: #f0f0f2; + + /* 文字颜色 - 浅色主题 */ + --text-primary: #1d1d1f; + --text-secondary: #6e6e73; + --text-tertiary: #86868b; + + /* 边框和分隔线 - 浅色主题 */ + --border-color: rgba(0, 0, 0, 0.1); + --border-hover: rgba(0, 0, 0, 0.2); + + /* 阴影 - 浅色主题 */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + * { box-sizing: border-box; margin: 0; diff --git a/webui/static/index.html b/webui/static/index.html index acf1c1c..e64ef7f 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -18,6 +18,7 @@ "imports": { "lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js", "lit/": "https://cdn.jsdelivr.net/gh/lit/dist@3/", + "dom-align": "https://cdn.jsdelivr.net/npm/dom-align@1.12.4/+esm", "~/": "/static/js/" } } diff --git a/webui/static/js/components/data-view.js b/webui/static/js/components/data-view.js index bb3e103..74c70b8 100644 --- a/webui/static/js/components/data-view.js +++ b/webui/static/js/components/data-view.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from 'lit'; import { sharedStyles, cssVariables } from '~/common/shared-styles.js'; +import domAlign from 'dom-align'; export class DataView extends LitElement { static properties = { @@ -127,11 +128,7 @@ export class DataView extends LitElement { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - - .data-table td:hover { - white-space: normal; - word-break: break-all; + cursor: pointer; } .data-table tr:hover { @@ -167,6 +164,113 @@ export class DataView extends LitElement { this.tableData = null; this.selectedColumns = []; this.loading = false; + this.hidePopoverTimeout = null; + this.popoverElement = null; + this.themeObserver = null; + } + + connectedCallback() { + super.connectedCallback(); + // 创建 popover 元素并添加到 body + this.createPopoverElement(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + // 移除 popover 元素 + this.removePopoverElement(); + } + + createPopoverElement() { + if (this.popoverElement) return; + + this.popoverElement = document.createElement('div'); + this.popoverElement.className = 'srdb-cell-popover'; + + // 从 CSS 变量中获取主题颜色 + this.updatePopoverTheme(); + + // 添加滚动条样式(使用 CSS 变量) + if (!document.getElementById('srdb-popover-scrollbar-style')) { + const style = document.createElement('style'); + style.id = 'srdb-popover-scrollbar-style'; + style.textContent = ` + .srdb-cell-popover::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .srdb-cell-popover::-webkit-scrollbar-track { + background: var(--bg-surface); + border-radius: 4px; + } + .srdb-cell-popover::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + } + .srdb-cell-popover::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); + } + `; + document.head.appendChild(style); + } + + this.popoverElement.addEventListener('mouseenter', () => this.keepPopover()); + this.popoverElement.addEventListener('mouseleave', () => this.hidePopover()); + + document.body.appendChild(this.popoverElement); + + // 监听主题变化 + this.themeObserver = new MutationObserver(() => { + this.updatePopoverTheme(); + }); + this.themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'] + }); + } + + updatePopoverTheme() { + if (!this.popoverElement) return; + + const rootStyles = getComputedStyle(document.documentElement); + const bgElevated = rootStyles.getPropertyValue('--bg-elevated').trim(); + const textPrimary = rootStyles.getPropertyValue('--text-primary').trim(); + const borderColor = rootStyles.getPropertyValue('--border-color').trim(); + const shadowMd = rootStyles.getPropertyValue('--shadow-md').trim(); + const radiusMd = rootStyles.getPropertyValue('--radius-md').trim(); + + this.popoverElement.style.cssText = ` + position: fixed; + z-index: 9999; + background: ${bgElevated}; + border: 1px solid ${borderColor}; + border-radius: ${radiusMd}; + box-shadow: ${shadowMd}; + padding: 12px; + max-width: 500px; + max-height: 400px; + overflow: auto; + font-size: 13px; + color: ${textPrimary}; + white-space: pre-wrap; + word-break: break-word; + font-family: 'Courier New', monospace; + opacity: 0; + transition: opacity 0.15s ease-in-out, background 0.3s ease, color 0.3s ease, border-color 0.3s ease; + display: none; + pointer-events: auto; + `; + } + + removePopoverElement() { + if (this.popoverElement) { + this.popoverElement.remove(); + this.popoverElement = null; + } + if (this.themeObserver) { + this.themeObserver.disconnect(); + this.themeObserver = null; + } } updated(changedProperties) { @@ -244,6 +348,97 @@ export class DataView extends LitElement { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } + formatValue(value) { + // 处理 null 和 undefined + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + // 处理 Object 和 Array 类型 + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch (e) { + return '[Object]'; + } + } + + // 其他类型直接返回 + return value; + } + + showPopover(event, value, col) { + if (!this.popoverElement) return; + + // 清除之前的隐藏定时器 + if (this.hidePopoverTimeout) { + clearTimeout(this.hidePopoverTimeout); + this.hidePopoverTimeout = null; + } + + // 格式化值 + let content = col === '_time' ? this.formatTime(value) : this.formatValue(value); + + // 对于 JSON 对象/数组,格式化显示 + if (typeof value === 'object' && value !== null) { + try { + content = JSON.stringify(value, null, 2); + } catch (e) { + content = String(content); + } + } else { + content = String(content); + } + + // 只在内容较长时显示 popover + if (content.length < 50) { + return; + } + + // 更新 popover 内容 + this.popoverElement.textContent = content; + this.popoverElement.style.display = 'block'; + + // 使用 dom-align 进行智能定位 + // 减小间隙到 2px,方便鼠标移入 + domAlign(this.popoverElement, event.target, { + points: ['tl', 'tr'], // popover左上角 对齐到 单元格右上角 + offset: [2, 0], // 右侧间距仅2px,便于鼠标移入 + overflow: { adjustX: true, adjustY: true } + }); + + // 使用 setTimeout 确保 dom-align 完成后再显示 + setTimeout(() => { + if (this.popoverElement) { + this.popoverElement.style.opacity = '1'; + } + }, 10); + } + + hidePopover() { + if (!this.popoverElement) return; + + // 延迟隐藏,给用户足够时间移动鼠标到 popover(300ms) + this.hidePopoverTimeout = setTimeout(() => { + if (this.popoverElement) { + this.popoverElement.style.opacity = '0'; + // 等待动画完成后再隐藏 + setTimeout(() => { + if (this.popoverElement) { + this.popoverElement.style.display = 'none'; + } + }, 150); + } + }, 300); + } + + keepPopover() { + // 鼠标进入 popover 时,取消隐藏 + if (this.hidePopoverTimeout) { + clearTimeout(this.hidePopoverTimeout); + this.hidePopoverTimeout = null; + } + } + getColumns() { let columns = []; @@ -274,9 +469,9 @@ export class DataView extends LitElement { return html` ${this.renderSchemaSection()} - +

Data (${this.formatCount(this.tableData.totalRows)} rows)

- + ${this.tableData.data.length === 0 ? html`

No data available

` : html` @@ -292,13 +487,16 @@ export class DataView extends LitElement { ${this.tableData.data.map(row => html` ${columns.map(col => html` - - ${col === '_time' ? this.formatTime(row[col]) : row[col]} + this.showPopover(e, row[col], col)} + @mouseleave=${() => this.hidePopover()} + > + ${col === '_time' ? this.formatTime(row[col]) : this.formatValue(row[col])} ${row[col + '_truncated'] ? html`✂️` : ''} `)} -