From b16eb354774ca33a73fd5264d3c14c6b5abd9d08 Mon Sep 17 00:00:00 2001 From: CritasWang Date: Thu, 2 Jul 2026 16:04:02 +0800 Subject: [PATCH] fix: correct table column alignment and non-millisecond timestamp formatting Data preview had two defects on table-model TsFiles: 1. Header/body column misalignment: the fixed "device" column shared the same key/dataIndex ("device") with a tag column also named "device", which collided in antdv Table and desynced header from body. The fixed identity column now uses a reserved key "__device__" isolated from tag columns. 2. Timestamps rendered as "NaN-NaN-NaN": nanosecond (19-digit) timestamps overflow JS Date. Added normalizeToMs() to auto-scale sec/ms/us/ns to milliseconds. Display stays at millisecond precision; the exact original value is preserved via a transformResponse hook that captures the raw digit string before JSON.parse (avoiding lossy double conversion) and is shown on hover, with an info banner explaining the precision behavior. Also fixes an atob() regression: after fileIds became URL-safe base64, atob() failed on "_"/"-" chars. Extracted encode/decodeFileId into utils/fileId.ts and replaced all six call sites (file tree, data-preview, chart, metadata). CSV export timestamps now use normalizeToMs too. --- .../tsfile/viewer/service/FileService.java | 9 +- frontend/src/App.vue | 2 +- frontend/src/api/tsfile/data.ts | 24 ++- frontend/src/api/tsfile/types.ts | 5 + frontend/src/components/tsfile/DataTable.vue | 52 +++-- frontend/src/components/tsfile/FileTree.vue | 195 ++++++++++++++++-- frontend/src/i18n/locales/en-US.json | 4 + frontend/src/i18n/locales/zh-CN.json | 4 + frontend/src/utils/fileId.ts | 50 +++++ frontend/src/utils/timestamp.ts | 48 +++++ frontend/src/views/tsfile/chart/index.vue | 5 +- .../src/views/tsfile/data-preview/index.vue | 8 +- frontend/src/views/tsfile/metadata/index.vue | 5 +- 13 files changed, 362 insertions(+), 49 deletions(-) create mode 100644 frontend/src/utils/fileId.ts create mode 100644 frontend/src/utils/timestamp.ts diff --git a/backend/src/main/java/org/apache/tsfile/viewer/service/FileService.java b/backend/src/main/java/org/apache/tsfile/viewer/service/FileService.java index 42225be..9074e91 100644 --- a/backend/src/main/java/org/apache/tsfile/viewer/service/FileService.java +++ b/backend/src/main/java/org/apache/tsfile/viewer/service/FileService.java @@ -322,10 +322,15 @@ public String getFilePath(String fileId) throws TsFileNotFoundException, AccessD return path; } - // Try to decode fileId as base64-encoded path (server-side files) + // Try to decode fileId as base64-encoded path (server-side files). + // The frontend encodes paths as URL-safe base64 without padding so that + // non-Latin1 characters (e.g. Chinese) survive and the value is safe as a + // single-segment route parameter. getUrlDecoder() also accepts standard + // base64 alphabets that contain no '+' or '/', preserving backward + // compatibility with previously issued fileIds. String decodedPath; try { - byte[] decodedBytes = Base64.getDecoder().decode(fileId); + byte[] decodedBytes = Base64.getUrlDecoder().decode(fileId); decodedPath = new String(decodedBytes, StandardCharsets.UTF_8); logger.debug("Decoded fileId from base64: {} -> {}", fileId, decodedPath); } catch (IllegalArgumentException e) { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6d84f70..fcd85e5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -82,7 +82,7 @@ function handleDirectorySelect(path: string, _name: string) { :collapsed-width="0" :theme="isDark ? 'dark' : 'light'" > -
+
diff --git a/frontend/src/api/tsfile/data.ts b/frontend/src/api/tsfile/data.ts index c398ea3..383f319 100644 --- a/frontend/src/api/tsfile/data.ts +++ b/frontend/src/api/tsfile/data.ts @@ -28,7 +28,29 @@ import type { import { apiClient } from "../request"; export function previewData(request: DataPreviewRequest) { - return apiClient.post("/data/preview", request); + return apiClient.post("/data/preview", request, { + // 保留时间戳原始精度:JSON.parse 会把 >2^53 的纳秒时间戳转成有损 double, + // 因此在解析前把每个 timestamp 的精确数字串旁挂为 timestampRaw 字符串字段。 + // 仅作用于本接口,避免影响其它响应。 + transformResponse: [ + (raw: unknown) => { + if (typeof raw !== "string") return raw; + try { + const patched = raw.replace( + /"timestamp"\s*:\s*(-?\d+)/g, + '"timestampRaw":"$1","timestamp":$1', + ); + return JSON.parse(patched); + } catch { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + }, + ], + }); } export function queryTableData(request: TableDataRequest) { diff --git a/frontend/src/api/tsfile/types.ts b/frontend/src/api/tsfile/types.ts index 3f038ca..c660cd8 100644 --- a/frontend/src/api/tsfile/types.ts +++ b/frontend/src/api/tsfile/types.ts @@ -160,6 +160,11 @@ export interface FilterConditions { export interface DataRow { timestamp: number; + /** + * 时间戳的精确原始数字串。JSON.parse 会把 >2^53 的纳秒时间戳转成有损 double, + * 该字段在解析前从响应文本中原样保留,用于无损展示原始存储值。 + */ + timestampRaw?: string; device: string; measurements: Record; } diff --git a/frontend/src/components/tsfile/DataTable.vue b/frontend/src/components/tsfile/DataTable.vue index 33f650d..3c2c5f1 100644 --- a/frontend/src/components/tsfile/DataTable.vue +++ b/frontend/src/components/tsfile/DataTable.vue @@ -28,9 +28,11 @@ import type { TableColumnType } from "antdv-next"; import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import { Alert, Button, Card, Input, Pagination, Select, Spin, Table } from "antdv-next"; +import { Alert, Button, Card, Input, Pagination, Select, Spin, Table, Tooltip } from "antdv-next"; import { DownloadOutlined } from "@antdv-next/icons"; +import { formatTimestamp } from "@/utils/timestamp"; + interface Props { data: DataRow[]; total: number; @@ -148,15 +150,16 @@ const columns = computed(() => { }, { title: t("tsfile.data.device"), - dataIndex: "device", - key: "device", + dataIndex: "__device__", + key: "__device__", fixed: "left", width: 180, sorter: true, }, ]; - // 标签列(固定左侧) + // 标签列(固定左侧)—— tag 名可能与保留列同名(如 "device"), + // 固定"设备"列已改用保留 key "__device__" 避免冲突,此处 tag 列可安全使用原名。 for (const tagCol of props.tagColumns) { cols.push({ title: tagCol, @@ -195,7 +198,8 @@ const tableData = computed(() => { const flatRow: Record = { _key: `${row.timestamp}-${row.device}-${index}`, timestamp: row.timestamp, - device: row.device, + timestampRaw: row.timestampRaw, + __device__: row.device, }; for (const measurement of visibleMeasurements) { flatRow[measurement] = formatValue(row.measurements[measurement]); @@ -263,13 +267,7 @@ function handleTableChange( } } -// 格式化时间戳(含毫秒) -function formatTimestamp(timestamp: number): string { - const d = new Date(timestamp); - const pad = (n: number, len = 2) => String(n).padStart(len, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; -} - +// 将不同精度的时间戳归一到毫秒并格式化,见 utils/timestamp.ts // 格式化值 function formatValue(value: unknown): number | string { if (value === null || value === undefined) return "-"; @@ -348,6 +346,16 @@ function formatValue(value: unknown): number | string { />
+ + + -