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();
|
||||
|
||||
@@ -2234,6 +2234,23 @@ export default withEnglishFallback({
|
||||
utf8: "Dekodlaşdırılmış mətn (UTF-8)",
|
||||
gbk: "Dekodlaşdırılmış mətn (GBK)",
|
||||
},
|
||||
binaryTextPreview: "Mətn kimi bax",
|
||||
binaryTextPreviewTitle: "İkili mətn önizləməsi",
|
||||
binaryTextPreviewDescription: "0x onaltılıq baytları seçilmiş simvol dəsti ilə ciddi şəkildə dekodlaşdırır. Yalnız baxış üçündür; ilkin dəyər dəyişmir.",
|
||||
binaryTextPreviewEncoding: "Simvol dəsti",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} bayt",
|
||||
binaryTextPreviewResult: "Dekodlaşdırılmış mətn",
|
||||
binaryTextPreviewCopy: "Nəticəni kopyala",
|
||||
binaryTextPreviewOriginalHint: "İlkin dəyər 0x onaltılıq kimi qalır; bu önizləmə hüceyrəni və ya verilənlər bazasını dəyişmir.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "Bu dəyər tam ikili dəyər olmadığı üçün dekodlaşdırıla bilməz.",
|
||||
incomplete: "Tam dəyər hələ yüklənməyib; natamam önizləməni dekodlaşdırmaq yanıltıcı olar.",
|
||||
undecodable: "Ciddi dekodlaşdırma alınmadı: bu mətn deyil, real ikili məlumat (şəkil, arxiv və ya yanlış bayt ardıcıllığı) kimi görünür.",
|
||||
},
|
||||
copyRowTsv: "Sətri kopyala (TSV)",
|
||||
copyColumnValues: "Sütunu kopyala (JSON)",
|
||||
copyColumnTsv: "Sütunu kopyala (TSV)",
|
||||
|
||||
@@ -2394,6 +2394,23 @@ export default {
|
||||
utf8: "Decoded text (UTF-8)",
|
||||
gbk: "Decoded text (GBK)",
|
||||
},
|
||||
binaryTextPreview: "View as text",
|
||||
binaryTextPreviewTitle: "Binary text preview",
|
||||
binaryTextPreviewDescription: "Strictly decodes the 0x hex bytes with the selected charset. Read-only preview; the original value is not modified.",
|
||||
binaryTextPreviewEncoding: "Charset",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} bytes",
|
||||
binaryTextPreviewResult: "Decoded text",
|
||||
binaryTextPreviewCopy: "Copy result",
|
||||
binaryTextPreviewOriginalHint: "The original value stays as 0x hex; this preview does not modify the cell or the database.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "This value is not a complete binary value, so it cannot be decoded.",
|
||||
incomplete: "The full value has not been loaded yet; decoding an incomplete preview would be misleading.",
|
||||
undecodable: "Strict decoding failed: this looks like real binary data (image, archive or invalid byte sequence), not text.",
|
||||
},
|
||||
copyRowTsv: "Copy Row (TSV)",
|
||||
copyColumnValues: "Copy Column (JSON)",
|
||||
copyColumnTsv: "Copy Column (TSV)",
|
||||
|
||||
@@ -2222,6 +2222,23 @@ export default withEnglishFallback({
|
||||
utf8: "Texto decodificado (UTF-8)",
|
||||
gbk: "Texto decodificado (GBK)",
|
||||
},
|
||||
binaryTextPreview: "Ver como texto",
|
||||
binaryTextPreviewTitle: "Vista de texto binaria",
|
||||
binaryTextPreviewDescription: "Decodifica de forma estricta los bytes hexadecimales 0x con el juego de caracteres elegido. Solo lectura; el valor original no cambia.",
|
||||
binaryTextPreviewEncoding: "Juego de caracteres",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} bytes",
|
||||
binaryTextPreviewResult: "Texto decodificado",
|
||||
binaryTextPreviewCopy: "Copiar resultado",
|
||||
binaryTextPreviewOriginalHint: "El valor original se mantiene en hexadecimal 0x; esta vista previa no modifica la celda ni la base de datos.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "Este valor no es un binario completo, por lo que no se puede decodificar.",
|
||||
incomplete: "El valor completo aún no se ha cargado; decodificar una vista parcial sería engañoso.",
|
||||
undecodable: "La decodificación estricta falló: parece un dato binario real (imagen, archivo comprimido o secuencia de bytes no válida), no texto.",
|
||||
},
|
||||
copyRowTsv: "Copiar fila (TSV)",
|
||||
copyColumnValues: "Copiar columna (JSON)",
|
||||
copyColumnTsv: "Copiar columna (TSV)",
|
||||
|
||||
@@ -2220,6 +2220,23 @@ export default withEnglishFallback({
|
||||
utf8: "Testo decodificato (UTF-8)",
|
||||
gbk: "Testo decodificato (GBK)",
|
||||
},
|
||||
binaryTextPreview: "Visualizza come testo",
|
||||
binaryTextPreviewTitle: "Anteprima testo binaria",
|
||||
binaryTextPreviewDescription: "Decodifica in modo rigoroso i byte esadecimali 0x con il set di caratteri scelto. Solo lettura; il valore originale non viene modificato.",
|
||||
binaryTextPreviewEncoding: "Set di caratteri",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} byte",
|
||||
binaryTextPreviewResult: "Testo decodificato",
|
||||
binaryTextPreviewCopy: "Copia risultato",
|
||||
binaryTextPreviewOriginalHint: "Il valore originale resta in esadecimale 0x; questa anteprima non modifica la cella né il database.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "Questo valore non è un binario completo, quindi non può essere decodificato.",
|
||||
incomplete: "Il valore completo non è ancora stato caricato; decodificare un'anteprima parziale sarebbe fuorviante.",
|
||||
undecodable: "Decodifica rigorosa non riuscita: sembra un dato binario reale (immagine, archivio o sequenza di byte non valida), non testo.",
|
||||
},
|
||||
copyRowTsv: "Copia Riga (TSV)",
|
||||
copyColumnValues: "Copia Colonna (JSON)",
|
||||
copyColumnTsv: "Copia Colonna (TSV)",
|
||||
|
||||
@@ -2232,6 +2232,23 @@ export default withEnglishFallback({
|
||||
utf8: "デコード済みテキスト (UTF-8)",
|
||||
gbk: "デコード済みテキスト (GBK)",
|
||||
},
|
||||
binaryTextPreview: "テキストとして表示",
|
||||
binaryTextPreviewTitle: "バイナリのテキストプレビュー",
|
||||
binaryTextPreviewDescription: "選択した文字コードで 0x の 16 進数バイトを厳密にデコードします。表示専用で、元の値は変更されません。",
|
||||
binaryTextPreviewEncoding: "文字コード",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} バイト",
|
||||
binaryTextPreviewResult: "デコード結果",
|
||||
binaryTextPreviewCopy: "結果をコピー",
|
||||
binaryTextPreviewOriginalHint: "元の値は 0x の 16 進数のまま保持され、このプレビューでセルやデータベースは変更されません。",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "この値は完全なバイナリではないため、デコードできません。",
|
||||
incomplete: "完全な値がまだ読み込まれていないため、不完全なプレビューをデコードすると誤解を招きます。",
|
||||
undecodable: "厳密なデコードに失敗しました。画像・アーカイブ・不正なバイト列など、テキストではないバイナリデータの可能性があります。",
|
||||
},
|
||||
copyRowTsv: "行をコピー (TSV)",
|
||||
copyColumnValues: "列をコピー (JSON)",
|
||||
copyColumnTsv: "列をコピー (TSV)",
|
||||
|
||||
@@ -2215,6 +2215,23 @@ export default withEnglishFallback({
|
||||
utf8: "디코딩된 텍스트 (UTF-8)",
|
||||
gbk: "디코딩된 텍스트 (GBK)",
|
||||
},
|
||||
binaryTextPreview: "텍스트로 보기",
|
||||
binaryTextPreviewTitle: "바이너리 텍스트 미리보기",
|
||||
binaryTextPreviewDescription: "선택한 문자 집합으로 0x 16진수 바이트를 엄격하게 디코딩합니다. 읽기 전용이며 원본 값은 변경되지 않습니다.",
|
||||
binaryTextPreviewEncoding: "문자 집합",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count}바이트",
|
||||
binaryTextPreviewResult: "디코딩 결과",
|
||||
binaryTextPreviewCopy: "결과 복사",
|
||||
binaryTextPreviewOriginalHint: "원본 값은 0x 16진수로 유지되며, 이 미리보기는 셀이나 데이터베이스를 변경하지 않습니다.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "이 값은 완전한 바이너리 값이 아니어서 디코딩할 수 없습니다.",
|
||||
incomplete: "전체 값이 아직 로드되지 않았습니다. 불완전한 미리보기를 디코딩하면 오해를 줄 수 있습니다.",
|
||||
undecodable: "엄격한 디코딩에 실패했습니다. 이미지, 압축 파일, 잘못된 바이트열 등 실제 바이너리 데이터일 수 있습니다.",
|
||||
},
|
||||
copyRowTsv: "행 복사 (TSV)",
|
||||
copyColumnValues: "컬럼 복사 (JSON)",
|
||||
copyColumnTsv: "컬럼 복사 (TSV)",
|
||||
|
||||
@@ -2222,6 +2222,23 @@ export default withEnglishFallback({
|
||||
utf8: "Texto decodificado (UTF-8)",
|
||||
gbk: "Texto decodificado (GBK)",
|
||||
},
|
||||
binaryTextPreview: "Ver como texto",
|
||||
binaryTextPreviewTitle: "Pré-visualização de texto binário",
|
||||
binaryTextPreviewDescription: "Decodifica estritamente os bytes hexadecimais 0x com o conjunto de caracteres escolhido. Somente leitura; o valor original não é alterado.",
|
||||
binaryTextPreviewEncoding: "Conjunto de caracteres",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} bytes",
|
||||
binaryTextPreviewResult: "Texto decodificado",
|
||||
binaryTextPreviewCopy: "Copiar resultado",
|
||||
binaryTextPreviewOriginalHint: "O valor original permanece em hexadecimal 0x; esta pré-visualização não altera a célula nem o banco de dados.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "Este valor não é um binário completo, então não pode ser decodificado.",
|
||||
incomplete: "O valor completo ainda não foi carregado; decodificar uma pré-visualização incompleta seria enganoso.",
|
||||
undecodable: "A decodificação estrita falhou: parece um dado binário real (imagem, arquivo compactado ou sequência de bytes inválida), não texto.",
|
||||
},
|
||||
copyRowTsv: "Copiar Linha (TSV)",
|
||||
copyColumnValues: "Copiar Coluna (JSON)",
|
||||
copyColumnTsv: "Copiar Coluna (TSV)",
|
||||
|
||||
@@ -2229,6 +2229,23 @@ export default withEnglishFallback({
|
||||
utf8: "Çözülmüş metin (UTF-8)",
|
||||
gbk: "Çözülmüş metin (GBK)",
|
||||
},
|
||||
binaryTextPreview: "Metin olarak görüntüle",
|
||||
binaryTextPreviewTitle: "İkili metin önizlemesi",
|
||||
binaryTextPreviewDescription: "0x onaltılık baytları seçilen karakter kümesiyle katı biçimde çözer. Salt okunur; özgün değer değişmez.",
|
||||
binaryTextPreviewEncoding: "Karakter kümesi",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} bayt",
|
||||
binaryTextPreviewResult: "Çözülen metin",
|
||||
binaryTextPreviewCopy: "Sonucu kopyala",
|
||||
binaryTextPreviewOriginalHint: "Özgün değer 0x onaltılık biçiminde kalır; bu önizleme hücreyi veya veritabanını değiştirmez.",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "Bu değer tam bir ikili değer olmadığı için çözülemez.",
|
||||
incomplete: "Tam değer henüz yüklenmedi; eksik bir önizlemeyi çözmek yanıltıcı olur.",
|
||||
undecodable: "Katı çözme başarısız oldu: bu metin değil, gerçek ikili veri (görsel, arşiv veya geçersiz bayt dizisi) gibi görünüyor.",
|
||||
},
|
||||
copyRowTsv: "Satırı Kopyala (TSV)",
|
||||
copyColumnValues: "Sütunu Kopyala (JSON)",
|
||||
copyColumnTsv: "Sütunu Kopyala (TSV)",
|
||||
|
||||
@@ -2309,6 +2309,23 @@ export default withEnglishFallback({
|
||||
utf8: "解码文本 (UTF-8)",
|
||||
gbk: "解码文本 (GBK)",
|
||||
},
|
||||
binaryTextPreview: "以文本查看",
|
||||
binaryTextPreviewTitle: "二进制文本预览",
|
||||
binaryTextPreviewDescription: "按所选字符集严格解码 0x 十六进制内容,仅用于查看,不修改原值。",
|
||||
binaryTextPreviewEncoding: "字符集",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} 字节",
|
||||
binaryTextPreviewResult: "解码结果",
|
||||
binaryTextPreviewCopy: "复制结果",
|
||||
binaryTextPreviewOriginalHint: "原始值仍以 0x 十六进制保存,本次预览不会修改单元格或数据库。",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "当前值不是完整的二进制内容,无法解码。",
|
||||
incomplete: "完整值尚未加载,解码不完整的预览会产生误导。",
|
||||
undecodable: "严格解码失败:该内容更像真实二进制数据(图片、压缩包或非法字节序列),不是文本。",
|
||||
},
|
||||
copyRowTsv: "复制行 (TSV)",
|
||||
copyColumnValues: "复制列 (JSON)",
|
||||
copyColumnTsv: "复制列 (TSV)",
|
||||
|
||||
@@ -2228,6 +2228,23 @@ export default withEnglishFallback({
|
||||
utf8: "文字編碼 (UTF-8)",
|
||||
gbk: "文字編碼 (GBK)",
|
||||
},
|
||||
binaryTextPreview: "以文字檢視",
|
||||
binaryTextPreviewTitle: "二進位文字預覽",
|
||||
binaryTextPreviewDescription: "依所選字元集嚴格解碼 0x 十六進位內容,僅供檢視,不修改原值。",
|
||||
binaryTextPreviewEncoding: "字元集",
|
||||
binaryTextPreviewEncodings: {
|
||||
utf8: "UTF-8",
|
||||
gbk: "GBK",
|
||||
},
|
||||
binaryTextPreviewByteCount: "{count} 位元組",
|
||||
binaryTextPreviewResult: "解碼結果",
|
||||
binaryTextPreviewCopy: "複製結果",
|
||||
binaryTextPreviewOriginalHint: "原始值仍以 0x 十六進位保存,本次預覽不會修改儲存格或資料庫。",
|
||||
binaryTextPreviewErrors: {
|
||||
notBinary: "目前值不是完整的二進位內容,無法解碼。",
|
||||
incomplete: "完整值尚未載入,解碼不完整的預覽會造成誤導。",
|
||||
undecodable: "嚴格解碼失敗:此內容更像真實二進位資料(圖片、壓縮檔或非法位元組序列),不是文字。",
|
||||
},
|
||||
copyRowTsv: "複製整筆 (TSV)",
|
||||
copyColumnValues: "複製欄 (JSON)",
|
||||
copyColumnTsv: "複製欄 (TSV)",
|
||||
|
||||
@@ -221,6 +221,51 @@ export function binaryCellUtf8Text(value: unknown, columnType?: string, database
|
||||
return binaryCellUtf8TextBytes(bytes, columnType);
|
||||
}
|
||||
|
||||
/** 单元格详情里「以文本查看」可选的字符集。 */
|
||||
export type BinaryCellTextEncoding = "utf8" | "gbk";
|
||||
|
||||
export const BINARY_CELL_TEXT_ENCODINGS: BinaryCellTextEncoding[] = ["utf8", "gbk"];
|
||||
|
||||
/**
|
||||
* 显式文本预览(「以文本查看」)的失败原因,调用方据此给出不同文案:
|
||||
*
|
||||
* - `notBinary`:值不是 binary canonical form(含 hex 已被截断、列类型非 binary 的情况);
|
||||
* - `incomplete`:大值预览闸门截断了原始值,bytes 不完整,继续解码会把残缺内容当成文本;
|
||||
* - `undecodable`:bytes 完整,但严格解码失败(非法序列,或图片/压缩包等二进制的控制字符)。
|
||||
*/
|
||||
export type BinaryCellTextPreviewError = "notBinary" | "incomplete" | "undecodable";
|
||||
|
||||
export type BinaryCellTextPreviewResult = { ok: true; text: string; encoding: BinaryCellTextEncoding; byteLength: number } | { ok: false; error: BinaryCellTextPreviewError };
|
||||
|
||||
/**
|
||||
* issue #9505:把 binary canonical value(`0x<hex>`)按用户显式选择的字符集解码成**只读**文本。
|
||||
*
|
||||
* 与「下载为 UTF-8/GBK」共用同一套 bytes primitive({@link parseBinaryCellBytes} +
|
||||
* `binaryPreviewBytes`),但刻意不复用 `binaryCellDownloadPayload` 的宽松 `TextDecoder`:
|
||||
* 预览必须走严格解码(见 {@link binaryCellUtf8TextBytes} / {@link printableGbkText}),
|
||||
* 否则图片头等真实二进制会被替换字符伪装成「正常文本」。
|
||||
*
|
||||
* 与 `binaryCellUtf8Text` 的区别是**不做数据库白名单**:MySQL BLOB 的自动文本预览只对 mysql
|
||||
* 连接开启(显示/编辑语义需要一致),而这里由用户在单元格详情里显式点击,属于只读 presentation,
|
||||
* 因此任何被 `isBinaryCellColumnType()` 识别的 binary 列(BLOB/TINYBLOB/MEDIUMBLOB/LONGBLOB、
|
||||
* BINARY/VARBINARY、BYTEA、BYTES、IMAGE、RAW/LONG RAW…)都可查看,且不参与解码闸门之外的任何写回路径。
|
||||
* 入口可见性完全复用 `isBinaryCellColumnType()`,本次不为未知类型名称放宽该闸门。
|
||||
*
|
||||
* 返回值只用于展示:原始 `0x<hex>`、`detail.value` / `rawValue`、编辑草稿和数据库 bytes 都不受影响。
|
||||
*/
|
||||
export function binaryCellTextPreview(value: unknown, encoding: BinaryCellTextEncoding, columnType?: string, databaseType?: DatabaseType, incomplete?: boolean): BinaryCellTextPreviewResult {
|
||||
if (incomplete) return { ok: false, error: "incomplete" };
|
||||
if (!isBinaryCellColumnType(columnType)) return { ok: false, error: "notBinary" };
|
||||
// 大值闸门会把 `0x<hex>` 本身截成 `0x...`,此时可解析的前缀不是完整 bytes,不能拿去解码。
|
||||
if (typeof value === "string" && TRUNCATED_HEX_VALUE_RE.test(value.trim())) return { ok: false, error: "notBinary" };
|
||||
const bytes = parseBinaryCellBytes(value, columnType, databaseType);
|
||||
if (!bytes) return { ok: false, error: "notBinary" };
|
||||
const preview = binaryPreviewBytes(bytes, columnType);
|
||||
const text = encoding === "gbk" ? printableGbkText(preview) : printableUtf8Text(preview);
|
||||
if (text === null) return { ok: false, error: "undecodable" };
|
||||
return { ok: true, text, encoding, byteLength: bytes.length };
|
||||
}
|
||||
|
||||
// 复制到剪贴板时,把「文本型」MySQL VARBINARY 单元格还原成其原始字符串。
|
||||
// DBX 后端为保留任意 bytes,把该值统一序列化成 `0x<hex>`;前端只有在严格 UTF-8 解码、
|
||||
// 无控制字符且重新编码后与原 bytes 完全一致时,才把 payload(如 token)复制为文本;
|
||||
|
||||
Reference in new issue
Block a user