feat(grid): 添加多行转置功能和相关界面支持 (#909)

- 新增多行转置状态管理
- 更新转置记录索引计算逻辑
- 增加多行转置切换开关及提示信息
- 更新国际化文件以支持多语言
This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-06-09 12:02:45 +08:00
1 parent 98342184af
commit f8834aee09
11 files changed
+172 -13

No files matched your search

+56 -11
View File
@@ -105,6 +105,7 @@ import {
nextKeyboardTransposeState,
nextTransposeState,
nextTransposeStateForRecordCount,
transposeRecordIndexesForMode,
transposeFieldWidth,
transposeScrollLeftForRecord,
visibleTransposeRecordWindow,
@@ -460,6 +461,7 @@ const previewDialogOpen = ref(false);
const previewDialogConfig = shallowRef<{ component: any; props: Record<string, any> } | null>(null);
const transposeRowIndex = ref<number | null>(null);
const showTranspose = ref(false);
const multiRowTranspose = ref(false);
const preserveTransposeOnNextResult = ref(false);
const transposeScrollRef = ref<HTMLElement | { $el?: HTMLElement }>();
const transposeScrollLeft = ref(0);
@@ -4836,22 +4838,37 @@ const visibleTransposeRecordIndexes = computed(() => {
const window = transposeRecordWindow.value;
return Array.from({ length: window.end - window.start }, (_, offset) => window.start + offset);
});
const activeTransposeRecordIndexes = computed(() =>
transposeRecordIndexesForMode({
multiRow: multiRowTranspose.value,
activeRecordIndex: transposeRowIndex.value,
totalRecords: displayRowCount.value,
visibleRecordIndexes: visibleTransposeRecordIndexes.value,
}),
);
const transposeBeforeSpacerWidth = computed(() =>
multiRowTranspose.value ? transposeRecordWindow.value.beforeWidth : 0,
);
const transposeAfterSpacerWidth = computed(() =>
multiRowTranspose.value ? transposeRecordWindow.value.afterWidth : 0,
);
const transposeRows = computed(() => {
return buildVisibleTransposeRows({
columns: visibleColumns.value,
records: displayRowRefs.value.map((_, index) => displayItemAt(index)?.data ?? []),
recordIndexes: visibleTransposeRecordIndexes.value,
recordIndexes: activeTransposeRecordIndexes.value,
valueIndexes: visibleColumnIndexes.value,
typeByColumn: columnTypeMap.value,
displayValue: (value, _column, index) => formatCellCached(value, visibleColumnIndexes.value[index]),
});
});
const isTransposeMode = computed(() => showTranspose.value && transposeRows.value.length > 0);
const transposeTotalWidth = computed(
() =>
transposePinnedWidth.value +
Array.from({ length: displayRowCount.value }, (_, i) => getTransposeRecordWidth(i)).reduce((sum, w) => sum + w, 0),
);
const transposeTotalWidth = computed(() => {
const recordIndexes = multiRowTranspose.value
? Array.from({ length: displayRowCount.value }, (_, i) => i)
: activeTransposeRecordIndexes.value;
return transposePinnedWidth.value + recordIndexes.reduce((sum, i) => sum + getTransposeRecordWidth(i), 0);
});
function transposeScrollElement(): HTMLElement | undefined {
const raw = transposeScrollRef.value;
@@ -4888,6 +4905,26 @@ function scrollTransposeRecordIntoView(rowIndex: number) {
});
}
function setMultiRowTranspose(value: boolean) {
multiRowTranspose.value = value;
if (!showTranspose.value) return;
nextTick(updateTransposeViewport);
if (value && transposeRowIndex.value !== null) {
scrollTransposeRecordIntoView(transposeRowIndex.value);
} else {
nextTick(() => {
const el = transposeScrollElement();
if (!el) return;
el.scrollLeft = 0;
updateTransposeViewport();
});
}
}
function toggleMultiRowTranspose() {
setMultiRowTranspose(!multiRowTranspose.value);
}
function applyTransposeState(next: { showTranspose: boolean; transposeRowIndex: number | null }) {
showTranspose.value = next.showTranspose;
transposeRowIndex.value = next.transposeRowIndex;
@@ -5624,6 +5661,9 @@ defineExpose({
toggleDdl: toggleTableInfo,
showTableInfo,
toggleTableInfo,
multiRowTranspose,
setMultiRowTranspose,
toggleMultiRowTranspose,
focusSearch,
visibleColumnCount,
displayableColumnCount,
@@ -6436,6 +6476,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span class="text-xs text-muted-foreground">
{{ t("grid.rowNumber") }} {{ (transposeRowIndex ?? 0) + 1 }}
</span>
<span
class="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{{ multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow") }}
</span>
<span class="flex-1" />
<Button
variant="ghost"
@@ -6488,9 +6533,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@mousedown.stop="onTransposePinnedResizeStart"
/>
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.beforeWidth}px` }" />
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
<div
v-for="recordIndex in visibleTransposeRecordIndexes"
v-for="recordIndex in activeTransposeRecordIndexes"
:key="`transpose-head-${recordIndex}`"
class="shrink-0 border-r border-border px-2 py-1.5 text-center tabular-nums relative"
:class="{
@@ -6514,7 +6559,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@dblclick.stop="autoFitTransposeRecord(recordIndex)"
/>
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.afterWidth}px` }" />
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
</div>
</template>
<template #default="{ item }">
@@ -6529,7 +6574,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
{{ item.column }}
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.beforeWidth}px` }" />
<div class="shrink-0" :style="{ width: `${transposeBeforeSpacerWidth}px` }" />
<div
v-for="cell in item.values"
:key="`${item.id}:${cell.recordIndex}`"
@@ -6647,7 +6692,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
</template>
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.afterWidth}px` }" />
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
</div>
</template>
</RecycleScroller>
@@ -23,6 +23,8 @@ import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
@@ -81,6 +83,8 @@ type DataGridHandle = {
toggleAllNullColumns: () => void;
showDdl: boolean;
toggleDdl: () => void;
multiRowTranspose: boolean;
setMultiRowTranspose: (value: boolean) => void;
};
type SearchableBrowserHandle = {
@@ -864,7 +868,9 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
variant="ghost"
size="icon"
class="h-6 w-7 shrink-0 text-foreground hover:bg-accent"
:class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }"
:class="{
'bg-accent text-foreground': dataGridRef?.nullColumnsHidden || dataGridRef?.multiRowTranspose,
}"
:title="t('grid.viewOptions')"
:aria-label="t('grid.viewOptions')"
>
@@ -880,6 +886,28 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<LightTooltip
:text="t('grid.transposeMultiRowHint')"
side="left"
:side-offset="6"
:delay="0"
:open-on-focus="false"
>
<label class="flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-xs hover:bg-accent">
<span class="min-w-0 flex items-center gap-1.5 font-medium">
{{ t("grid.transposeMultiRowToggle") }}
<span class="text-muted-foreground">
{{ dataGridRef?.multiRowTranspose ? t("grid.transposeMultiRow") : t("grid.transposeSingleRow") }}
</span>
</span>
<Switch
size="sm"
:model-value="!!dataGridRef?.multiRowTranspose"
:aria-label="t('grid.transposeMultiRow')"
@update:model-value="(value: boolean) => dataGridRef?.setMultiRowTranspose(value)"
/>
</label>
</LightTooltip>
<label
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent"
:class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }"
@@ -8,12 +8,14 @@ const props = withDefaults(
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
delay?: number;
openOnFocus?: boolean;
}>(),
{
disabled: false,
side: "top",
sideOffset: 8,
delay: 300,
openOnFocus: true,
},
);
@@ -147,6 +149,11 @@ function scheduleOpen() {
timer = setTimeout(open, props.delay);
}
function scheduleFocusOpen() {
if (!props.openOnFocus) return;
scheduleOpen();
}
onBeforeUnmount(close);
watch(
@@ -164,7 +171,7 @@ watch(
class="contents"
@mouseenter="scheduleOpen"
@mouseleave="close"
@focusin="scheduleOpen"
@focusin="scheduleFocusOpen"
@focusout="close"
>
<slot />