From d95225d641661e807e59fe48b4e1f2b986787e52 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 17 May 2026 22:18:08 +0800 Subject: [PATCH] feat(grid): add custom column formatters --- src/components/grid/DataGrid.vue | 136 +++++++++++++++++++++++++++++-- src/i18n/locales/en.ts | 8 ++ src/i18n/locales/zh-CN.ts | 7 ++ src/lib/columnFormatter.ts | 68 ++++++++++++++-- src/stores/settingsStore.ts | 47 ++++++++++- tests/columnFormatter.test.ts | 28 +++++++ tests/settingsStore.test.ts | 10 +++ 7 files changed, 289 insertions(+), 15 deletions(-) diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index a0987b239..05cb6a2db 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -95,6 +95,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, normalizeColumnFormatter, + resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, } from "@/lib/columnFormatter"; @@ -294,11 +295,22 @@ const localFilterOpenColumn = ref(null); const localFilterSearch = ref(""); const localFilterDraft = ref(null); const formatterOpenColumn = ref(null); -const formatterKind = ref("datetime"); +type FormatterDraftKind = Exclude; +const CUSTOM_FORMATTER_NEW = "__new"; +const formatterKind = ref("datetime"); const formatterDateUnit = ref("auto"); const formatterJsonPath = ref("$.user.name"); const formatterMaskPrefix = ref(4); const formatterMaskSuffix = ref(4); +const formatterCustomId = ref(CUSTOM_FORMATTER_NEW); +const formatterCustomName = ref(""); +const formatterCustomTemplate = ref("${value}"); + +const savedCustomFormatters = computed(() => { + return Object.values(settingsStore.editorSettings.customColumnFormatters).sort((a, b) => + a.name.localeCompare(b.name), + ); +}); function localFilterKey(value: CellValue): string { if (value === null) return "__dbx_null__"; @@ -414,6 +426,18 @@ function formatterKeyForColumn(column: string): string | null { } function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined { + const column = props.result.columns[columnIndex]; + if (!column) return undefined; + const key = formatterKeyForColumn(column); + return key + ? resolveColumnFormatter( + settingsStore.editorSettings.columnFormatters[key], + settingsStore.editorSettings.customColumnFormatters, + ) + : undefined; +} + +function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined { const column = props.result.columns[columnIndex]; if (!column) return undefined; const key = formatterKeyForColumn(column); @@ -435,12 +459,15 @@ function currentFormatterDraft(): ColumnFormatterConfig { suffix: Math.max(0, Math.floor(Number(formatterMaskSuffix.value) || 0)), }; } + if (formatterKind.value === "custom-template") { + return { kind: "custom-template", template: formatterCustomTemplate.value.trim() || "${value}" }; + } return { kind: "datetime", unit: formatterDateUnit.value }; } function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { const draft = formatter ?? { kind: "datetime", unit: "auto" as const }; - formatterKind.value = draft.kind; + formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind; if (draft.kind === "datetime") { formatterDateUnit.value = draft.unit; } else if (draft.kind === "json-path") { @@ -448,11 +475,20 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { } else if (draft.kind === "mask") { formatterMaskPrefix.value = draft.prefix; formatterMaskSuffix.value = draft.suffix; + } else if (draft.kind === "custom-ref") { + const saved = settingsStore.editorSettings.customColumnFormatters[draft.formatterId]; + formatterCustomId.value = saved ? saved.id : CUSTOM_FORMATTER_NEW; + formatterCustomName.value = saved?.name ?? ""; + formatterCustomTemplate.value = saved?.template ?? "${value}"; + } else if (draft.kind === "custom-template") { + formatterCustomId.value = CUSTOM_FORMATTER_NEW; + formatterCustomName.value = ""; + formatterCustomTemplate.value = draft.template; } } function openColumnFormatter(columnIndex: number) { - loadFormatterDraft(columnFormatter(columnIndex)); + loadFormatterDraft(savedColumnFormatter(columnIndex)); formatterOpenColumn.value = columnIndex; } @@ -464,7 +500,17 @@ function saveColumnFormatter(columnIndex: number) { const column = props.result.columns[columnIndex]; const key = column ? formatterKeyForColumn(column) : null; if (!key) return; - settingsStore.updateColumnFormatter(key, currentFormatterDraft()); + let formatter = currentFormatterDraft(); + if (formatterKind.value === "custom-template" && formatterCustomName.value.trim()) { + const id = formatterCustomId.value === CUSTOM_FORMATTER_NEW ? createCustomFormatterId() : formatterCustomId.value; + const saved = settingsStore.upsertCustomColumnFormatter({ + id, + name: formatterCustomName.value, + template: formatterCustomTemplate.value, + }); + if (saved) formatter = { kind: "custom-ref", formatterId: saved.id }; + } + settingsStore.updateColumnFormatter(key, formatter); closeColumnFormatter(); } @@ -480,8 +526,29 @@ function formatterDraftIsSavable(): boolean { return !!normalizeColumnFormatter(currentFormatterDraft()); } +function selectCustomFormatter(value: string) { + formatterCustomId.value = value; + if (value === CUSTOM_FORMATTER_NEW) { + formatterCustomName.value = ""; + formatterCustomTemplate.value = "${value}"; + return; + } + const saved = settingsStore.editorSettings.customColumnFormatters[value]; + if (!saved) return; + formatterCustomName.value = saved.name; + formatterCustomTemplate.value = saved.template; +} + +function createCustomFormatterId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return `fmt_${crypto.randomUUID()}`; + return `fmt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; +} + function formatterPreviewRows(columnIndex: number) { - const formatter = currentFormatterDraft(); + const formatter = resolveColumnFormatter( + currentFormatterDraft(), + settingsStore.editorSettings.customColumnFormatters, + ); return displayItems.value.slice(0, 5).map((item, index) => { const value = item.data[columnIndex] ?? null; return { @@ -2810,6 +2877,9 @@ defineExpose({ {{ t("grid.formatterDatetime") }} {{ t("grid.formatterJsonPath") }} {{ t("grid.formatterMask") }} + {{ + t("grid.formatterCustomTemplate") + }} @@ -2849,7 +2919,7 @@ defineExpose({ /> -
+
+
+
+
+ {{ t("grid.formatterSavedCustom") }} +
+ +
+ + +
+ {{ t("grid.formatterCustomTemplateHint") }} +
+
+
{{ t("grid.formatterPreview") }} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1deece9c5..1c8cadb80 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -270,6 +270,7 @@ export default { formatterDatetime: "Unix timestamp", formatterJsonPath: "JSON path", formatterMask: "Mask text", + formatterCustomTemplate: "Custom template", formatterTimestampUnit: "Timestamp unit", formatterUnitAuto: "Auto", formatterUnitSeconds: "Seconds", @@ -277,6 +278,13 @@ export default { formatterJsonPathInput: "JSON path", formatterMaskPrefix: "Visible prefix", formatterMaskSuffix: "Visible suffix", + formatterSavedCustom: "Saved templates", + formatterNewCustom: "New template", + formatterCustomName: "Template name", + formatterCustomNamePlaceholder: "Example: User label", + formatterCustomTemplateInput: "Template", + formatterCustomTemplateHint: + "Available variables: ${value}, ${upper}, ${lower}, ${length}. Give it a name to save it to the list.", formatterPreview: "Preview", saveFormatter: "Save", clearFormatter: "Clear", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index d1952c54a..f91d4740b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -267,6 +267,7 @@ export default { formatterDatetime: "Unix 时间戳", formatterJsonPath: "JSON 路径", formatterMask: "文本脱敏", + formatterCustomTemplate: "自定义模板", formatterTimestampUnit: "时间戳单位", formatterUnitAuto: "自动", formatterUnitSeconds: "秒", @@ -274,6 +275,12 @@ export default { formatterJsonPathInput: "JSON 路径", formatterMaskPrefix: "前缀保留", formatterMaskSuffix: "后缀保留", + formatterSavedCustom: "已保存模板", + formatterNewCustom: "新建模板", + formatterCustomName: "模板名称", + formatterCustomNamePlaceholder: "例如:用户标签", + formatterCustomTemplateInput: "模板", + formatterCustomTemplateHint: "可用变量:${value}、${upper}、${lower}、${length}。填写名称后会保存到列表。", formatterPreview: "预览", saveFormatter: "保存", clearFormatter: "清除", diff --git a/src/lib/columnFormatter.ts b/src/lib/columnFormatter.ts index 105629c3f..2df6b02db 100644 --- a/src/lib/columnFormatter.ts +++ b/src/lib/columnFormatter.ts @@ -2,10 +2,18 @@ import { displayCellValue, type CellValue } from "@/lib/cellValue"; export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto"; +export interface CustomColumnFormatterConfig { + id: string; + name: string; + template: string; +} + export type ColumnFormatterConfig = | { kind: "datetime"; unit: DateTimeFormatterUnit } | { kind: "json-path"; path: string } - | { kind: "mask"; prefix: number; suffix: number }; + | { kind: "mask"; prefix: number; suffix: number } + | { kind: "custom-template"; template: string } + | { kind: "custom-ref"; formatterId: string }; export interface ColumnFormatterKeyParts { connectionId: string; @@ -41,24 +49,60 @@ export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig return { kind: "mask", prefix: config.prefix as number, suffix: config.suffix as number }; } + if (config.kind === "custom-template") { + return typeof config.template === "string" && config.template.trim() + ? { kind: "custom-template", template: config.template.slice(0, 500) } + : undefined; + } + + if (config.kind === "custom-ref") { + return typeof config.formatterId === "string" && config.formatterId.trim() + ? { kind: "custom-ref", formatterId: config.formatterId } + : undefined; + } + return undefined; } +export function normalizeCustomColumnFormatter(value: unknown): CustomColumnFormatterConfig | undefined { + if (!value || typeof value !== "object") return undefined; + const config = value as Record; + if (typeof config.id !== "string" || !config.id.trim()) return undefined; + if (typeof config.name !== "string" || !config.name.trim()) return undefined; + if (typeof config.template !== "string" || !config.template.trim()) return undefined; + return { + id: config.id.trim(), + name: config.name.trim().slice(0, 80), + template: config.template.slice(0, 500), + }; +} + +export function resolveColumnFormatter( + formatter: ColumnFormatterConfig | undefined, + customFormatters: Record, +): ColumnFormatterConfig | undefined { + if (!formatter) return undefined; + if (formatter.kind !== "custom-ref") return formatter; + const customFormatter = customFormatters[formatter.formatterId]; + return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined; +} + export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string { if (!formatter) return displayCellValue(value); - if (value === null) return displayCellValue(value); try { if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit); if (formatter.kind === "json-path") return formatJsonPath(value, formatter.path); if (formatter.kind === "mask") return formatMask(value, formatter); + if (formatter.kind === "custom-template") return formatCustomTemplate(value, formatter.template); return displayCellValue(value); } catch { return displayCellValue(value); } } -function formatDateTime(value: Exclude, unit: DateTimeFormatterUnit): string { +function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit): string { + if (value === null) return displayCellValue(value); const numeric = typeof value === "number" ? value : Number(String(value).trim()); if (!Number.isFinite(numeric)) return displayCellValue(value); const timestamp = @@ -67,7 +111,8 @@ function formatDateTime(value: Exclude, unit: DateTimeFormatter return Number.isNaN(date.getTime()) ? displayCellValue(value) : date.toLocaleString(); } -function formatJsonPath(value: Exclude, path: string): string { +function formatJsonPath(value: CellValue, path: string): string { + if (value === null) return displayCellValue(value); if (typeof value !== "string") return displayCellValue(value); const parsed = JSON.parse(value); const tokens = parseJsonPath(path); @@ -90,10 +135,8 @@ function formatJsonPath(value: Exclude, path: string): string { return String(current); } -function formatMask( - value: Exclude, - formatter: Extract, -): string { +function formatMask(value: CellValue, formatter: Extract): string { + if (value === null) return displayCellValue(value); const text = displayCellValue(value); const visibleCount = formatter.prefix + formatter.suffix; if (text.length <= visibleCount) return "*".repeat(text.length); @@ -102,6 +145,15 @@ function formatMask( )}`; } +function formatCustomTemplate(value: CellValue, template: string): string { + const text = displayCellValue(value); + return template + .replaceAll("${value}", text) + .replaceAll("${upper}", text.toUpperCase()) + .replaceAll("${lower}", text.toLowerCase()) + .replaceAll("${length}", String(text.length)); +} + function isSupportedJsonPath(path: string): boolean { if (!path.startsWith("$")) return false; try { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index b475d5722..55a9e6c4a 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,7 +1,12 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import * as api from "@/lib/api"; -import { normalizeColumnFormatter, type ColumnFormatterConfig } from "@/lib/columnFormatter"; +import { + normalizeColumnFormatter, + normalizeCustomColumnFormatter, + type ColumnFormatterConfig, + type CustomColumnFormatterConfig, +} from "@/lib/columnFormatter"; import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/shortcutRegistry"; import type { SidebarActivation } from "@/lib/treeNodeClick"; @@ -151,6 +156,7 @@ export interface EditorSettings { shortcuts: ShortcutSettings; sidebarActivation: SidebarActivation; columnFormatters: Record; + customColumnFormatters: Record; } export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [ @@ -187,6 +193,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { shortcuts: normalizeShortcutSettings(), sidebarActivation: "single", columnFormatters: {}, + customColumnFormatters: {}, }; export const STORAGE_KEY = "dbx-editor-settings"; @@ -202,6 +209,16 @@ function normalizeColumnFormatters(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const formatters: Record = {}; + for (const formatter of Object.values(value as Record)) { + const normalized = normalizeCustomColumnFormatter(formatter); + if (normalized) formatters[normalized.id] = normalized; + } + return formatters; +} + export function normalizeEditorSettings(settings: Partial): EditorSettings { return { fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily, @@ -218,6 +235,7 @@ export function normalizeEditorSettings(settings: Partial): Edit ? settings.sidebarActivation : DEFAULT_EDITOR_SETTINGS.sidebarActivation, columnFormatters: normalizeColumnFormatters(settings.columnFormatters), + customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters), }; } @@ -309,6 +327,31 @@ export const useSettingsStore = defineStore("settings", () => { updateEditorSettings({ columnFormatters }); } + function upsertCustomColumnFormatter( + formatter: CustomColumnFormatterConfig, + ): CustomColumnFormatterConfig | undefined { + const normalized = normalizeCustomColumnFormatter(formatter); + if (!normalized) return undefined; + updateEditorSettings({ + customColumnFormatters: { + ...editorSettings.value.customColumnFormatters, + [normalized.id]: normalized, + }, + }); + return normalized; + } + + function deleteCustomColumnFormatter(id: string) { + const customColumnFormatters = { ...editorSettings.value.customColumnFormatters }; + delete customColumnFormatters[id]; + const columnFormatters = Object.fromEntries( + Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => { + return formatter.kind !== "custom-ref" || formatter.formatterId !== id; + }), + ); + updateEditorSettings({ customColumnFormatters, columnFormatters }); + } + return { aiConfig, isAiConfigLoaded, @@ -318,5 +361,7 @@ export const useSettingsStore = defineStore("settings", () => { editorSettings, updateEditorSettings, updateColumnFormatter, + upsertCustomColumnFormatter, + deleteCustomColumnFormatter, }; }); diff --git a/tests/columnFormatter.test.ts b/tests/columnFormatter.test.ts index dc68e8362..02f0ed416 100644 --- a/tests/columnFormatter.test.ts +++ b/tests/columnFormatter.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { applyColumnFormatter, buildColumnFormatterKey, + resolveColumnFormatter, normalizeColumnFormatter, type ColumnFormatterConfig, } from "../src/lib/columnFormatter.ts"; @@ -50,6 +51,14 @@ test("normalizes only supported formatter configs", () => { path: "$.a[0]", }); assert.equal(normalizeColumnFormatter({ kind: "json-path", path: "a.b" }), undefined); + assert.deepEqual(normalizeColumnFormatter({ kind: "custom-template", template: "ID-${value}" }), { + kind: "custom-template", + template: "ID-${value}", + }); + assert.deepEqual(normalizeColumnFormatter({ kind: "custom-ref", formatterId: "fmt_1" }), { + kind: "custom-ref", + formatterId: "fmt_1", + }); }); test("builds stable formatter keys for table columns", () => { @@ -64,3 +73,22 @@ test("builds stable formatter keys for table columns", () => { "conn::db::public::users::created_at", ); }); + +test("applies safe custom formatter templates", () => { + assert.equal(applyColumnFormatter("ada", { kind: "custom-template", template: "user:${value}" }), "user:ada"); + assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${upper}" }), "ADA"); + assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${lower}" }), "ada"); + assert.equal(applyColumnFormatter("Ada", { kind: "custom-template", template: "${length}" }), "3"); + assert.equal(applyColumnFormatter(null, { kind: "custom-template", template: "value=${value}" }), "value=NULL"); +}); + +test("resolves saved custom formatter references", () => { + assert.deepEqual( + resolveColumnFormatter( + { kind: "custom-ref", formatterId: "fmt_1" }, + { fmt_1: { id: "fmt_1", name: "User label", template: "user:${value}" } }, + ), + { kind: "custom-template", template: "user:${value}" }, + ); + assert.equal(resolveColumnFormatter({ kind: "custom-ref", formatterId: "missing" }, {}), undefined); +}); diff --git a/tests/settingsStore.test.ts b/tests/settingsStore.test.ts index e868ee9d4..ebe3b5b03 100644 --- a/tests/settingsStore.test.ts +++ b/tests/settingsStore.test.ts @@ -54,6 +54,12 @@ test("keeps only valid saved column formatter configs", () => { "conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 }, "conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" }, "conn::db::public::users::invalid_json": { kind: "json-path", path: "user.name" }, + "conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" }, + }, + customColumnFormatters: { + fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" }, + fmt_empty_name: { id: "fmt_empty_name", name: "", template: "x:${value}" }, + fmt_empty_template: { id: "fmt_empty_template", name: "Broken", template: "" }, }, } as any); @@ -61,6 +67,10 @@ test("keeps only valid saved column formatter configs", () => { "conn::db::public::users::created_at": { kind: "datetime", unit: "auto" }, "conn::db::public::users::name": { kind: "mask", prefix: 2, suffix: 2 }, "conn::db::public::users::payload": { kind: "json-path", path: "$.user.name" }, + "conn::db::public::users::status": { kind: "custom-ref", formatterId: "fmt_1" }, + }); + assert.deepEqual(settings.customColumnFormatters, { + fmt_1: { id: "fmt_1", name: "Status label", template: "status:${value}" }, }); });