feat: shared ErrorBanner component, CustomSaveHandler with preview, and cleanup

This commit is contained in:
t8y2 2026-06-11 20:24:56 +08:00
parent 9fbafa9a6d
commit 514a01b3b1
4 changed files with 195 additions and 43 deletions

View File

@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import * as api from "@/lib/api";
import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit";
@ -18,6 +19,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@ -256,6 +258,110 @@ async function gridSave(changes: { dirtyRows: Map<number, Map<number, string | n
await load();
}
function formatMongoValue(val: unknown): string {
if (val === null || val === undefined) return "null";
if (typeof val === "number" || typeof val === "boolean") return String(val);
if (typeof val === "string") return JSON.stringify(val);
return JSON.stringify(val);
}
function mongoIdPreview(val: unknown): string {
if (val === null || val === undefined) return "null";
if (typeof val === "string" && /^[a-fA-F0-9]{24}$/.test(val)) return `ObjectId("${val}")`;
return formatMongoValue(val);
}
function buildUpdateDoc(changes: Map<number, string | number | boolean | null>, columns: string[]): Record<string, unknown> {
const setFields: Record<string, unknown> = {};
const unsetFields: Record<string, unknown> = {};
for (const [colIdx, newVal] of changes) {
const col = columns[colIdx];
if (!col || col === "_id") continue;
if (newVal === null) {
unsetFields[col] = "";
} else if (typeof newVal === "string") {
try {
setFields[col] = JSON.parse(newVal);
} catch {
setFields[col] = newVal;
}
} else {
setFields[col] = newVal;
}
}
const doc: Record<string, unknown> = {};
if (Object.keys(setFields).length > 0) doc.$set = setFields;
if (Object.keys(unsetFields).length > 0) doc.$unset = unsetFields;
return doc;
}
function buildNewDoc(newRow: (string | number | boolean | null)[], columns: string[]): Record<string, unknown> {
const doc: Record<string, unknown> = {};
for (let ci = 0; ci < columns.length; ci++) {
const col = columns[ci];
if (!col || col === "_id") continue;
const val = newRow[ci];
if (val === null) continue;
if (typeof val === "string") {
try {
doc[col] = JSON.parse(val);
} catch {
doc[col] = val;
}
} else {
doc[col] = val;
}
}
return doc;
}
async function previewDocumentChanges(changes: { dirtyRows: Map<number, Map<number, string | number | boolean | null>>; deletedRows: Set<number>; newRows: (string | number | boolean | null)[][]; columns: string[]; rows: (string | number | boolean | null)[][] }): Promise<string[]> {
const { dirtyRows, deletedRows, newRows, columns, rows } = changes;
const idColIdx = columns.indexOf("_id");
const stmts: string[] = [];
const coll = props.collection;
const isEs = documentStoreProvider.value.kind === "elasticsearch";
for (const [rowIdx, dirtyCols] of dirtyRows) {
const row = rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
const updateDoc = buildUpdateDoc(dirtyCols, columns);
if (isEs) {
stmts.push(`POST /${coll}/_update/${JSON.stringify(String(id))}\n${JSON.stringify({ doc: updateDoc.$set ?? updateDoc }, null, 2)}`);
} else {
stmts.push(`db.${coll}.updateOne({_id: ${mongoIdPreview(id)}}, ${JSON.stringify(updateDoc)})`);
}
}
for (const rowIdx of deletedRows) {
const row = rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
stmts.push(`DELETE /${coll}/_doc/${JSON.stringify(String(id))}`);
} else {
stmts.push(`db.${coll}.deleteOne({_id: ${mongoIdPreview(id)}})`);
}
}
for (const newRow of newRows) {
const doc = buildNewDoc(newRow, columns);
if (isEs) {
stmts.push(`POST /${coll}/_doc\n${JSON.stringify(doc, null, 2)}`);
} else {
stmts.push(`db.${coll}.insertOne(${JSON.stringify(doc)})`);
}
}
return stmts;
}
const customSaveHandler = computed<CustomSaveHandler>(() => ({
save: gridSave,
preview: previewDocumentChanges,
}));
async function load() {
loading.value = true;
error.value = "";
@ -688,7 +794,7 @@ function resetTableSearchSplitWidth() {
:result="gridResult"
context="results"
editable
:custom-save="gridSave"
:custom-save-handler="customSaveHandler"
:loading="loading"
:sql="documentStoreLabels.queryPreview"
:page-offset="page * pageSize"
@ -932,9 +1038,7 @@ function resetTableSearchSplitWidth() {
{{ t("mongo.selectDocument") }}
</div>
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
{{ error }}
</div>
<ErrorBanner v-if="error" :message="error" />
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
</div>
</Pane>

View File

@ -45,7 +45,6 @@ import {
WrapText,
Info,
Rows3,
TriangleAlert,
RefreshCcw,
RotateCcw,
Pencil,
@ -72,6 +71,7 @@ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/compon
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue";
@ -181,7 +181,7 @@ const props = defineProps<{
cacheKey?: string;
onExecuteSql?: (sql: string) => Promise<void>;
fullExportResult?: () => Promise<QueryResult | undefined>;
customSave?: (changes: { dirtyRows: Map<number, Map<number, CellValue>>; newRows: CellValue[][]; deletedRows: Set<number>; columns: string[]; rows: CellValue[][] }) => Promise<void>;
customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler;
}>();
const dataGridTraceId = uuid().slice(0, 8);
@ -1902,7 +1902,7 @@ const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsCont
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(undefined);
const canEditExistingRows = computed(() => !!props.customSave || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []));
const canEditExistingRows = computed(() => !!props.customSaveHandler || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []));
watch(
() => [props.databaseType, props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName],
async () => {
@ -2118,7 +2118,7 @@ const editor = useDataGridEditor({
sourceColumns: computed(() => props.sourceColumns),
canEditExistingRows,
onExecuteSql: computed(() => props.onExecuteSql),
customSave: computed(() => props.customSave),
customSaveHandler: computed(() => props.customSaveHandler),
sql: computed(() => props.sql),
searchText,
whereFilterInput,
@ -2234,7 +2234,7 @@ const saveActionMode = computed(() =>
const saveToolbarState = computed(() =>
dataGridSaveToolbarState({
editable: props.editable,
hasSaveTarget: !!props.tableMeta || !!props.customSave,
hasSaveTarget: !!props.tableMeta || !!props.customSaveHandler,
hasPendingChanges: hasPendingChanges.value,
isSaving: isSaving.value,
}),
@ -2242,13 +2242,13 @@ const saveToolbarState = computed(() =>
const hasSearchBarSlot = computed(() => !!slots["search-bar"]);
const showDataGridTopbar = computed(
() =>
(useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSave)) ||
(useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSaveHandler)) ||
hasLocalColumnFilters.value ||
canShowWhereSearch.value ||
hasSearchBarSlot.value ||
showQueryEditReadyBadge.value ||
props.context !== "results" ||
(!!props.editable && (!!props.tableMeta || !!props.customSave)) ||
(!!props.editable && (!!props.tableMeta || !!props.customSaveHandler)) ||
transactionActive.value ||
saveToolbarState.value.showActions,
);
@ -2616,11 +2616,6 @@ const {
isRowSelected,
} = selection;
const selectionSummary = computed(() => {
if (hasRowSelection.value) return t("grid.selectedRows", { count: selectedRowCount.value });
return t("grid.selectedCells", { count: selectedCellCount.value });
});
const multiRowCount = computed(() => {
if (hasRowSelection.value) return selectedRowCount.value;
const range = selectedRange.value;
@ -5858,7 +5853,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
"
>
<div class="data-grid-topbar flex items-stretch relative">
<div v-if="useTransaction && editable && (tableMeta || customSave)" class="flex items-center px-2 py-0.5 border-r shrink-0">
<div v-if="useTransaction && editable && (tableMeta || customSaveHandler)" class="flex items-center px-2 py-0.5 border-r shrink-0">
<Select :model-value="rowStatusFilter" @update:model-value="(value: any) => setRowStatusFilter(String(value))">
<SelectTrigger class="h-5 max-w-28 border-0 bg-transparent px-0 py-0 text-xs font-medium text-foreground/70 shadow-none focus-visible:ring-0 data-[state=open]:text-foreground [&_svg]:size-3">
<SelectValue :placeholder="t('grid.filterRows')" />
@ -5883,7 +5878,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</template>
<template v-if="canShowWhereSearch">
<div ref="searchSplitContainerRef" class="flex flex-1 min-w-0">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': useTransaction && editable && (tableMeta || customSave) }" :style="whereSearchPaneStyle">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': useTransaction && editable && (tableMeta || customSaveHandler) }" :style="whereSearchPaneStyle">
<Popover v-model:open="filterBuilderOpen">
<PopoverTrigger as-child>
<button
@ -6147,7 +6142,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
{{ t("grid.renderModeHint") }}
</TooltipContent>
</Tooltip>
<Button v-if="editable && (tableMeta || customSave)" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow"> <Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }} </Button>
<Button v-if="editable && (tableMeta || customSaveHandler)" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow"> <Plus class="w-3 h-3 mr-1" /> {{ t("grid.addRow") }} </Button>
<template v-if="saveToolbarState.showActions">
<Tooltip v-if="pendingChangeCount > 0">
<TooltipTrigger as-child>
@ -6230,20 +6225,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</button>
</div>
</Transition>
<div v-if="isErrorResult" class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-destructive">
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
<div class="space-y-1 select-text" @mousedown.stop @click.stop>
<div class="text-sm font-medium">{{ t("grid.queryError") }}</div>
<div class="text-xs max-w-lg break-all cursor-text text-destructive/80 select-text">{{ errorMessage }}</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click.stop="copyText(errorMessage)">
<Copy class="h-3.5 w-3.5" />
{{ t("grid.copy") }}
</Button>
<ErrorBanner v-if="isErrorResult" variant="centered" :message="errorMessage">
<template #actions>
<slot name="error-actions" :error-message="errorMessage" />
</div>
</div>
</template>
</ErrorBanner>
<div v-else-if="isTransposeMode" class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="h-8 flex items-center gap-2 px-3 border-y shrink-0 bg-muted/20">
<Rows3 class="w-3.5 h-3.5 text-muted-foreground" />
@ -7270,11 +7256,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span v-if="showTruncationWarning" class="shrink-0 text-amber-500 text-xs">(truncated)</span>
<span v-if="!hasData" class="shrink-0">{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>
<span class="shrink-0">{{ result.execution_time_ms }}ms</span>
<span v-if="selectedRowCount > 0 || hasCellSelection" class="min-w-0 truncate text-foreground">
{{ selectionSummary }}
</span>
<template v-if="editable && (tableMeta || customSave)">
<template v-if="editable && (tableMeta || customSaveHandler)">
<span v-if="hasPendingChanges" class="shrink-0 text-foreground">
{{ t("grid.pendingChanges", { count: pendingChangeCount }) }}
</span>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from "vue";
import { Copy, TriangleAlert } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/clipboard";
import { useToast } from "@/composables/useToast";
const { t } = useI18n();
const { toast } = useToast();
const props = withDefaults(
defineProps<{
message: string;
variant?: "banner" | "centered";
title?: string;
}>(),
{
variant: "banner",
},
);
const displayTitle = computed(() => props.title ?? t("grid.queryError"));
async function copy() {
try {
await copyToClipboard(props.message);
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
</script>
<template>
<!-- banner: 紧凑内联横幅 -->
<div v-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
<span class="flex-1 min-w-0 break-all">{{ message }}</span>
<Button variant="ghost" size="icon-sm" class="h-5 w-5 shrink-0 text-destructive/70 hover:text-destructive" :aria-label="t('grid.copy')" @click.stop="copy">
<Copy class="h-3 w-3" />
</Button>
</div>
<!-- centered: 居中占满 -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-destructive">
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
<div class="space-y-1 select-text" @mousedown.stop @click.stop>
<div class="text-sm font-medium">{{ displayTitle }}</div>
<div class="text-xs max-w-lg break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click.stop="copy">
<Copy class="h-3.5 w-3.5" />
{{ t("grid.copy") }}
</Button>
<slot name="actions" />
</div>
</div>
</template>

View File

@ -30,6 +30,11 @@ type GridScrollerRef =
scrollToPosition?: (position: number) => void;
};
export interface CustomSaveHandler {
save: (changes: { dirtyRows: Map<number, Map<number, CellValue>>; newRows: CellValue[][]; deletedRows: Set<number>; columns: string[]; rows: CellValue[][] }) => Promise<void>;
preview?: (changes: { dirtyRows: Map<number, Map<number, CellValue>>; newRows: CellValue[][]; deletedRows: Set<number>; columns: string[]; rows: CellValue[][] }) => Promise<string[]>;
}
export interface UseDataGridEditorOptions {
result: ComputedRef<{ columns: string[]; rows: CellValue[][] }>;
editable: ComputedRef<boolean | undefined>;
@ -48,7 +53,7 @@ export interface UseDataGridEditorOptions {
sourceColumns?: ComputedRef<Array<string | undefined> | undefined>;
canEditExistingRows?: ComputedRef<boolean>;
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
customSave?: ComputedRef<((changes: { dirtyRows: Map<number, Map<number, CellValue>>; newRows: CellValue[][]; deletedRows: Set<number>; columns: string[]; rows: CellValue[][] }) => Promise<void>) | undefined>;
customSaveHandler?: ComputedRef<CustomSaveHandler | undefined>;
sql: ComputedRef<string | undefined>;
searchText: Ref<string>;
whereFilterInput: Ref<string>;
@ -119,7 +124,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
sourceColumns = computed(() => undefined),
canEditExistingRows = computed(() => true),
onExecuteSql,
customSave,
customSaveHandler,
sql,
searchText,
orderByInput,
@ -174,7 +179,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const isSaving = ref(false);
const saveError = ref("");
const useTransaction = computed(() => editable.value && supportsDataGridTransaction(databaseType.value) && (!!customSave?.value || (!!connectionId.value && !!database.value && !!tableMeta.value)));
const useTransaction = computed(() => editable.value && supportsDataGridTransaction(databaseType.value) && (!!customSaveHandler?.value || (!!connectionId.value && !!database.value && !!tableMeta.value)));
if (hasPendingChanges.value && useTransaction.value) {
transactionActive.value = true;
@ -695,9 +700,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
isSaving.value = true;
const shouldReloadAfterSave = newRows.value.length > 0 || deletedRows.value.size > 0;
if (customSave?.value) {
if (customSaveHandler?.value) {
try {
await customSave.value({
await customSaveHandler.value.save({
dirtyRows: dirtyRows.value,
newRows: newRows.value,
deletedRows: deletedRows.value,
@ -896,8 +901,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
isPreviewLoading.value = true;
previewStatements.value = [];
try {
if (customSave?.value) {
// customSave doesn't expose SQL — return empty
if (customSaveHandler?.value) {
const preview = customSaveHandler.value.preview;
if (preview) return await preview({ dirtyRows: dirtyRows.value, newRows: newRows.value, deletedRows: deletedRows.value, columns: result.value.columns, rows: result.value.rows });
return [];
}
const stmtOptions = saveStatementOptions();