fix(grid): support text preview of binary cells

This commit is contained in:
jearhe authored and GitHub committed 2026-09-18 22:49:58 +08:00
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();
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)复制为文本;