This is an automated email from the ASF dual-hosted git repository.

HTHou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tsfile-viewer.git


The following commit(s) were added to refs/heads/main by this push:
     new 1a14814  fix: correct table column alignment and non-millisecond 
timestamp formatting (#27)
1a14814 is described below

commit 1a14814af9c7ffd02c3b2de8d7bd36ca2b77104b
Author: CritasWang <[email protected]>
AuthorDate: Thu Jul 2 16:22:08 2026 +0800

    fix: correct table column alignment and non-millisecond timestamp 
formatting (#27)
    
    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.
---
 .../apache/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 +-
 frontend/src/views/tsfile/data-preview/index.vue   |   8 +-
 frontend/src/views/tsfile/metadata/index.vue       |   5 +-
 13 files changed, 362 insertions(+), 49 deletions(-)

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 class FileService {
       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'"
           >
-            <div style="padding: 16px; overflow-y: auto; height: 100%;">
+            <div style="padding: 16px; overflow: hidden; height: 100%;">
               <FileTree @select="handleFileSelect" 
@select-directory="handleDirectorySelect" />
             </div>
           </LayoutSider>
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<unknown, DataPreviewResponse>("/data/preview", 
request);
+  return apiClient.post<unknown, DataPreviewResponse>("/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<string, unknown>;
 }
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<TableColumnType[]>(() => {
     },
     {
       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<string, unknown> = {
       _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 {
       />
     </div>
 
+    <!-- 时间戳精度说明 -->
+    <Alert
+      v-if="!error"
+      type="info"
+      show-icon
+      :message="t('tsfile.data.precisionNote')"
+      class="mb-3"
+      banner
+    />
+
     <!-- 数据表格 -->
     <Table
       v-if="!error"
@@ -362,13 +370,15 @@ function formatValue(value: unknown): number | string {
       row-key="_key"
       @change="handleTableChange"
     >
-      <template #bodyCell="{ column, text }">
+      <template #bodyCell="{ column, text, record }">
         <template v-if="column.key === 'timestamp'">
-          <span class="font-mono text-xs">
-            {{ formatTimestamp(text as number) }}
-          </span>
+          <Tooltip :title="`${t('tsfile.data.rawTimestamp')}: 
${record.timestampRaw ?? text}`">
+            <span class="font-mono text-xs timestamp-cell">
+              {{ formatTimestamp(text as number) }}
+            </span>
+          </Tooltip>
         </template>
-        <template v-else-if="column.key === 'device'">
+        <template v-else-if="column.key === '__device__'">
           <span class="font-medium">{{ text }}</span>
         </template>
         <template v-else>
@@ -412,3 +422,11 @@ function formatValue(value: unknown): number | string {
     </div>
   </Card>
 </template>
+
+<style scoped>
+/* 时间戳单元格:虚线下划线提示悬浮可查看原始存储值 */
+.timestamp-cell {
+  border-bottom: 1px dotted var(--ant-color-border, #bbb);
+  cursor: help;
+}
+</style>
diff --git a/frontend/src/components/tsfile/FileTree.vue 
b/frontend/src/components/tsfile/FileTree.vue
index 269414e..ea188c1 100644
--- a/frontend/src/components/tsfile/FileTree.vue
+++ b/frontend/src/components/tsfile/FileTree.vue
@@ -24,12 +24,13 @@
  */
 import type { TreeNode } from "@/api/tsfile/types";
 
-import { h, onMounted, ref } from "vue";
+import { computed, h, onBeforeUnmount, onMounted, ref } from "vue";
 import { useI18n } from "vue-i18n";
 
-import { Alert, Spin, Tree } from "antdv-next";
+import { Alert, Input, Spin, Tree } from "antdv-next";
 
 import { fileApi } from "@/api/tsfile";
+import { encodeFileId } from "@/utils/fileId";
 
 const { t } = useI18n();
 
@@ -52,6 +53,90 @@ const expandedKeys = ref<string[]>([]);
 const loadingKeys = ref<Set<string>>(new Set());
 const loading = ref(false);
 const hasError = ref(false);
+const searchValue = ref("");
+
+// 虚拟滚动需要一个明确的像素高度;用 ResizeObserver 测量树区域可用高度,
+// 传给 <Tree :height>,antdv 检测到 height 后自动启用虚拟滚动,只渲染可视节点。
+const treeContainer = ref<HTMLElement | null>(null);
+const treeHeight = ref(400);
+let resizeObserver: ResizeObserver | null = null;
+
+// 搜索时自动展开命中节点的祖先路径。展开态在搜索期间由 expandedKeys 接管,
+// 清空搜索后恢复用户手动展开的状态。
+const manualExpandedKeys = ref<string[]>([]);
+
+/**
+ * 收集所有 title 命中搜索词的节点 key,及其祖先 key(用于自动展开)。
+ * 树是懒加载的:只在已加载(已展开过)的节点范围内匹配。
+ */
+function collectMatchedKeys(nodes: FlatNode[], keyword: string, ancestors: 
string[], out: Set<string>): boolean {
+  let anyMatch = false;
+  for (const node of nodes) {
+    const selfMatch = node.title.toLowerCase().includes(keyword);
+    let childMatch = false;
+    if (node.children && node.children.length > 0) {
+      childMatch = collectMatchedKeys(node.children, keyword, [...ancestors, 
node.key], out);
+    }
+    if (selfMatch || childMatch) {
+      // 命中节点的所有祖先都要展开才能看到它
+      for (const a of ancestors) out.add(a);
+      if (childMatch) out.add(node.key);
+      anyMatch = true;
+    }
+  }
+  return anyMatch;
+}
+
+const matchedKeys = computed<Set<string>>(() => {
+  const keyword = searchValue.value.trim().toLowerCase();
+  if (!keyword) return new Set();
+  const out = new Set<string>();
+  collectMatchedKeys(treeData.value, keyword, [], out);
+  return out;
+});
+
+/**
+ * 按搜索词把树裁剪为「命中节点 + 其祖先路径」的子树。
+ * 保留规则:节点自身 title 命中,或其后代中有命中项(祖先需保留以展示路径)。
+ */
+function filterTree(nodes: FlatNode[], keyword: string): FlatNode[] {
+  const result: FlatNode[] = [];
+  for (const node of nodes) {
+    const selfMatch = node.title.toLowerCase().includes(keyword);
+    const filteredChildren =
+      node.children && node.children.length > 0 ? filterTree(node.children, 
keyword) : [];
+    if (selfMatch || filteredChildren.length > 0) {
+      result.push({
+        ...node,
+        children: filteredChildren.length > 0 ? filteredChildren : 
node.children,
+      });
+    }
+  }
+  return result;
+}
+
+// 传给 <Tree> 的数据:无搜索词时是完整树,有搜索词时是裁剪后的子树。
+const displayTreeData = computed<FlatNode[]>(() => {
+  const keyword = searchValue.value.trim().toLowerCase();
+  if (!keyword) return treeData.value;
+  return filterTree(treeData.value, keyword);
+});
+
+// 有搜索词时用命中祖先集合展开树;无搜索词时使用用户手动展开的状态。
+function syncExpandedForSearch() {
+  const keyword = searchValue.value.trim();
+  if (keyword) {
+    expandedKeys.value = Array.from(matchedKeys.value);
+  } else {
+    expandedKeys.value = [...manualExpandedKeys.value];
+  }
+}
+
+function measureTreeHeight() {
+  if (treeContainer.value) {
+    treeHeight.value = Math.max(200, treeContainer.value.clientHeight);
+  }
+}
 
 function transformNode(node: TreeNode): FlatNode {
   const result: FlatNode = {
@@ -109,7 +194,12 @@ async function loadRootTree() {
  * 展开节点时加载子目录
  */
 async function handleExpand(keys: (string | number)[], info: { expanded: 
boolean; node: any }) {
-  expandedKeys.value = keys.map(k => String(k));
+  const stringKeys = keys.map(k => String(k));
+  expandedKeys.value = stringKeys;
+  // 记录用户手动展开的状态,供清空搜索后恢复
+  if (!searchValue.value.trim()) {
+    manualExpandedKeys.value = stringKeys;
+  }
 
   if (!info.expanded) return;
 
@@ -132,12 +222,15 @@ async function handleExpand(keys: (string | number)[], 
info: { expanded: boolean
   }
 }
 
+/**
+ * 将文件路径编码为 URL-safe 的 Base64 fileId,见 utils/fileId.ts。
+ */
 function handleSelect(_selectedKeys: (string | number)[], info: any) {
   const data = info.node;
   if (data.isDirectory) {
     emit("selectDirectory", data.path || data.key, data.title);
   } else {
-    const fileId = btoa(data.path || data.key);
+    const fileId = encodeFileId(data.path || data.key);
     emit("select", fileId, data.path || data.key, data.title);
   }
 }
@@ -148,38 +241,95 @@ function getNodeIconClass(node: any): string {
   return "i-mdi:file text-blue-500";
 }
 
+/**
+ * 渲染节点标题;搜索时把命中的关键词片段高亮显示。
+ */
 function renderTitle(node: any) {
+  const title = String(node.title);
+  const keyword = searchValue.value.trim();
+  let titleContent: any = title;
+
+  if (keyword) {
+    const lowerTitle = title.toLowerCase();
+    const idx = lowerTitle.indexOf(keyword.toLowerCase());
+    if (idx !== -1) {
+      const before = title.slice(0, idx);
+      const match = title.slice(idx, idx + keyword.length);
+      const after = title.slice(idx + keyword.length);
+      titleContent = [
+        before,
+        h("span", { class: "bg-yellow-200 text-yellow-900 rounded px-0.5" }, 
match),
+        after,
+      ];
+    }
+  }
+
   return h("span", { class: "inline-flex items-center gap-2" }, [
     h("span", { class: getNodeIconClass(node) }),
-    h("span", null, node.title),
+    h("span", null, titleContent),
   ]);
 }
 
+function handleSearch(value: string) {
+  searchValue.value = value;
+  syncExpandedForSearch();
+}
+
 onMounted(() => {
   loadRootTree();
+  if (treeContainer.value) {
+    measureTreeHeight();
+    resizeObserver = new ResizeObserver(() => measureTreeHeight());
+    resizeObserver.observe(treeContainer.value);
+  }
+});
+
+onBeforeUnmount(() => {
+  resizeObserver?.disconnect();
+  resizeObserver = null;
 });
 </script>
 
 <template>
   <div class="file-tree">
-    <div class="mb-4">
-      <h3 class="text-lg font-semibold">{{ t("tsfile.file.browser") }}</h3>
+    <div class="mb-3 flex-shrink-0">
+      <h3 class="mb-2 text-lg font-semibold">{{ t("tsfile.file.browser") 
}}</h3>
+      <Input
+        :value="searchValue"
+        :placeholder="t('tsfile.file.searchPlaceholder')"
+        allow-clear
+        @update:value="handleSearch"
+      >
+        <template #prefix>
+          <span class="i-mdi:magnify text-gray-400" />
+        </template>
+      </Input>
     </div>
 
-    <Alert v-if="hasError" type="warning" 
:message="t('tsfile.file.loadTreeError')" show-icon class="mb-4" />
-
-    <Spin :spinning="loading">
-      <Tree
-        v-if="treeData.length > 0"
-        :tree-data="treeData"
-        :expanded-keys="expandedKeys"
-        :selectable="true"
-        :title-render="renderTitle"
-        block-node
-        @expand="handleExpand"
-        @select="handleSelect"
-      />
-    </Spin>
+    <Alert v-if="hasError" type="warning" 
:message="t('tsfile.file.loadTreeError')" show-icon class="mb-3 flex-shrink-0" 
/>
+
+    <div ref="treeContainer" class="min-h-0 flex-1">
+      <Spin :spinning="loading">
+        <Tree
+          v-if="displayTreeData.length > 0"
+          :tree-data="displayTreeData"
+          :expanded-keys="expandedKeys"
+          :height="treeHeight"
+          :selectable="true"
+          :title-render="renderTitle"
+          block-node
+          @expand="handleExpand"
+          @select="handleSelect"
+        />
+        <div
+          v-else-if="searchValue.trim() && treeData.length > 0"
+          class="py-6 text-center text-gray-500"
+        >
+          <span class="i-mdi:file-search-outline mb-2 inline-block text-4xl 
text-gray-400 opacity-70" />
+          <p class="mx-2 text-sm leading-relaxed">{{ 
t("tsfile.file.searchNoResult") }}</p>
+        </div>
+      </Spin>
+    </div>
 
     <div v-if="!loading && !hasError && treeData.length === 0" class="py-6 
text-center text-gray-500">
       <span class="i-mdi:folder-alert mb-2 inline-block text-4xl 
text-yellow-400 opacity-70" />
@@ -190,6 +340,9 @@ onMounted(() => {
 
 <style scoped>
 .file-tree {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
   user-select: none;
 }
 </style>
diff --git a/frontend/src/i18n/locales/en-US.json 
b/frontend/src/i18n/locales/en-US.json
index debbf7f..9c0bcd0 100644
--- a/frontend/src/i18n/locales/en-US.json
+++ b/frontend/src/i18n/locales/en-US.json
@@ -51,6 +51,8 @@
     "file": {
       "title": "File Selection",
       "browser": "File Browser",
+      "searchPlaceholder": "Search files in expanded folders…",
+      "searchNoResult": "No matching files in expanded folders. Search only 
covers folders that have been loaded (expanded); please expand the relevant 
folders first.",
       "selectFile": "Select File",
       "uploadFile": "Upload File",
       "recentFiles": "Recent Files",
@@ -168,6 +170,8 @@
       "applyFilters": "Apply Filters",
       "resetFilters": "Reset Filters",
       "timestamp": "Timestamp",
+      "rawTimestamp": "Raw timestamp",
+      "precisionNote": "Timestamps are displayed to millisecond precision in 
the local timezone; the source may have higher resolution (microsecond / 
nanosecond). Hover a timestamp to see the full original stored value.",
       "device": "Device",
       "exportCsv": "Export CSV",
       "exportJson": "Export JSON",
diff --git a/frontend/src/i18n/locales/zh-CN.json 
b/frontend/src/i18n/locales/zh-CN.json
index c37dc32..0c96170 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -51,6 +51,8 @@
     "file": {
       "title": "文件选择",
       "browser": "文件浏览器",
+      "searchPlaceholder": "搜索已展开目录中的文件…",
+      "searchNoResult": "已展开目录中没有匹配的文件。搜索仅在已加载(展开过)的目录内进行,请先展开相应目录。",
       "selectFile": "选择文件",
       "uploadFile": "上传文件",
       "recentFiles": "最近访问",
@@ -168,6 +170,8 @@
       "applyFilters": "应用筛选",
       "resetFilters": "重置筛选",
       "timestamp": "时间戳",
+      "rawTimestamp": "原始时间戳",
+      "precisionNote": "时间戳按本地时区显示到毫秒;数据源可能为更高精度(微秒 / 纳秒),悬浮时间戳可查看完整的原始存储值。",
       "device": "设备",
       "exportCsv": "导出 CSV",
       "exportJson": "导出 JSON",
diff --git a/frontend/src/utils/fileId.ts b/frontend/src/utils/fileId.ts
new file mode 100644
index 0000000..3f76805
--- /dev/null
+++ b/frontend/src/utils/fileId.ts
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * 将文件路径编码为 URL-safe 的 Base64 fileId。
+ *
+ * 先按 UTF-8 编码成字节再做 Base64,以支持中文等非 Latin1 字符
+ * (直接使用 btoa 遇到中文会抛 InvalidCharacterError)。
+ * 随后转为 URL-safe 变体(+/ → -_,去掉 = padding),使其可安全地
+ * 作为单段路由参数(:fileId)使用。后端以 Base64.getUrlDecoder() 解码。
+ */
+export function encodeFileId(path: string): string {
+  const bytes = new TextEncoder().encode(path);
+  let binary = "";
+  for (const byte of bytes) {
+    binary += String.fromCharCode(byte);
+  }
+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, 
"");
+}
+
+/**
+ * 将 URL-safe Base64 fileId 解码回原始文件路径(与 encodeFileId 互逆)。
+ * 正确还原 UTF-8 编码的中文字符。解码失败时抛出,调用方需自行兜底。
+ */
+export function decodeFileId(fileId: string): string {
+  let b64 = fileId.replace(/-/g, "+").replace(/_/g, "/");
+  while (b64.length % 4) b64 += "=";
+  const binary = atob(b64);
+  const bytes = new Uint8Array(binary.length);
+  for (let i = 0; i < binary.length; i++) {
+    bytes[i] = binary.charCodeAt(i);
+  }
+  return new TextDecoder().decode(bytes);
+}
diff --git a/frontend/src/utils/timestamp.ts b/frontend/src/utils/timestamp.ts
new file mode 100644
index 0000000..ddd47ca
--- /dev/null
+++ b/frontend/src/utils/timestamp.ts
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * 将不同精度的 TsFile 时间戳归一到毫秒。
+ *
+ * TsFile 的时间戳单位可能是秒 / 毫秒 / 微秒 / 纳秒,按数量级(位数)自适应判断:
+ *   ~1e18 纳秒(19位) → /1e6,~1e15 微秒(16位) → /1e3,
+ *   ~1e12 毫秒(13位) → 原样,~1e9 秒(10位) → *1e3。
+ *
+ * 直接用 new Date(纳秒) 会超出 JS Date 有效范围而得到 Invalid Date(显示 NaN)。
+ */
+export function normalizeToMs(timestamp: number): number {
+  const abs = Math.abs(timestamp);
+  if (abs >= 1e17) return Math.floor(timestamp / 1e6); // 纳秒
+  if (abs >= 1e14) return Math.floor(timestamp / 1e3); // 微秒
+  if (abs >= 1e11) return timestamp; // 毫秒
+  if (abs >= 1e8) return timestamp * 1e3; // 秒
+  return timestamp; // 其它(含 0 / 极小值)按毫秒处理
+}
+
+/**
+ * 将时间戳格式化为 `YYYY-MM-DD HH:mm:ss.SSS`(本地时区,含毫秒)。
+ * 无法解析时回退为原始值的字符串形式。
+ */
+export function formatTimestamp(timestamp: number): string {
+  if (timestamp === null || timestamp === undefined || 
Number.isNaN(timestamp)) return "-";
+  const d = new Date(normalizeToMs(timestamp));
+  if (Number.isNaN(d.getTime())) return String(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)}`;
+}
diff --git a/frontend/src/views/tsfile/chart/index.vue 
b/frontend/src/views/tsfile/chart/index.vue
index b3f0bc3..45b1f2f 100644
--- a/frontend/src/views/tsfile/chart/index.vue
+++ b/frontend/src/views/tsfile/chart/index.vue
@@ -28,6 +28,7 @@ import ChartPanel from "@/components/tsfile/ChartPanel.vue";
 import TableFilterPanel from "@/components/tsfile/TableFilterPanel.vue";
 import TreeFilterPanel from "@/components/tsfile/TreeFilterPanel.vue";
 import { useFileStore } from "@/stores/tsfile/file";
+import { decodeFileId } from "@/utils/fileId";
 
 const route = useRoute();
 const router = useRouter();
@@ -50,7 +51,7 @@ const isTableModel = computed(() => metadata.value?.tables && 
metadata.value.tab
 const displayFileName = computed(() => {
   if (fileStore.currentFileName) return fileStore.currentFileName;
   try {
-    const decoded = atob(fileId.value);
+    const decoded = decodeFileId(fileId.value);
     return decoded.split('/').pop() || fileId.value;
   } catch {
     return fileId.value;
@@ -130,7 +131,7 @@ function goBack() {
 }
 function goToQuickScan() {
   try {
-    const filePath = atob(fileId.value);
+    const filePath = decodeFileId(fileId.value);
     fileStore.setScanTarget(filePath, 'file', true);
     router.push('/tsfile/scan');
   } catch {
diff --git a/frontend/src/views/tsfile/data-preview/index.vue 
b/frontend/src/views/tsfile/data-preview/index.vue
index 63df9cb..8bbf5f3 100644
--- a/frontend/src/views/tsfile/data-preview/index.vue
+++ b/frontend/src/views/tsfile/data-preview/index.vue
@@ -28,6 +28,8 @@ import DataTable from "@/components/tsfile/DataTable.vue";
 import TableFilterPanel from "@/components/tsfile/TableFilterPanel.vue";
 import TreeFilterPanel from "@/components/tsfile/TreeFilterPanel.vue";
 import { useFileStore } from "@/stores/tsfile/file";
+import { normalizeToMs } from "@/utils/timestamp";
+import { decodeFileId } from "@/utils/fileId";
 
 const route = useRoute();
 const router = useRouter();
@@ -38,7 +40,7 @@ const fileId = computed(() => route.params.fileId as string);
 const displayFileName = computed(() => {
   if (fileStore.currentFileName) return fileStore.currentFileName;
   try {
-    const decoded = atob(fileId.value);
+    const decoded = decodeFileId(fileId.value);
     return decoded.split('/').pop() || fileId.value;
   } catch {
     return fileId.value;
@@ -151,7 +153,7 @@ function exportCSV() {
   const headers = ["Timestamp", "Device", ...measurementCols];
   const rows = dataRows.value.map((row) =>
     [
-      new Date(row.timestamp).toISOString(),
+      new Date(normalizeToMs(row.timestamp)).toISOString(),
       row.device,
       ...measurementCols.map((col) => row.measurements[col] ?? ""),
     ].join(","),
@@ -181,7 +183,7 @@ function goBack() {
 }
 function goToQuickScan() {
   try {
-    const filePath = atob(fileId.value);
+    const filePath = decodeFileId(fileId.value);
     fileStore.setScanTarget(filePath, 'file', true);
     router.push('/tsfile/scan');
   } catch {
diff --git a/frontend/src/views/tsfile/metadata/index.vue 
b/frontend/src/views/tsfile/metadata/index.vue
index 63df471..fe06d78 100644
--- a/frontend/src/views/tsfile/metadata/index.vue
+++ b/frontend/src/views/tsfile/metadata/index.vue
@@ -29,6 +29,7 @@ import MeasurementsTable from 
"@/components/tsfile/MeasurementsTable.vue";
 import RowGroupsTable from "@/components/tsfile/RowGroupsTable.vue";
 import TablesTable from "@/components/tsfile/TablesTable.vue";
 import { useFileStore } from "@/stores/tsfile/file";
+import { decodeFileId } from "@/utils/fileId";
 
 const route = useRoute();
 const router = useRouter();
@@ -44,7 +45,7 @@ const activeTab = ref("rowGroups");
 const displayFileName = computed(() => {
   if (fileStore.currentFileName) return fileStore.currentFileName;
   try {
-    const decoded = atob(fileId.value);
+    const decoded = decodeFileId(fileId.value);
     return decoded.split('/').pop() || fileId.value;
   } catch {
     return fileId.value;
@@ -104,7 +105,7 @@ function goBack() {
 }
 function goToQuickScan() {
   try {
-    const filePath = atob(fileId.value);
+    const filePath = decodeFileId(fileId.value);
     fileStore.setScanTarget(filePath, 'file', true);
     router.push('/tsfile/scan');
   } catch {

Reply via email to