fix(grid): support text preview of binary cells
This commit is contained in:
1 parent
ccc0361907
commit
1c47062cdd
16 files changed
+560
-3
No files matched your search
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DataGridCellDetailTextPreview from "@/components/grid/DataGridCellDetailTextPreview.vue";
|
||||
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Code2, Copy, Download, Eye, FileUp, Info, Pencil } from "@lucide/vue";
|
||||
@@ -150,8 +151,12 @@ watch(
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Binary cells keep `0x<hex>` as the canonical value; the transform kinds below
|
||||
(timestamp/JSON/radix…) have text semantics and would misread hex. Offer the
|
||||
explicit, read-only bytes decode instead. -->
|
||||
<DataGridCellDetailTextPreview v-if="open && isBinaryCellColumnType(detail.type)" :identity="`${detail.rowId}:${detail.colIndex}`" :value="detail.value" :column-type="detail.type" :database-type="databaseType" :incomplete="detail.isSourceTruncated" />
|
||||
<DataGridValueTransform
|
||||
v-if="open && !isBinaryCellColumnType(detail.type)"
|
||||
v-else-if="open"
|
||||
:source="(detail.isNull ?? detail.value === null) ? null : detail.rawValue"
|
||||
:identity="`${detail.rowId}:${detail.colIndex}`"
|
||||
:incomplete="detail.isSourceTruncated"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DataGridCellDetailTextPreview from "@/components/grid/DataGridCellDetailTextPreview.vue";
|
||||
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
|
||||
import { computed, toRef } from "vue";
|
||||
import { Code2, Copy, Download, Eye, FileDiff, FileUp, Pencil, X } from "@lucide/vue";
|
||||
@@ -142,8 +143,10 @@ defineExpose({ openSearch });
|
||||
<div class="flex min-h-5 min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<div class="shrink-0 text-muted-foreground">{{ t("grid.cellValue") }}</div>
|
||||
<div class="min-w-0 flex flex-1 flex-wrap items-center justify-end gap-1">
|
||||
<!-- 与下载菜单同一道闸门:两者都读已提交的 `0x<hex>`,编辑中不对草稿做只读文本预览。 -->
|
||||
<DataGridCellDetailTextPreview v-if="!editing && isBinaryCellColumnType(detail.type)" :identity="`${detail.rowId}:${detail.colIndex}`" :value="detail.value" :column-type="detail.type" :database-type="databaseType" :incomplete="detail.isSourceTruncated" />
|
||||
<DataGridValueTransform
|
||||
v-if="!isBinaryCellColumnType(detail.type)"
|
||||
v-else-if="!isBinaryCellColumnType(detail.type)"
|
||||
:source="(detail.isNull ?? detail.value === null) && (!editing || !detailEditValue || detailEditValue === detail.rawValue) ? null : editing ? detailEditValue : detail.rawValue"
|
||||
:identity="`${detail.rowId}:${detail.colIndex}:${editing}`"
|
||||
:incomplete="detail.isSourceTruncated"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { BINARY_CELL_TEXT_ENCODINGS, binaryCellTextPreview, type BinaryCellTextEncoding } from "@/lib/dataGrid/binaryCellDownload";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
/** 单元格原始值(binary canonical form `0x<hex>`),只读使用。 */
|
||||
value: unknown;
|
||||
columnType?: string;
|
||||
databaseType?: DatabaseType;
|
||||
/** 单元格身份,切换单元格时重置字符集选择并关闭弹窗。 */
|
||||
identity: string;
|
||||
/** 后端返回的值本身不完整(大值预览闸门)时不解码残缺 bytes。 */
|
||||
incomplete?: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const encoding = ref<BinaryCellTextEncoding>("utf8");
|
||||
|
||||
// 只读 presentation:结果不回写 detail.value / rawValue,不进入编辑与提交路径。
|
||||
const preview = computed(() => binaryCellTextPreview(props.value, encoding.value, props.columnType, props.databaseType, props.incomplete));
|
||||
const decodedText = computed(() => (preview.value.ok ? preview.value.text : ""));
|
||||
const byteLength = computed(() => (preview.value.ok ? preview.value.byteLength : 0));
|
||||
const errorKey = computed(() => (preview.value.ok ? "" : `grid.binaryTextPreviewErrors.${preview.value.error}`));
|
||||
|
||||
watch(
|
||||
() => props.identity,
|
||||
() => {
|
||||
open.value = false;
|
||||
encoding.value = "utf8";
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
async function copyResult() {
|
||||
const result = preview.value;
|
||||
if (!result.ok) return;
|
||||
try {
|
||||
await copyToClipboard(result.text);
|
||||
toast(t("grid.cellValueCopied"), 2000);
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error), 5000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-6 px-2 text-xs" @mousedown.prevent>{{ t("grid.binaryTextPreview") }}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="flex max-h-[85vh] flex-col overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("grid.binaryTextPreviewTitle") }}</DialogTitle>
|
||||
<DialogDescription>{{ t("grid.binaryTextPreviewDescription") }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="text-muted-foreground">{{ t("grid.binaryTextPreviewEncoding") }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button v-for="item in BINARY_CELL_TEXT_ENCODINGS" :key="item" :variant="encoding === item ? 'secondary' : 'outline'" size="sm" class="h-7 px-2 text-xs" @click="encoding = item">
|
||||
{{ t(`grid.binaryTextPreviewEncodings.${item}`) }}
|
||||
</Button>
|
||||
</div>
|
||||
<span v-if="!errorKey" class="text-muted-foreground">{{ t("grid.binaryTextPreviewByteCount", { count: byteLength }) }}</span>
|
||||
</div>
|
||||
<p v-if="errorKey" role="alert" class="text-xs text-destructive">{{ t(errorKey) }}</p>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span>{{ t("grid.binaryTextPreviewResult") }}</span>
|
||||
<Button variant="outline" size="sm" class="h-6 text-xs" @click="copyResult">{{ t("grid.binaryTextPreviewCopy") }}</Button>
|
||||
</div>
|
||||
<textarea :value="decodedText" :aria-label="t('grid.binaryTextPreviewResult')" readonly class="dbx-data-grid-value-font h-64 min-h-24 w-full resize-y rounded border bg-muted/20 p-3 text-xs" spellcheck="false" />
|
||||
</template>
|
||||
<p class="text-xs text-muted-foreground">{{ t("grid.binaryTextPreviewOriginalHint") }}</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
|
||||
renderWkt: vi.fn(),
|
||||
panelCancel: vi.fn(),
|
||||
panelOpenSearch: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
@@ -53,7 +55,15 @@ vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHos
|
||||
vi.mock("@/components/ui/input", async () => ({ Input: (await import("./vueHostHarness")).createPassthroughStub("Input", "input") }));
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
return { Dialog: createPassthroughStub("Dialog"), DialogContent: createPassthroughStub("DialogContent"), DialogFooter: createPassthroughStub("DialogFooter"), DialogHeader: createPassthroughStub("DialogHeader"), DialogTitle: createPassthroughStub("DialogTitle") };
|
||||
return {
|
||||
Dialog: createPassthroughStub("Dialog"),
|
||||
DialogContent: createPassthroughStub("DialogContent"),
|
||||
DialogDescription: createPassthroughStub("DialogDescription"),
|
||||
DialogFooter: createPassthroughStub("DialogFooter"),
|
||||
DialogHeader: createPassthroughStub("DialogHeader"),
|
||||
DialogTitle: createPassthroughStub("DialogTitle"),
|
||||
DialogTrigger: createPassthroughStub("DialogTrigger"),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/dropdown-menu", async () => {
|
||||
const { createPassthroughStub } = await import("./vueHostHarness");
|
||||
@@ -82,6 +92,8 @@ vi.mock("@/composables/useCellDetailEditor", () => ({ useCellDetailEditor: () =>
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: {} } }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { cellDetailJsonFormatted: true, theme: "default", fontSize: 13, fontFamily: "monospace" }, updateEditorSettings: mocks.updateSettings }) }));
|
||||
vi.mock("@/lib/dataGrid/geometryPreview", () => ({ isHexGeometry: () => false, renderWktOnCanvas: mocks.renderWkt }));
|
||||
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/composables/useDataGridCellDetail", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
@@ -106,6 +118,8 @@ import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
|
||||
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const dataGridCellDetailEditSource = readFileSync("apps/desktop/src/composables/useDataGridCellDetailEdit.ts", "utf8");
|
||||
const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8");
|
||||
const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8");
|
||||
const binaryTextPreviewSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailTextPreview.vue", "utf8");
|
||||
const cellDetailHeaderSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailHeader.vue", "utf8");
|
||||
const globalsCss = readFileSync("apps/desktop/src/styles/globals.css", "utf8");
|
||||
|
||||
@@ -1540,6 +1554,164 @@ describe("cell detail surfaces", () => {
|
||||
expect(hostText(panel.root)).toContain("0x89504e470d0a1a0a");
|
||||
});
|
||||
|
||||
// issue #9505:GaussDB(ZenithDriver)的 BLOB 已经以 `0x<hex>` 到达前端,缺的是详情里显式、只读的文本查看入口。
|
||||
it("offers an explicit read-only binary text preview in the dialog and the panel", () => {
|
||||
const blobDetail = detail({
|
||||
type: "BLOB",
|
||||
value: "0x5b5b68656164657273203d207b7d",
|
||||
rawValue: "0x5b5b68656164657273203d207b7d",
|
||||
rawValuePreview: "0x5b5b68656164657273203d207b7d",
|
||||
displayValue: "BLOB [14 bytes]",
|
||||
displayValuePreview: "BLOB [14 bytes]",
|
||||
formattedJson: "",
|
||||
});
|
||||
const dialog = mountComponent(DataGridCellDetailDialog, {
|
||||
open: true,
|
||||
detail: blobDetail,
|
||||
typeColorClass: () => "",
|
||||
openImagePreview: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
canDownloadBinaryValue: () => true,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => false,
|
||||
importBinaryValue: vi.fn(),
|
||||
databaseType: "gaussdb",
|
||||
});
|
||||
const panel = mountComponent(DataGridCellDetailPanel, {
|
||||
detail: blobDetail,
|
||||
panelIsBottom: true,
|
||||
metadataCollapsed: false,
|
||||
valueFillsHeight: false,
|
||||
editing: false,
|
||||
sideJsonView: false,
|
||||
showCompactJson: false,
|
||||
canCompactJson: false,
|
||||
typeColorClass: () => "",
|
||||
canDownloadBinaryValue: () => true,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => false,
|
||||
importBinaryValue: vi.fn(),
|
||||
openImagePreview: vi.fn(),
|
||||
canCopySqlCondition: () => true,
|
||||
databaseType: "gaussdb",
|
||||
});
|
||||
|
||||
for (const mounted of [dialog, panel]) {
|
||||
// canonical 值仍是 0x 十六进制,解码文本只出现在只读预览里。
|
||||
expect(hostText(mounted.root)).toContain("0x5b5b68656164657273203d207b7d");
|
||||
expect(hostText(mounted.root)).toContain("grid.binaryTextPreview");
|
||||
// binary 列不再渲染 timestamp/radix 这类对 hex 无意义的文本转换。
|
||||
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "DataGridValueTransform")).toHaveLength(0);
|
||||
expect(findOne(mounted.root, (node) => node.type === "textarea").value).toBe("[[headers = {}");
|
||||
}
|
||||
});
|
||||
|
||||
it("re-decodes the binary text preview with GBK only after the user picks it", async () => {
|
||||
const panel = mountComponent(DataGridCellDetailPanel, {
|
||||
detail: detail({ type: "BLOB", value: "0xd6d0cec4", rawValue: "0xd6d0cec4", rawValuePreview: "0xd6d0cec4", displayValue: "BLOB [4 bytes]", displayValuePreview: "BLOB [4 bytes]", formattedJson: "" }),
|
||||
panelIsBottom: true,
|
||||
metadataCollapsed: false,
|
||||
valueFillsHeight: false,
|
||||
editing: false,
|
||||
sideJsonView: false,
|
||||
showCompactJson: false,
|
||||
canCompactJson: false,
|
||||
typeColorClass: () => "",
|
||||
canDownloadBinaryValue: () => true,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => false,
|
||||
importBinaryValue: vi.fn(),
|
||||
openImagePreview: vi.fn(),
|
||||
canCopySqlCondition: () => true,
|
||||
databaseType: "gaussdb",
|
||||
});
|
||||
|
||||
// 默认 UTF-8:GBK 字节严格解码失败时给出失败提示,而不是替换字符。
|
||||
expect(hostText(panel.root)).toContain("grid.binaryTextPreviewErrors.undecodable");
|
||||
dispatch(
|
||||
findOne(panel.root, (node) => node.type === "button" && hostText(node).trim() === "grid.binaryTextPreviewEncodings.gbk"),
|
||||
"click",
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
expect(hostText(panel.root)).not.toContain("grid.binaryTextPreviewErrors.undecodable");
|
||||
expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("中文");
|
||||
});
|
||||
|
||||
it("keeps the value transform entry for non-binary detail cells", () => {
|
||||
const panel = mountComponent(DataGridCellDetailPanel, {
|
||||
detail: detail({ type: "VARCHAR(32)", value: "hello", rawValue: "hello", rawValuePreview: "hello", displayValue: "hello", displayValuePreview: "hello", formattedJson: "" }),
|
||||
panelIsBottom: true,
|
||||
metadataCollapsed: false,
|
||||
valueFillsHeight: false,
|
||||
editing: false,
|
||||
sideJsonView: false,
|
||||
showCompactJson: false,
|
||||
canCompactJson: false,
|
||||
typeColorClass: () => "",
|
||||
canDownloadBinaryValue: () => false,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => false,
|
||||
importBinaryValue: vi.fn(),
|
||||
openImagePreview: vi.fn(),
|
||||
canCopySqlCondition: () => true,
|
||||
databaseType: "mysql",
|
||||
});
|
||||
|
||||
expect(findAll(panel.root, (node) => node.props["data-stub"] === "DataGridValueTransform")).toHaveLength(1);
|
||||
expect(hostText(panel.root)).not.toContain("grid.binaryTextPreviewTitle");
|
||||
expect(findAll(panel.root, (node) => node.type === "textarea")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the binary text preview out of the copy-back and edit paths", async () => {
|
||||
const copyValue = vi.fn();
|
||||
const startEdit = vi.fn();
|
||||
const panel = mountComponent(DataGridCellDetailPanel, {
|
||||
detail: detail({ type: "BLOB", value: "0x48656c6c6f", rawValue: "0x48656c6c6f", rawValuePreview: "0x48656c6c6f", displayValue: "BLOB [5 bytes]", displayValuePreview: "BLOB [5 bytes]", formattedJson: "" }),
|
||||
panelIsBottom: true,
|
||||
metadataCollapsed: false,
|
||||
valueFillsHeight: false,
|
||||
editing: false,
|
||||
sideJsonView: false,
|
||||
showCompactJson: false,
|
||||
canCompactJson: false,
|
||||
typeColorClass: () => "",
|
||||
canDownloadBinaryValue: () => true,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => false,
|
||||
importBinaryValue: vi.fn(),
|
||||
openImagePreview: vi.fn(),
|
||||
canCopySqlCondition: () => true,
|
||||
databaseType: "gaussdb",
|
||||
onCopyValue: copyValue,
|
||||
onStartEdit: startEdit,
|
||||
});
|
||||
|
||||
dispatch(
|
||||
findOne(panel.root, (node) => node.type === "button" && hostText(node).trim() === "grid.binaryTextPreviewCopy"),
|
||||
"click",
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith("Hello");
|
||||
expect(mocks.toast).toHaveBeenCalledWith("grid.cellValueCopied", 2000);
|
||||
// 预览只读:不触发栅格复制/编辑,details 预览仍展示未修改的 canonical hex。
|
||||
expect(copyValue).not.toHaveBeenCalled();
|
||||
expect(startEdit).not.toHaveBeenCalled();
|
||||
expect(hostText(panel.root)).toContain("0x48656c6c6f");
|
||||
expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("Hello");
|
||||
});
|
||||
|
||||
it("keeps the binary text preview component strictly read-only and shared by both surfaces", () => {
|
||||
expect(binaryTextPreviewSource).toContain("binaryCellTextPreview(props.value");
|
||||
// 不发出任何编辑/提交事件,也不给 detail.value / rawValue 赋值。
|
||||
expect(binaryTextPreviewSource).not.toContain("defineEmits");
|
||||
expect(binaryTextPreviewSource).not.toMatch(/(?:props|detail)\.(?:value|rawValue)\s*=/);
|
||||
expect(cellDetailDialogSource).toMatch(/<DataGridCellDetailTextPreview\s+v-if="open && isBinaryCellColumnType\(detail\.type\)"/);
|
||||
// Panel 与下载菜单同一道闸门:编辑中不对草稿做只读文本预览。
|
||||
expect(cellDetailPanelSource).toMatch(/<DataGridCellDetailTextPreview\s+v-if="!editing && isBinaryCellColumnType\(detail\.type\)"/);
|
||||
});
|
||||
|
||||
it("copies the presented value, emits edit, closes, and replaces the JSON result", async () => {
|
||||
const copyText = vi.fn();
|
||||
const edit = vi.fn();
|
||||
|
||||
Reference in new issue
Block a user