-
{{ t("tsfile.file.browser") }}
+
+
{{ t("tsfile.file.browser") }}
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
{{ t("tsfile.file.searchNoResult") }}
+
+
+
@@ -190,6 +340,9 @@ onMounted(() => {
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 {