From 1b934e72e01cb52d060e452bb26d18f9e47ef883 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 15 May 2026 23:27:09 +0800 Subject: [PATCH] feat(grid): add column formatters --- .gitignore | 1 + src/components/grid/DataGrid.vue | 300 +++++++++++++++++++++++++++++-- src/i18n/locales/en.ts | 18 ++ src/i18n/locales/zh-CN.ts | 18 ++ src/lib/columnFormatter.ts | 138 ++++++++++++++ src/stores/settingsStore.ts | 26 +++ tests/columnFormatter.test.ts | 66 +++++++ tests/settingsStore.test.ts | 23 +++ 8 files changed, 579 insertions(+), 11 deletions(-) create mode 100644 src/lib/columnFormatter.ts create mode 100644 tests/columnFormatter.test.ts diff --git a/.gitignore b/.gitignore index 8104738e95..ec9a152848 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .claude/ .codex/ +.superpowers/ .mcp.json # Build output diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 0686724ce6..da9aebf00c 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -81,6 +81,13 @@ import { import { formatGridSqlLiteral } from "@/lib/dataGridSql"; import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus"; import { displayCellValue, type CellValue } from "@/lib/cellValue"; +import { + applyColumnFormatter, + buildColumnFormatterKey, + normalizeColumnFormatter, + type ColumnFormatterConfig, + type DateTimeFormatterUnit, +} from "@/lib/columnFormatter"; import { isCancelSearchShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter"; @@ -268,6 +275,12 @@ const localColumnFilters = ref>>({}); const localFilterOpenColumn = ref(null); const localFilterSearch = ref(""); const localFilterDraft = ref(null); +const formatterOpenColumn = ref(null); +const formatterKind = ref("datetime"); +const formatterDateUnit = ref("auto"); +const formatterJsonPath = ref("$.user.name"); +const formatterMaskPrefix = ref(4); +const formatterMaskSuffix = ref(4); function localFilterKey(value: CellValue): string { if (value === null) return "__dbx_null__"; @@ -276,8 +289,8 @@ function localFilterKey(value: CellValue): string { return `str:${String(value)}`; } -function localFilterLabel(value: CellValue): string { - return value === null ? "NULL" : formatCell(value); +function localFilterLabel(value: CellValue, columnIndex: number): string { + return value === null ? "NULL" : formatCell(value, columnIndex); } function localFilterActive(colIdx: number): boolean { @@ -318,7 +331,7 @@ function buildLocalFilterOptions(columnIndex: number) { if (current) { current.count += 1; } else { - byKey.set(key, { key, label: localFilterLabel(value), count: 1, value }); + byKey.set(key, { key, label: localFilterLabel(value, columnIndex), count: 1, value }); } }; @@ -371,6 +384,96 @@ function closeLocalFilter() { localFilterSearch.value = ""; } +function formatterKeyForColumn(column: string): string | null { + if (!props.connectionId || !props.tableMeta) return null; + return buildColumnFormatterKey({ + connectionId: props.connectionId, + database: props.database, + schema: props.tableMeta.schema, + tableName: props.tableMeta.tableName, + column, + }); +} + +function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined { + const column = props.result.columns[columnIndex]; + if (!column) return undefined; + const key = formatterKeyForColumn(column); + return key ? settingsStore.editorSettings.columnFormatters[key] : undefined; +} + +function columnHasFormatter(columnIndex: number): boolean { + return !!columnFormatter(columnIndex); +} + +function currentFormatterDraft(): ColumnFormatterConfig { + if (formatterKind.value === "json-path") { + return { kind: "json-path", path: formatterJsonPath.value.trim() || "$" }; + } + if (formatterKind.value === "mask") { + return { + kind: "mask", + prefix: Math.max(0, Math.floor(Number(formatterMaskPrefix.value) || 0)), + suffix: Math.max(0, Math.floor(Number(formatterMaskSuffix.value) || 0)), + }; + } + return { kind: "datetime", unit: formatterDateUnit.value }; +} + +function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { + const draft = formatter ?? { kind: "datetime", unit: "auto" as const }; + formatterKind.value = draft.kind; + if (draft.kind === "datetime") { + formatterDateUnit.value = draft.unit; + } else if (draft.kind === "json-path") { + formatterJsonPath.value = draft.path; + } else if (draft.kind === "mask") { + formatterMaskPrefix.value = draft.prefix; + formatterMaskSuffix.value = draft.suffix; + } +} + +function openColumnFormatter(columnIndex: number) { + loadFormatterDraft(columnFormatter(columnIndex)); + formatterOpenColumn.value = columnIndex; +} + +function closeColumnFormatter() { + formatterOpenColumn.value = null; +} + +function saveColumnFormatter(columnIndex: number) { + const column = props.result.columns[columnIndex]; + const key = column ? formatterKeyForColumn(column) : null; + if (!key) return; + settingsStore.updateColumnFormatter(key, currentFormatterDraft()); + closeColumnFormatter(); +} + +function clearColumnFormatter(columnIndex: number) { + const column = props.result.columns[columnIndex]; + const key = column ? formatterKeyForColumn(column) : null; + if (!key) return; + settingsStore.updateColumnFormatter(key, undefined); + closeColumnFormatter(); +} + +function formatterDraftIsSavable(): boolean { + return !!normalizeColumnFormatter(currentFormatterDraft()); +} + +function formatterPreviewRows(columnIndex: number) { + const formatter = currentFormatterDraft(); + return displayItems.value.slice(0, 5).map((item, index) => { + const value = item.data[columnIndex] ?? null; + return { + index: index + 1, + raw: displayCellValue(value), + formatted: applyColumnFormatter(value, formatter), + }; + }); +} + function toggleLocalFilterValue(key: string) { const draft = localFilterDraft.value; if (!draft) return; @@ -1077,7 +1180,7 @@ const sortedRows = computed(() => { const rows = props.result.rows; indices = indices.filter((sourceIndex) => { const data = rows[sourceIndex]; - return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q)); + return data.some((cell, columnIndex) => cell !== null && formatCell(cell, columnIndex).toLowerCase().includes(q)); }); } return indices; @@ -1133,7 +1236,7 @@ const searchMatches = computed(() => { for (let r = 0; r < items.length; r++) { const data = items[r].data; for (let c = 0; c < data.length; c++) { - if (data[c] !== null && String(data[c]).toLowerCase().includes(q)) { + if (data[c] !== null && formatCell(data[c], c).toLowerCase().includes(q)) { matches.push({ displayRow: r, col: c }); } } @@ -1320,6 +1423,7 @@ const activeCellDetail = computed(() => { if (!item || !column) return null; const value = item.data[cell.col] ?? null; const rawValue = displayCellValue(value); + const displayValue = formatCell(value, cell.col); const valueText = value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value); const trimmed = valueText.trim(); const maybeJson = typeof value === "string" && (trimmed.startsWith("{") || trimmed.startsWith("[")); @@ -1340,6 +1444,7 @@ const activeCellDetail = computed(() => { comment: columnCommentMap.value.get(column) || "", value, rawValue, + displayValue, length: value === null ? 0 : String(value).length, formattedJson, isEditable: canEditRowItem(item), @@ -1557,10 +1662,9 @@ async function applyWhereFilter() { const CELL_DISPLAY_MAX_LENGTH = 256; -function formatCell(value: CellValue): string { - if (value === null) return "NULL"; - if (typeof value === "boolean") return value ? "true" : "false"; - const s = typeof value === "object" ? JSON.stringify(value) : String(value); +function formatCell(value: CellValue, columnIndex?: number): string { + const formatter = columnIndex === undefined ? undefined : columnFormatter(columnIndex); + const s = applyColumnFormatter(value, formatter); return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s; } @@ -1742,7 +1846,7 @@ const transposeData = computed(() => { column: col, type: columnTypeMap.value.get(col) || "", value: item.data[columnIndex], - display: formatCell(item.data[columnIndex]), + display: formatCell(item.data[columnIndex], columnIndex), isNull: item.data[columnIndex] === null, }; }); @@ -2406,6 +2510,173 @@ defineExpose({ /> + + + + + +
+
+ {{ t("grid.columnFormatterFor", { column: col }) }} +
+
+ {{ t("grid.columnFormatterHint") }} +
+
+
+
+
+ {{ t("grid.formatterType") }} +
+ +
+ +
+
+ {{ t("grid.formatterTimestampUnit") }} +
+ +
+ +
+
+ {{ t("grid.formatterJsonPathInput") }} +
+ +
+ +
+ + +
+ +
+
+ {{ t("grid.formatterPreview") }} +
+
+
+ {{ row.index }} + {{ row.raw }} + {{ row.formatted }} +
+
+
+
+ +
+ +
+ + +
+
+
+