feat(grid): improve save error feedback and dirty cell highlighting

This commit is contained in:
rihkddd authored and GitHub committed 2026-08-05 01:09:59 +08:00
1 parent a2e1128670
commit 27d72f8776
15 files changed
+130 -17

No files matched your search

+44 -10
View File
@@ -656,6 +656,7 @@ const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible
const orderByInput = ref(props.initialOrderByInput ?? "");
const whereFilterInput = ref(props.initialWhereInput ?? "");
const queryControlError = ref("");
const conditionColumns = computed(() => dataGridConditionColumnOptions(props.tableMeta?.columns ?? props.result.columns, resolvedDatabaseType.value));
const conditionIdentifierQuote = computed(() => dataGridConditionIdentifierQuote(resolvedDatabaseType.value, connectionStore.connectionIdentifierQuote?.(props.connectionId)));
const conditionHistoryScope = computed(() => ({
@@ -4507,7 +4508,7 @@ async function applyOrderBySearch() {
emit("update:orderByInput", orderByInput.value);
if (orderByClause) rememberDataGridConditionHistory("orderBy", conditionHistoryScope.value, orderByClause);
isApplyingWhere.value = true;
saveError.value = "";
queryControlError.value = "";
currentPage.value = 1;
clearSort();
try {
@@ -4530,7 +4531,7 @@ async function applyOrderBySearch() {
});
await props.onExecuteSql(sql);
} catch (e: any) {
saveError.value = String(e?.message || e);
queryControlError.value = String(e?.message || e);
} finally {
isApplyingWhere.value = false;
}
@@ -4541,7 +4542,7 @@ async function applyWhereFilter() {
const whereInput = currentWhereInput();
if (whereInput) rememberDataGridConditionHistory("where", conditionHistoryScope.value, whereInput);
isApplyingWhere.value = true;
saveError.value = "";
queryControlError.value = "";
currentPage.value = 1;
emit("update:whereInput", whereInput ?? "");
try {
@@ -4564,7 +4565,7 @@ async function applyWhereFilter() {
});
await props.onExecuteSql(sql);
} catch (e: any) {
saveError.value = String(e?.message || e);
queryControlError.value = String(e?.message || e);
} finally {
isApplyingWhere.value = false;
}
@@ -4773,7 +4774,7 @@ const canvasSurfaceWidth = computed(() => {
if (vw <= 0) return total;
return Math.min(vw, total);
});
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${tableFontFamily.value}:${tableFontSize.value}`);
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${tableFontFamily.value}:${tableFontSize.value}:${!!saveError.value}`);
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
let canvasPixelRatioMediaQuery: MediaQueryList | null = null;
@@ -5405,6 +5406,7 @@ watch(
showCellDetail,
editingCell,
frozenColumnCount,
saveError,
// Pending edit structures can contain large nested cell maps; the editor
// version ref gives the canvas a cheap invalidation signal without a deep watch.
pendingChangesVersion,
@@ -8459,7 +8461,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
data-grid-root
data-grid-active="true"
class="h-full flex flex-col overflow-hidden outline-none"
:class="{ 'data-grid--editing-cell': !!editingCell, 'data-grid--dark': isDark }"
:class="{ 'data-grid--editing-cell': !!editingCell, 'data-grid--dark': isDark, 'data-grid--has-save-error': !!saveError }"
:style="gridStyle"
tabindex="0"
@keydown="onGridKeydown"
@@ -9959,7 +9961,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
<!-- Error bar -->
<ErrorBanner v-if="saveError" :message="saveError" copy-mode="label" dismissible @dismiss="saveError = ''" />
<ErrorBanner v-if="queryControlError" variant="card" :title="t('grid.queryError')" :message="queryControlError" copy-mode="label" dismissible @dismiss="queryControlError = ''" />
<ErrorBanner v-if="saveError" variant="card" :title="t('grid.saveErrorTitle')" :message="saveError" copy-mode="label" dismissible @dismiss="saveError = ''" />
<!-- Bottom status bar -->
<div v-if="!isErrorResult" class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
@@ -10121,7 +10124,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-new-bg: rgb(243, 243, 243);
--data-grid-row-deleted-bg: rgb(255, 244, 244);
--data-grid-cell-active-bg: rgb(244, 248, 255);
--data-grid-cell-dirty-bg: rgb(255, 248, 230);
--data-grid-cell-dirty-bg: rgb(166, 210, 255);
--data-grid-cell-selected-bg: rgb(239, 246, 255);
--data-grid-cell-selected-single-bg: rgb(191, 219, 254);
--data-grid-cell-selected-dirty-bg: rgb(235, 224, 184);
@@ -10142,13 +10145,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: rgb(255, 255, 255);
}
[data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(250, 212, 216) !important;
--data-grid-cell-selected-dirty-bg: rgb(240, 192, 198) !important;
}
[data-grid-root].data-grid--dark,
:global(.dark) [data-grid-root] {
--data-grid-row-muted-bg: rgb(40, 40, 43);
--data-grid-row-new-bg: rgb(51, 51, 55);
--data-grid-row-deleted-bg: rgb(55, 31, 32);
--data-grid-cell-active-bg: rgb(25, 34, 46);
--data-grid-cell-dirty-bg: rgb(94, 75, 26);
--data-grid-cell-dirty-bg: rgb(33, 66, 131);
--data-grid-cell-selected-bg: rgb(20, 40, 60);
--data-grid-cell-selected-single-bg: rgb(30, 64, 96);
--data-grid-cell-selected-dirty-bg: rgb(76, 66, 38);
@@ -10169,12 +10177,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: rgb(19, 20, 22);
}
[data-grid-root].data-grid--dark.data-grid--has-save-error,
:global(.dark) [data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(94, 56, 57) !important;
--data-grid-cell-selected-dirty-bg: rgb(114, 66, 67) !important;
}
@supports (background: color-mix(in oklab, white 50%, transparent)) {
[data-grid-root] {
--data-grid-row-muted-bg: color-mix(in oklab, var(--muted) 99%, var(--foreground));
--data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent);
--data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(240 177 0) 10%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(166 210 255) 85%, var(--background));
--data-grid-cell-selected-bg: color-mix(in oklab, rgb(59 130 246) 12%, var(--background));
--data-grid-cell-selected-single-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
--data-grid-cell-selected-dirty-bg: color-mix(in oklab, rgb(234 181 50) 30%, color-mix(in oklab, rgb(59 130 246) 18%, var(--background)));
@@ -10185,6 +10199,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-number-deleted-bg: color-mix(in oklab, var(--destructive) 15%, var(--background));
--data-grid-row-number-selected-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
}
[data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(250, 212, 216) !important;
--data-grid-cell-selected-dirty-bg: rgb(240, 192, 198) !important;
}
[data-grid-root].data-grid--dark.data-grid--has-save-error,
:global(.dark) [data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(94, 56, 57) !important;
--data-grid-cell-selected-dirty-bg: rgb(114, 66, 67) !important;
}
}
.data-grid-header-shell {
@@ -10673,6 +10696,17 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: var(--data-grid-cell-dirty-bg) !important;
}
.cell-dirty::after {
content: "";
position: absolute;
top: 0;
right: 0;
border-style: solid;
border-width: 0 5px 5px 0;
border-color: transparent #f59e0b transparent transparent;
pointer-events: none;
}
.cell-search-match {
background-color: var(--data-grid-cell-search-bg) !important;
}
+25 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Copy, TriangleAlert } from "@lucide/vue";
import { Copy, TriangleAlert, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -12,7 +12,7 @@ const { toast } = useToast();
const props = withDefaults(
defineProps<{
message: string;
variant?: "banner" | "centered";
variant?: "banner" | "centered" | "card";
title?: string;
dismissible?: boolean;
copyMode?: "icon" | "label";
@@ -41,8 +41,30 @@ async function copy() {
</script>
<template>
<!-- card: 卡片类报错信息面板 -->
<div v-if="variant === 'card'" class="mx-3 my-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3 shadow-sm shrink-0 select-text flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 border-b border-destructive/20 pb-2">
<div class="flex items-center gap-2 font-semibold text-xs text-destructive">
<TriangleAlert class="h-4 w-4 text-destructive shrink-0" aria-hidden="true" />
<span>{{ displayTitle }}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-[11px] border-destructive/30 text-destructive hover:bg-destructive/15 hover:text-destructive" :aria-label="t('grid.copy')" @click.stop="copy">
<Copy class="h-3 w-3" />
{{ t("grid.copy") }}
</Button>
<Button v-if="dismissible" variant="ghost" size="icon-sm" class="h-6 w-6 text-destructive/70 hover:text-destructive hover:bg-destructive/15" :aria-label="t('grid.dismiss')" @click.stop="emit('dismiss')">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
{{ message }}
</div>
</div>
<!-- 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">
<div v-else-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 v-if="copyMode === 'label'" type="button" class="shrink-0 hover:underline" :aria-label="t('grid.copy')" @click.stop="copy">
{{ t("grid.copy") }}