fix(grid): add cell row and column detail dialogs (#546)

This commit is contained in:
vrustx 2026-05-30 19:08:42 +08:00 committed by GitHub
parent c95e01e48c
commit 398e040d5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1025 additions and 40 deletions

View File

@ -42,6 +42,7 @@ import {
KeyRound,
Link2,
ListTree,
Maximize2,
TableProperties,
LockKeyhole,
} from "lucide-vue-next";
@ -59,6 +60,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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
@ -94,7 +96,6 @@ import {
} from "@/lib/dataGridTranspose";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { cellImagePreviewUrl } from "@/lib/cellImageUrl";
import {
canFormatCellDetailJson,
cellDetailEditorText,
@ -105,6 +106,16 @@ import {
visibleCellDetailTabs,
type CellDetailTab,
} from "@/lib/cellDetailPresentation";
import {
buildDataGridCellDetail,
buildDataGridColumnDetail,
buildDataGridRowDetail,
dataGridColumnDetailJson,
dataGridColumnDetailTsv,
dataGridRowDetailJson,
dataGridRowDetailTsv,
type DataGridCellDetail,
} from "@/lib/dataGridDetail";
import {
applyColumnFormatter,
buildColumnFormatterKey,
@ -310,10 +321,17 @@ function typeColorClass(t: string): string {
}
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
const contextHeaderColumn = ref<string | null>(null);
const contextHeaderColumnIndex = ref<number | null>(null);
const detailCell = ref<{ rowIndex: number; col: number } | null>(null);
const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null);
const showCellDetail = ref(false);
const activeCellDetailTab = ref<CellDetailTab>(defaultCellDetailTab());
const cellDetailDialogOpen = ref(false);
const cellDetailDialogTarget = ref<{ rowIndex: number; col: number } | null>(null);
const rowDetailDialogOpen = ref(false);
const rowDetailDialogRowId = ref<number | null>(null);
const columnDetailDialogOpen = ref(false);
const columnDetailDialogColumnIndex = ref<number | null>(null);
const detailWidth = ref(320);
const isResizingDetail = ref(false);
const imagePreviewOpen = ref(false);
@ -2060,41 +2078,77 @@ const contextCellValue = computed<CellValue | null>(() => {
if (!contextCell.value || contextCell.value.col < 0) return null;
return contextRowItem.value?.data[contextCell.value.col] ?? null;
});
function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null {
const item = displayItems.value[rowIndex];
if (!item) return null;
return buildDataGridCellDetail({
rowIndex,
rowId: item.id,
row: item.data,
columns: props.result.columns,
columnIndex,
typeByColumn: columnTypeMap.value,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCell(value, index),
isEditable: canEditCellItem(item, columnIndex),
});
}
const activeCellDetail = computed(() => {
const cell = detailCell.value;
if (!cell) return null;
const item = displayItems.value[cell.rowIndex];
const column = props.result.columns[cell.col];
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("["));
let formattedJson = "";
if (maybeJson) {
try {
formattedJson = JSON.stringify(JSON.parse(value), null, 2);
} catch {
formattedJson = "";
}
}
return {
rowNumber: cell.rowIndex + 1,
return cell ? cellDetailFor(cell.rowIndex, cell.col) : null;
});
const dialogCellDetail = computed(() => {
const target = cellDetailDialogTarget.value;
return target ? cellDetailFor(target.rowIndex, target.col) : null;
});
const rowDetail = computed(() => {
if (rowDetailDialogRowId.value === null) return null;
const item = getRowItem(rowDetailDialogRowId.value);
if (!item) return null;
return buildDataGridRowDetail({
rowIndex: item.displayIndex,
rowId: item.id,
colIndex: cell.col,
column,
type: columnTypeMap.value.get(column) || "",
comment: columnCommentMap.value.get(column) || "",
value,
rawValue,
displayValue,
imagePreviewUrl: cellImagePreviewUrl(value),
length: value === null ? 0 : String(value).length,
formattedJson,
isEditable: canEditCellItem(item, cell.col),
};
row: item.data,
columns: props.result.columns,
columnIndexes: displayableColumnIndexes.value,
typeByColumn: columnTypeMap.value,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCell(value, index),
isEditableColumn: (columnIndex) => canEditCellItem(item, columnIndex),
});
});
const columnDetail = computed(() => {
if (columnDetailDialogColumnIndex.value === null) return null;
const columnIndex = columnDetailDialogColumnIndex.value;
return buildDataGridColumnDetail({
rows: displayItems.value.map((item) => ({
rowIndex: item.displayIndex,
rowId: item.id,
row: item.data,
isEditable: canEditCellItem(item, columnIndex),
})),
columns: props.result.columns,
columnIndex,
typeByColumn: columnTypeMap.value,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCell(value, index),
});
});
watch(cellDetailDialogOpen, (open) => {
if (!open) cellDetailDialogTarget.value = null;
});
watch(rowDetailDialogOpen, (open) => {
if (!open) rowDetailDialogRowId.value = null;
});
watch(columnDetailDialogOpen, (open) => {
if (!open) columnDetailDialogColumnIndex.value = null;
});
const activeCellDetailTabs = computed(() => {
@ -2676,6 +2730,71 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number,
showCellDetails(rowIndex, actualColIdx);
}
function openCellDetailDialog(rowIndex: number, columnIndex: number) {
cellDetailDialogTarget.value = { rowIndex, col: columnIndex };
cellDetailDialogOpen.value = true;
}
function openColumnDetailDialog(columnIndex: number) {
if (!props.result.columns[columnIndex]) return;
columnDetailDialogColumnIndex.value = columnIndex;
columnDetailDialogOpen.value = true;
}
function openContextCellDetailDialog() {
const cell = contextCell.value;
if (!cell || cell.col < 0) return;
openCellDetailDialog(cell.rowIndex, cell.col);
}
function openContextColumnDetailDialog() {
const cell = contextCell.value;
if (cell && cell.col >= 0) {
openColumnDetailDialog(cell.col);
return;
}
if (contextHeaderColumnIndex.value === null) return;
openColumnDetailDialog(contextHeaderColumnIndex.value);
}
function openActiveCellDetailDialog() {
const detail = activeCellDetail.value;
if (!detail) return;
openCellDetailDialog(detail.rowNumber - 1, detail.colIndex);
}
function openActiveColumnDetailDialog() {
const detail = activeCellDetail.value;
if (!detail) return;
openColumnDetailDialog(detail.colIndex);
}
function openRowDetailDialog(rowId: number) {
rowDetailDialogRowId.value = rowId;
rowDetailDialogOpen.value = true;
}
function openContextRowDetailDialog() {
const cell = contextCell.value;
if (!cell) return;
openRowDetailDialog(cell.rowId);
}
function openActiveRowDetailDialog() {
const detail = activeCellDetail.value;
if (!detail) return;
openRowDetailDialog(detail.rowId);
}
function closeDetailDialogs() {
cellDetailDialogOpen.value = false;
cellDetailDialogTarget.value = null;
rowDetailDialogOpen.value = false;
rowDetailDialogRowId.value = null;
columnDetailDialogOpen.value = false;
columnDetailDialogColumnIndex.value = null;
}
function transposeCellIsSelected(rowIndex: number, actualColIdx: number) {
const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);
return visibleColIdx >= 0 && cellIsSelected(rowIndex, visibleColIdx);
@ -2689,6 +2808,7 @@ function selectTransposeCell(rowIndex: number, actualColIdx: number, event: Mous
const visibleColIdx = visibleColumnIndexes.value.indexOf(actualColIdx);
if (visibleColIdx < 0) return;
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
clearRowSelection();
if (event.shiftKey || event.metaKey || event.ctrlKey) {
extendCellSelectionTo(rowIndex, visibleColIdx);
@ -3010,6 +3130,69 @@ async function copyDetailSqlCondition() {
copyText(detailSqlConditionCopy.value.text);
}
function copyDialogCellValue() {
const detail = dialogCellDetail.value;
if (!detail) return;
copyText(detail.value === null ? "" : displayCellValue(detail.value));
}
function copyDialogCellFormattedJson() {
const detail = dialogCellDetail.value;
if (!detail?.formattedJson) return;
copyText(detail.formattedJson);
}
function copyDialogCellColumnName() {
const detail = dialogCellDetail.value;
if (!detail) return;
copyText(detail.column);
}
function openDialogCellInSidePanel() {
const detail = dialogCellDetail.value;
if (!detail) return;
showCellDetails(detail.rowNumber - 1, detail.colIndex);
cellDetailDialogOpen.value = false;
}
function copyRowDetailJson() {
const detail = rowDetail.value;
if (!detail) return;
copyText(dataGridRowDetailJson(detail));
}
function copyRowDetailTsv() {
const detail = rowDetail.value;
if (!detail) return;
copyText(dataGridRowDetailTsv(detail));
}
function copyRowDetailFieldValue(field: DataGridCellDetail) {
copyText(field.value === null ? "" : displayCellValue(field.value));
}
function copyColumnDetailJson() {
const detail = columnDetail.value;
if (!detail) return;
copyText(dataGridColumnDetailJson(detail));
}
function copyColumnDetailTsv() {
const detail = columnDetail.value;
if (!detail) return;
copyText(dataGridColumnDetailTsv(detail));
}
function copyColumnDetailColumnName() {
const detail = columnDetail.value;
if (!detail) return;
copyText(detail.column);
}
function copyColumnDetailFieldValue(field: DataGridCellDetail) {
copyText(field.value === null ? "" : displayCellValue(field.value));
}
const TRANSPOSE_RECORD_DEFAULT_WIDTH = 168;
const TRANSPOSE_RECORD_MIN_WIDTH = 96;
const TRANSPOSE_PINNED_MIN_WIDTH = 104;
@ -3232,6 +3415,7 @@ function selectTransposeRecord(rowIndex: number, event?: MouseEvent) {
if (rowIndex < 0 || rowIndex >= displayItems.value.length) return;
transposeRowIndex.value = rowIndex;
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
const item = displayItems.value[rowIndex];
if (item) {
if (event) {
@ -3293,6 +3477,7 @@ watch(
clearCellSelection();
clearRowSelection();
closeCellDetails();
closeDetailDialogs();
if (shouldPreserveTranspose) {
applyTransposeState(
nextTransposeStateForRecordCount(showTranspose.value, transposeRowIndex.value, displayItems.value.length),
@ -3305,11 +3490,12 @@ watch(
);
// --- Context menu handlers ---
function onHeaderContext(col: string) {
function onHeaderContext(col: string, columnIndex: number) {
contextCell.value = null;
clearCellSelection();
clearRowSelection();
contextHeaderColumn.value = col;
contextHeaderColumnIndex.value = columnIndex;
}
async function copyHeaderColumn() {
if (!contextHeaderColumn.value) return;
@ -3365,6 +3551,7 @@ async function copyAlterColumnSql() {
}
function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleColIdx: number) {
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextCell.value = { rowId, rowIndex, col: colIdx };
if (hasRowSelection.value && isRowSelected(rowId)) {
void prefetchCopyStatements();
@ -3379,6 +3566,7 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC
function onRowContext(rowId: number, rowIndex: number) {
contextHeaderColumn.value = null;
contextHeaderColumnIndex.value = null;
contextCell.value = { rowId, rowIndex, col: -1 };
if (!isRowSelected(rowId)) {
clearCellSelection();
@ -3870,6 +4058,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
// 1. Copy column name
if (contextHeaderColumn.value) {
items.push({ label: t("grid.copyColumnName"), action: copyHeaderColumn, icon: Copy });
items.push({
label: t("grid.openColumnDetailsDialog"),
action: openContextColumnDetailDialog,
icon: TableProperties,
});
if (canCopyAlterColumnSql.value) {
items.push({ label: t("grid.copyAlterColumnSql"), action: copyAlterColumnSql, icon: Copy });
}
@ -3891,22 +4084,36 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
items.push({ label: "", separator: true });
}
// 3. Copy submenu
// 3. Detail dialogs
if (contextCell.value) {
if (contextColumn.value) {
items.push({ label: t("grid.openCellDetailsDialog"), action: openContextCellDetailDialog, icon: Maximize2 });
items.push({
label: t("grid.openColumnDetailsDialog"),
action: openContextColumnDetailDialog,
icon: TableProperties,
});
}
items.push({ label: t("grid.openRowDetailsDialog"), action: openContextRowDetailDialog, icon: ListTree });
items.push({ label: "", separator: true });
}
// 4. Copy submenu
if (!contextHeaderColumn.value) {
items.push(copySubmenu());
}
// 4. Transpose
// 5. Transpose
if (contextCell.value) {
items.push({ label: t("grid.transpose"), action: openContextTranspose, icon: Rows3 });
}
// 5. Selection submenu
// 6. Selection submenu
if (hasCellSelection.value) {
items.push(selectionSubmenu());
}
// 6. Row actions
// 7. Row actions
if (props.editable && contextRowItem.value) {
const labels = rowActionLabels();
items.push({ label: "", separator: true });
@ -3933,7 +4140,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
items.push({ label: "", separator: true });
}
// 7. Export submenu
// 8. Export submenu
items.push(exportSubmenu());
return items;
@ -4637,7 +4844,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:style="{ width: `var(--col-w-${colIdx})` }"
:data-grid-column-index="actualColumnIndex(colIdx)"
@click="selectColumn(colIdx, $event)"
@contextmenu="onHeaderContext(col)"
@contextmenu="onHeaderContext(col, actualColumnIndex(colIdx))"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
@ -5500,6 +5707,33 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
<Info class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
:title="t('grid.openCellDetailsDialog')"
@click="openActiveCellDetailDialog"
>
<Maximize2 class="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
:title="t('grid.openRowDetailsDialog')"
@click="openActiveRowDetailDialog"
>
<ListTree class="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
:title="t('grid.openColumnDetailsDialog')"
@click="openActiveColumnDetailDialog"
>
<TableProperties class="w-3 h-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeCellDetails">
<X class="w-3 h-3" />
</Button>
@ -5848,6 +6082,335 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</Tooltip>
</div>
<Dialog v-model:open="cellDetailDialogOpen">
<DialogContent v-if="dialogCellDetail" class="sm:max-w-[840px] max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader class="shrink-0 pr-8">
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
<Info class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
</DialogTitle>
</DialogHeader>
<div class="min-h-0 flex-1 overflow-auto pr-1 text-xs space-y-4">
<div class="grid gap-3 rounded border bg-muted/20 p-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
<div class="font-medium break-all">{{ dialogCellDetail.column }}</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.rowNumber") }}</div>
<div>{{ dialogCellDetail.rowNumber }}</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
<div :class="dialogCellDetail.type ? typeColorClass(dialogCellDetail.type) : 'text-muted-foreground'">
{{ dialogCellDetail.type || "-" }}
</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.valueLength") }}</div>
<div>{{ dialogCellDetail.length }}</div>
</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.columnComment") }}</div>
<div class="whitespace-pre-wrap break-words">
{{ dialogCellDetail.comment || t("grid.noComment") }}
</div>
</div>
<div class="space-y-2">
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
<a
v-if="dialogCellDetail.imagePreviewUrl"
:href="dialogCellDetail.imagePreviewUrl"
role="button"
class="block max-h-72 overflow-hidden rounded border bg-muted/20"
@click.prevent="openImagePreview(dialogCellDetail.imagePreviewUrl, dialogCellDetail.column)"
>
<img
:src="dialogCellDetail.imagePreviewUrl"
:alt="dialogCellDetail.column"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
class="max-h-72 w-full object-contain"
/>
</a>
<pre
class="max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap break-words"
:class="{ 'italic text-muted-foreground': dialogCellDetail.value === null }"
>{{ dialogCellDetail.rawValue }}</pre
>
</div>
<div v-if="dialogCellDetail.displayValue !== dialogCellDetail.rawValue" class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedValue") }}</div>
<pre class="max-h-40 overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap">{{
dialogCellDetail.displayValue
}}</pre>
</div>
<div v-if="dialogCellDetail.formattedJson" class="space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:title="t('grid.copyValue')"
@click="copyDialogCellFormattedJson"
>
<Copy class="h-3 w-3" />
</Button>
</div>
<pre
class="max-h-[44vh] overflow-auto rounded border bg-muted/20 p-3 font-mono text-xs whitespace-pre-wrap"
>{{ dialogCellDetail.formattedJson }}</pre
>
</div>
</div>
<DialogFooter class="shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyDialogCellValue">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyValue") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyDialogCellColumnName">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
</Button>
</div>
<Button
v-if="dialogCellDetail.isEditable"
variant="ghost"
size="sm"
class="h-7 text-xs"
@click="openDialogCellInSidePanel"
>
<Pencil class="mr-1.5 h-3 w-3" /> {{ t("grid.editValue") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="rowDetailDialogOpen">
<DialogContent v-if="rowDetail" class="sm:max-w-[960px] max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader class="shrink-0 pr-8">
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
<ListTree class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 truncate">{{ t("grid.rowDetailsFor", { row: rowDetail.rowNumber }) }}</span>
</DialogTitle>
</DialogHeader>
<div class="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
<span>{{ t("grid.columnsCount", { count: rowDetail.fields.length }) }}</span>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded border">
<table class="w-full min-w-[640px] text-xs">
<thead class="sticky top-0 z-10 bg-muted/80 text-muted-foreground backdrop-blur">
<tr class="border-b">
<th class="w-16 px-3 py-2 text-left font-medium">{{ t("grid.fieldIndex") }}</th>
<th class="w-56 px-3 py-2 text-left font-medium">{{ t("grid.columnName") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("grid.cellValue") }}</th>
<th class="w-10 px-2 py-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(field, fieldIndex) in rowDetail.fields"
:key="`${field.colIndex}:${field.column}`"
class="border-b align-top last:border-b-0"
>
<td class="px-3 py-2 text-muted-foreground tabular-nums">{{ fieldIndex + 1 }}</td>
<td class="px-3 py-2">
<div class="font-medium break-all">{{ field.column }}</div>
<div
:class="field.type ? typeColorClass(field.type) : 'text-muted-foreground'"
class="mt-1 text-[11px]"
>
{{ field.type || "-" }}
</div>
<div v-if="field.comment" class="mt-1 text-[11px] text-muted-foreground whitespace-pre-wrap">
{{ field.comment }}
</div>
</td>
<td class="min-w-0 px-3 py-2">
<div class="mb-1 text-[11px] text-muted-foreground">
{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}:
{{ field.value === null ? "true" : field.length }}
</div>
<a
v-if="field.imagePreviewUrl"
:href="field.imagePreviewUrl"
role="button"
class="mb-2 block max-h-48 overflow-hidden rounded border bg-muted/20"
@click.prevent="openImagePreview(field.imagePreviewUrl, field.column)"
>
<img
:src="field.imagePreviewUrl"
:alt="field.column"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
class="max-h-48 w-full object-contain"
/>
</a>
<pre
class="max-h-44 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
:class="{ 'italic text-muted-foreground': field.value === null }"
>{{ field.rawValue }}</pre
>
<div v-if="field.formattedJson" class="mt-2 space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<pre
class="max-h-44 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
>{{ field.formattedJson }}</pre
>
</div>
</td>
<td class="px-2 py-2">
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:title="t('grid.copyValue')"
@click="copyRowDetailFieldValue(field)"
>
<Copy class="h-3 w-3" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<DialogFooter class="shrink-0 justify-start gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyRowDetailJson">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyRow") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyRowDetailTsv">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyRowTsv") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="columnDetailDialogOpen">
<DialogContent v-if="columnDetail" class="sm:max-w-[900px] max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader class="shrink-0 pr-8">
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm">
<TableProperties class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 truncate">{{ t("grid.columnDetailsFor", { column: columnDetail.column }) }}</span>
</DialogTitle>
</DialogHeader>
<div class="grid shrink-0 gap-3 rounded border bg-muted/20 p-3 text-xs sm:grid-cols-3">
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.columnName") }}</div>
<div class="font-medium break-all">{{ columnDetail.column }}</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.columnType") }}</div>
<div :class="columnDetail.type ? typeColorClass(columnDetail.type) : 'text-muted-foreground'">
{{ columnDetail.type || "-" }}
</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.rowCount") }}</div>
<div>{{ columnDetail.fields.length }}</div>
</div>
<div class="space-y-1 sm:col-span-3">
<div class="text-muted-foreground">{{ t("grid.columnComment") }}</div>
<div class="whitespace-pre-wrap break-words">
{{ columnDetail.comment || t("grid.noComment") }}
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded border">
<table class="w-full min-w-[500px] text-xs">
<thead class="sticky top-0 z-10 bg-muted/80 text-muted-foreground backdrop-blur">
<tr class="border-b">
<th class="w-24 px-3 py-2 text-left font-medium">{{ t("grid.rowNumber") }}</th>
<th class="px-3 py-2 text-left font-medium">{{ t("grid.cellValue") }}</th>
<th class="w-10 px-2 py-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="field in columnDetail.fields"
:key="`${field.rowId}:${field.colIndex}`"
class="border-b align-top last:border-b-0"
>
<td class="px-3 py-2 tabular-nums">{{ field.rowNumber }}</td>
<td class="min-w-0 px-3 py-2">
<div class="mb-1 text-[11px] text-muted-foreground">
{{ field.value === null ? t("grid.nullValue") : t("grid.valueLength") }}:
{{ field.value === null ? "true" : field.length }}
</div>
<a
v-if="field.imagePreviewUrl"
:href="field.imagePreviewUrl"
role="button"
class="mb-2 block max-h-40 overflow-hidden rounded border bg-muted/20"
@click.prevent="openImagePreview(field.imagePreviewUrl, field.column)"
>
<img
:src="field.imagePreviewUrl"
:alt="field.column"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
class="max-h-40 w-full object-contain"
/>
</a>
<pre
class="max-h-36 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
:class="{ 'italic text-muted-foreground': field.value === null }"
>{{ field.rawValue }}</pre
>
<div v-if="field.formattedJson" class="mt-2 space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<pre
class="max-h-36 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
>{{ field.formattedJson }}</pre
>
</div>
</td>
<td class="px-2 py-2">
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:title="t('grid.copyValue')"
@click="copyColumnDetailFieldValue(field)"
>
<Copy class="h-3 w-3" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<DialogFooter class="shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyColumnDetailJson">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnValues") }}
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs" @click="copyColumnDetailTsv">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnTsv") }}
</Button>
</div>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyColumnDetailColumnName">
<Copy class="mr-1.5 h-3 w-3" /> {{ t("grid.copyColumnName") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showDeleteRowConfirm"
:message="

View File

@ -519,6 +519,16 @@ export default {
pendingChanges: "{count} pending",
selectedCells: "{count} selected",
cellDetails: "Cell Details",
openCellDetailsDialog: "Open Cell Details",
rowDetails: "Row Details",
rowDetailsFor: "Row {row} Details",
openRowDetailsDialog: "Open Row Details",
openColumnDetailsDialog: "Open Column Details",
columnDetailsFor: "{column} Column Details",
columnsCount: "{count} columns",
rowCount: "Rows",
rowsCount: "{count} rows",
fieldIndex: "No.",
cellValue: "Value",
valueEditor: "Value Editor",
imagePreview: "Image Preview",
@ -535,8 +545,12 @@ export default {
nullValue: "NULL",
noComment: "No comment",
formattedJson: "Formatted JSON",
formattedValue: "Formatted Value",
rawValue: "Raw Value",
copyValue: "Copy Value",
copyRowTsv: "Copy Row (TSV)",
copyColumnValues: "Copy Column (JSON)",
copyColumnTsv: "Copy Column (TSV)",
editValue: "Edit Value",
formatJson: "Format JSON",
setNull: "Set NULL",

View File

@ -490,6 +490,16 @@ export default {
pendingChanges: "{count} pendientes",
selectedCells: "{count} seleccionadas",
cellDetails: "Detalles de celda",
openCellDetailsDialog: "Abrir detalles de celda",
rowDetails: "Detalles de fila",
rowDetailsFor: "Detalles de fila {row}",
openRowDetailsDialog: "Abrir detalles de fila",
openColumnDetailsDialog: "Abrir detalles de columna",
columnDetailsFor: "Detalles de columna {column}",
columnsCount: "{count} columnas",
rowCount: "Filas",
rowsCount: "{count} filas",
fieldIndex: "N.º",
cellValue: "Valor",
valueEditor: "Editor de valor",
imagePreview: "Vista previa de imagen",
@ -506,8 +516,12 @@ export default {
nullValue: "NULL",
noComment: "Sin comentario",
formattedJson: "JSON formateado",
formattedValue: "Valor formateado",
rawValue: "Valor sin procesar",
copyValue: "Copiar valor",
copyRowTsv: "Copiar fila (TSV)",
copyColumnValues: "Copiar columna (JSON)",
copyColumnTsv: "Copiar columna (TSV)",
editValue: "Editar valor",
formatJson: "Formatear JSON",
setNull: "Establecer NULL",

View File

@ -514,6 +514,16 @@ export default {
pendingChanges: "{count} 项待保存",
selectedCells: "已选 {count} 个单元格",
cellDetails: "单元格详情",
openCellDetailsDialog: "单元格详情",
rowDetails: "行详情",
rowDetailsFor: "第 {row} 行详情",
openRowDetailsDialog: "行详情",
openColumnDetailsDialog: "列详情",
columnDetailsFor: "“{column}”列详情",
columnsCount: "{count} 列",
rowCount: "行数",
rowsCount: "{count} 行",
fieldIndex: "序号",
cellValue: "值",
valueEditor: "值编辑器",
imagePreview: "图片预览",
@ -530,8 +540,12 @@ export default {
nullValue: "NULL",
noComment: "暂无注释",
formattedJson: "格式化 JSON",
formattedValue: "格式化值",
rawValue: "原始值",
copyValue: "复制值",
copyRowTsv: "复制行 (TSV)",
copyColumnValues: "复制列 (JSON)",
copyColumnTsv: "复制列 (TSV)",
editValue: "编辑值",
formatJson: "格式化 JSON",
setNull: "设为 NULL",

View File

@ -0,0 +1,183 @@
import { cellImagePreviewUrl } from "@/lib/cellImageUrl";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { formatJsonText } from "@/lib/cellDetailPresentation";
export interface DataGridCellDetail {
rowNumber: number;
rowId: number;
colIndex: number;
column: string;
type: string;
comment: string;
value: CellValue;
rawValue: string;
displayValue: string;
imagePreviewUrl: string | null;
length: number;
formattedJson: string;
isEditable: boolean;
}
export interface DataGridRowDetail {
rowNumber: number;
rowId: number;
fields: DataGridCellDetail[];
}
export interface DataGridColumnDetail {
colIndex: number;
column: string;
type: string;
comment: string;
fields: DataGridCellDetail[];
}
export interface BuildDataGridCellDetailOptions {
rowIndex: number;
rowId: number;
row: readonly CellValue[];
columns: readonly string[];
columnIndex: number;
typeByColumn?: ReadonlyMap<string, string>;
commentByColumn?: ReadonlyMap<string, string>;
displayValue: (value: CellValue, columnIndex: number) => string;
isEditable: boolean;
}
export interface BuildDataGridRowDetailOptions {
rowIndex: number;
rowId: number;
row: readonly CellValue[];
columns: readonly string[];
columnIndexes: readonly number[];
typeByColumn?: ReadonlyMap<string, string>;
commentByColumn?: ReadonlyMap<string, string>;
displayValue: (value: CellValue, columnIndex: number) => string;
isEditableColumn?: (columnIndex: number) => boolean;
}
export interface BuildDataGridColumnDetailRow {
rowIndex: number;
rowId: number;
row: readonly CellValue[];
isEditable?: boolean;
}
export interface BuildDataGridColumnDetailOptions {
rows: readonly BuildDataGridColumnDetailRow[];
columns: readonly string[];
columnIndex: number;
typeByColumn?: ReadonlyMap<string, string>;
commentByColumn?: ReadonlyMap<string, string>;
displayValue: (value: CellValue, columnIndex: number) => string;
}
export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions): DataGridCellDetail | null {
const column = options.columns[options.columnIndex];
if (column === undefined) return null;
const value = options.row[options.columnIndex] ?? null;
const rawValue = displayCellValue(value);
const formattedJson = typeof value === "string" && looksLikeJsonContainer(value) ? (formatJsonText(value) ?? "") : "";
return {
rowNumber: options.rowIndex + 1,
rowId: options.rowId,
colIndex: options.columnIndex,
column,
type: options.typeByColumn?.get(column) ?? "",
comment: options.commentByColumn?.get(column) ?? "",
value,
rawValue,
displayValue: options.displayValue(value, options.columnIndex),
imagePreviewUrl: cellImagePreviewUrl(value),
length: value === null ? 0 : String(value).length,
formattedJson,
isEditable: options.isEditable,
};
}
export function buildDataGridColumnDetail(options: BuildDataGridColumnDetailOptions): DataGridColumnDetail | null {
const column = options.columns[options.columnIndex];
if (column === undefined) return null;
const fields = options.rows
.map((row) =>
buildDataGridCellDetail({
rowIndex: row.rowIndex,
rowId: row.rowId,
row: row.row,
columns: options.columns,
columnIndex: options.columnIndex,
typeByColumn: options.typeByColumn,
commentByColumn: options.commentByColumn,
displayValue: options.displayValue,
isEditable: row.isEditable ?? false,
}),
)
.filter((field): field is DataGridCellDetail => field !== null);
return {
colIndex: options.columnIndex,
column,
type: options.typeByColumn?.get(column) ?? "",
comment: options.commentByColumn?.get(column) ?? "",
fields,
};
}
export function buildDataGridRowDetail(options: BuildDataGridRowDetailOptions): DataGridRowDetail {
const fields = options.columnIndexes
.map((columnIndex) =>
buildDataGridCellDetail({
rowIndex: options.rowIndex,
rowId: options.rowId,
row: options.row,
columns: options.columns,
columnIndex,
typeByColumn: options.typeByColumn,
commentByColumn: options.commentByColumn,
displayValue: options.displayValue,
isEditable: options.isEditableColumn?.(columnIndex) ?? false,
}),
)
.filter((field): field is DataGridCellDetail => field !== null);
return {
rowNumber: options.rowIndex + 1,
rowId: options.rowId,
fields,
};
}
export function dataGridRowDetailJson(detail: DataGridRowDetail): string {
const row: Record<string, CellValue> = {};
detail.fields.forEach((field) => {
row[field.column] = field.value;
});
return JSON.stringify(row, null, 2);
}
export function dataGridRowDetailTsv(detail: DataGridRowDetail): string {
return detail.fields.map((field) => displayCellValue(field.value)).join("\t");
}
export function dataGridColumnDetailJson(detail: DataGridColumnDetail): string {
return JSON.stringify(
detail.fields.map((field) => ({
row: field.rowNumber,
value: field.value,
})),
null,
2,
);
}
export function dataGridColumnDetailTsv(detail: DataGridColumnDetail): string {
return detail.fields.map((field) => displayCellValue(field.value)).join("\n");
}
function looksLikeJsonContainer(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}

View File

@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildDataGridCellDetail,
buildDataGridColumnDetail,
dataGridColumnDetailJson,
dataGridColumnDetailTsv,
buildDataGridRowDetail,
dataGridRowDetailJson,
dataGridRowDetailTsv,
} from "../../apps/desktop/src/lib/dataGridDetail.ts";
import type { CellValue } from "../../apps/desktop/src/lib/cellValue.ts";
test("buildDataGridCellDetail returns null for an invalid column", () => {
const detail = buildDataGridCellDetail({
rowIndex: 0,
rowId: 10,
row: ["Ada"],
columns: ["name"],
columnIndex: 2,
displayValue: (value) => String(value),
isEditable: false,
});
assert.equal(detail, null);
});
test("buildDataGridCellDetail preserves full value metadata", () => {
const typeByColumn = new Map([["payload", "jsonb"]]);
const commentByColumn = new Map([["payload", "raw event payload"]]);
const detail = buildDataGridCellDetail({
rowIndex: 2,
rowId: 42,
row: [7, '{"ok":true,"items":[1,2]}'],
columns: ["id", "payload"],
columnIndex: 1,
typeByColumn,
commentByColumn,
displayValue: (value) => `formatted:${String(value).slice(0, 8)}`,
isEditable: true,
});
assert.deepEqual(detail, {
rowNumber: 3,
rowId: 42,
colIndex: 1,
column: "payload",
type: "jsonb",
comment: "raw event payload",
value: '{"ok":true,"items":[1,2]}',
rawValue: '{"ok":true,"items":[1,2]}',
displayValue: 'formatted:{"ok":tr',
imagePreviewUrl: null,
length: 25,
formattedJson: '{\n "ok": true,\n "items": [\n 1,\n 2\n ]\n}',
isEditable: true,
});
});
test("buildDataGridCellDetail reports image preview URLs", () => {
const detail = buildDataGridCellDetail({
rowIndex: 0,
rowId: 1,
row: ["https://example.com/avatar.png"],
columns: ["avatar"],
columnIndex: 0,
displayValue: (value) => String(value),
isEditable: false,
});
assert.equal(detail?.imagePreviewUrl, "https://example.com/avatar.png");
});
test("buildDataGridRowDetail maps requested column indexes in order", () => {
const row: CellValue[] = ["Ada", null, true];
const detail = buildDataGridRowDetail({
rowIndex: 4,
rowId: 99,
row,
columns: ["name", "nickname", "active"],
columnIndexes: [2, 0, 1],
displayValue: (value) => (value === null ? "NULL" : String(value)),
isEditableColumn: (columnIndex) => columnIndex !== 2,
});
assert.equal(detail.rowNumber, 5);
assert.equal(detail.rowId, 99);
assert.deepEqual(
detail.fields.map((field) => ({
column: field.column,
colIndex: field.colIndex,
rawValue: field.rawValue,
isEditable: field.isEditable,
})),
[
{ column: "active", colIndex: 2, rawValue: "true", isEditable: false },
{ column: "name", colIndex: 0, rawValue: "Ada", isEditable: true },
{ column: "nickname", colIndex: 1, rawValue: "NULL", isEditable: true },
],
);
});
test("buildDataGridRowDetail keeps duplicate columns as separate fields", () => {
const detail = buildDataGridRowDetail({
rowIndex: 0,
rowId: 1,
row: ["first", "second"],
columns: ["name", "name"],
columnIndexes: [0, 1],
displayValue: (value) => String(value),
});
assert.deepEqual(
detail.fields.map((field) => [field.colIndex, field.column, field.rawValue]),
[
[0, "name", "first"],
[1, "name", "second"],
],
);
});
test("dataGridRowDetailJson and dataGridRowDetailTsv format copy payloads", () => {
const detail = buildDataGridRowDetail({
rowIndex: 0,
rowId: 1,
row: [1, "Ada", null],
columns: ["id", "name", "nickname"],
columnIndexes: [0, 1, 2],
displayValue: (value) => (value === null ? "NULL" : String(value)),
});
assert.equal(dataGridRowDetailJson(detail), '{\n "id": 1,\n "name": "Ada",\n "nickname": null\n}');
assert.equal(dataGridRowDetailTsv(detail), "1\tAda\tNULL");
});
test("buildDataGridColumnDetail maps a whole column across rows", () => {
const typeByColumn = new Map([["name", "varchar"]]);
const commentByColumn = new Map([["name", "display name"]]);
const detail = buildDataGridColumnDetail({
rows: [
{ rowIndex: 0, rowId: 10, row: [1, "Ada"], isEditable: true },
{ rowIndex: 3, rowId: 13, row: [4, null], isEditable: false },
],
columns: ["id", "name"],
columnIndex: 1,
typeByColumn,
commentByColumn,
displayValue: (value) => (value === null ? "NULL" : String(value)),
});
assert.equal(detail?.column, "name");
assert.equal(detail?.type, "varchar");
assert.equal(detail?.comment, "display name");
assert.deepEqual(
detail?.fields.map((field) => ({
rowNumber: field.rowNumber,
rowId: field.rowId,
rawValue: field.rawValue,
isEditable: field.isEditable,
})),
[
{ rowNumber: 1, rowId: 10, rawValue: "Ada", isEditable: true },
{ rowNumber: 4, rowId: 13, rawValue: "NULL", isEditable: false },
],
);
});
test("buildDataGridColumnDetail returns null for an invalid column", () => {
const detail = buildDataGridColumnDetail({
rows: [{ rowIndex: 0, rowId: 1, row: ["Ada"] }],
columns: ["name"],
columnIndex: 3,
displayValue: (value) => String(value),
});
assert.equal(detail, null);
});
test("dataGridColumnDetailJson and dataGridColumnDetailTsv format copy payloads", () => {
const detail = buildDataGridColumnDetail({
rows: [
{ rowIndex: 0, rowId: 1, row: [1, "Ada"] },
{ rowIndex: 1, rowId: 2, row: [2, null] },
],
columns: ["id", "name"],
columnIndex: 1,
displayValue: (value) => (value === null ? "NULL" : String(value)),
});
assert.ok(detail);
assert.equal(
dataGridColumnDetailJson(detail),
'[\n {\n "row": 1,\n "value": "Ada"\n },\n {\n "row": 2,\n "value": null\n }\n]',
);
assert.equal(dataGridColumnDetailTsv(detail), "Ada\nNULL");
});