exchange tast to tactilea
This commit is contained in:
@@ -5,10 +5,14 @@
|
||||
import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import HudPanel from "$lib/components/HudPanel.svelte";
|
||||
import CenterStage from "$lib/components/CenterStage.svelte";
|
||||
import FileExplorerModal from "$lib/components/FileExplorerModal.svelte";
|
||||
import { pressureColorPalettes } from "$lib/config/color-map";
|
||||
import "$lib/styles/theme.css";
|
||||
import type {
|
||||
ConnectionState,
|
||||
FileExplorerEntry,
|
||||
FileExplorerListResult,
|
||||
FileExplorerRoot,
|
||||
HudColorMapOption,
|
||||
HudCopy,
|
||||
HudConfigLink,
|
||||
@@ -21,6 +25,7 @@
|
||||
LocaleCode,
|
||||
SerialConnectResult,
|
||||
SerialExportResult,
|
||||
SerialRecordStateResult,
|
||||
SerialImportResult,
|
||||
SignalTone,
|
||||
StageStatusTone,
|
||||
@@ -28,6 +33,8 @@
|
||||
} from "$lib/types/hud";
|
||||
|
||||
type SignalPanelTemplate = Pick<HudSignalPanel, "id" | "code" | "title" | "side">;
|
||||
type FileExplorerMode = "open" | "save";
|
||||
|
||||
interface ReplayFrame {
|
||||
values: number[];
|
||||
dtsMs: number;
|
||||
@@ -65,6 +72,20 @@
|
||||
exportActionLabel: "导出 CSV",
|
||||
exportingActionLabel: "导出中",
|
||||
importActionLabel: "导入 CSV",
|
||||
fileExplorerImportTitle: "导入 CSV 文件",
|
||||
fileExplorerExportTitle: "导出 CSV 文件",
|
||||
fileExplorerPathLabel: "路径",
|
||||
fileExplorerNameLabel: "文件名",
|
||||
fileExplorerCancelLabel: "取消",
|
||||
fileExplorerOpenLabel: "打开",
|
||||
fileExplorerSaveLabel: "保存",
|
||||
fileExplorerEmptyHint: "当前目录下没有可用条目",
|
||||
fileExplorerCsvHint: "仅显示 *.csv 文件",
|
||||
fileExplorerLoadingLabel: "处理中...",
|
||||
fileExplorerUpLabel: "↑ 上一级",
|
||||
fileExplorerNameColumnLabel: "名称",
|
||||
fileExplorerSizeColumnLabel: "大小",
|
||||
fileExplorerModifiedColumnLabel: "修改时间",
|
||||
replaySectionLabel: "回放",
|
||||
replayPlayLabel: "播放",
|
||||
replayPauseLabel: "暂停",
|
||||
@@ -107,6 +128,20 @@
|
||||
exportActionLabel: "Export CSV",
|
||||
exportingActionLabel: "Exporting",
|
||||
importActionLabel: "Import CSV",
|
||||
fileExplorerImportTitle: "Import CSV File",
|
||||
fileExplorerExportTitle: "Export CSV File",
|
||||
fileExplorerPathLabel: "Path",
|
||||
fileExplorerNameLabel: "File Name",
|
||||
fileExplorerCancelLabel: "Cancel",
|
||||
fileExplorerOpenLabel: "Open",
|
||||
fileExplorerSaveLabel: "Save",
|
||||
fileExplorerEmptyHint: "No entries in this directory",
|
||||
fileExplorerCsvHint: "Only *.csv files are listed",
|
||||
fileExplorerLoadingLabel: "Processing...",
|
||||
fileExplorerUpLabel: "↑ Up",
|
||||
fileExplorerNameColumnLabel: "Name",
|
||||
fileExplorerSizeColumnLabel: "Size",
|
||||
fileExplorerModifiedColumnLabel: "Modified",
|
||||
replaySectionLabel: "Replay",
|
||||
replayPlayLabel: "Play",
|
||||
replayPauseLabel: "Pause",
|
||||
@@ -186,6 +221,15 @@
|
||||
let replayProgress = 0;
|
||||
let replayFileName = "";
|
||||
let replayTimerId: number | null = null;
|
||||
let fileExplorerOpen = false;
|
||||
let fileExplorerMode: FileExplorerMode = "open";
|
||||
let fileExplorerBusy = false;
|
||||
let fileExplorerCurrentPath = "";
|
||||
let fileExplorerParentPath: string | null = null;
|
||||
let fileExplorerEntries: FileExplorerEntry[] = [];
|
||||
let fileExplorerRoots: FileExplorerRoot[] = [];
|
||||
let fileExplorerSelectedPath = "";
|
||||
let fileExplorerFileName = "";
|
||||
|
||||
$: uiCopy = copyByLocale[locale];
|
||||
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen);
|
||||
@@ -197,6 +241,10 @@
|
||||
$: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset);
|
||||
$: replayHasData = replayFrames.length > 0;
|
||||
$: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : "";
|
||||
$: fileExplorerTitle =
|
||||
fileExplorerMode === "open" ? uiCopy.fileExplorerImportTitle : uiCopy.fileExplorerExportTitle;
|
||||
$: fileExplorerConfirmLabel =
|
||||
fileExplorerMode === "open" ? uiCopy.fileExplorerOpenLabel : uiCopy.fileExplorerSaveLabel;
|
||||
|
||||
function isTauriRuntime(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
@@ -353,6 +401,209 @@
|
||||
return frames;
|
||||
}
|
||||
|
||||
function buildDefaultExportName(): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `joyson_export_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`;
|
||||
}
|
||||
|
||||
function ensureCsvSuffix(fileName: string): string {
|
||||
const trimmed = fileName.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed.toLowerCase().endsWith(".csv") ? trimmed : `${trimmed}.csv`;
|
||||
}
|
||||
|
||||
function inferPathSeparator(path: string): string {
|
||||
return path.includes("\\") ? "\\" : "/";
|
||||
}
|
||||
|
||||
function joinPath(parent: string, fileName: string): string {
|
||||
const safeParent = parent.trim();
|
||||
if (!safeParent) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
const separator = inferPathSeparator(safeParent);
|
||||
if (safeParent.endsWith(separator)) {
|
||||
return `${safeParent}${fileName}`;
|
||||
}
|
||||
|
||||
return `${safeParent}${separator}${fileName}`;
|
||||
}
|
||||
|
||||
function isCsvPath(path: string): boolean {
|
||||
return path.toLowerCase().endsWith(".csv");
|
||||
}
|
||||
|
||||
function applyImportedFrames(fileName: string, frames: ReplayFrame[], frameCount: number, channelCount: number): void {
|
||||
if (!frames.length) {
|
||||
throw new Error("EmptyReplayData");
|
||||
}
|
||||
|
||||
replayFrames = frames;
|
||||
replayFileName = fileName;
|
||||
replayCurrentIndex = 0;
|
||||
replayHasDisplayedFrame = false;
|
||||
replayProgress = 0;
|
||||
resetReplayVisualState();
|
||||
|
||||
connectionNotice =
|
||||
locale === "zh-CN"
|
||||
? `已导入 ${fileName},共 ${frameCount} 帧 / ${channelCount} 通道,可开始回放。`
|
||||
: `${fileName} loaded (${frameCount} frames / ${channelCount} channels). Ready to replay.`;
|
||||
connectionNoticeTone = "ok";
|
||||
}
|
||||
|
||||
async function loadFileExplorerDirectory(path?: string): Promise<void> {
|
||||
if (!isTauriRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileExplorerBusy = true;
|
||||
try {
|
||||
const result = await invoke<FileExplorerListResult>("file_explorer_list", {
|
||||
path,
|
||||
extensions: fileExplorerMode === "open" ? ["csv"] : undefined
|
||||
});
|
||||
|
||||
fileExplorerCurrentPath = result.currentPath;
|
||||
fileExplorerParentPath = result.parentPath;
|
||||
fileExplorerRoots = result.roots;
|
||||
fileExplorerEntries = result.entries;
|
||||
|
||||
const selectedExists = fileExplorerEntries.some((entry) => entry.path === fileExplorerSelectedPath);
|
||||
if (!selectedExists) {
|
||||
fileExplorerSelectedPath = "";
|
||||
}
|
||||
} catch (error) {
|
||||
connectionNotice =
|
||||
locale === "zh-CN" ? "文件浏览器加载失败,请检查目录权限。" : "File explorer failed to load. Check directory permissions.";
|
||||
connectionNoticeTone = "warn";
|
||||
console.error("File explorer load failed:", error);
|
||||
} finally {
|
||||
fileExplorerBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openFileExplorer(mode: FileExplorerMode): Promise<void> {
|
||||
if (!isTauriRuntime()) {
|
||||
if (mode === "open") {
|
||||
await importViaBrowserInput();
|
||||
return;
|
||||
}
|
||||
|
||||
await runSerialExport();
|
||||
return;
|
||||
}
|
||||
|
||||
fileExplorerMode = mode;
|
||||
fileExplorerOpen = true;
|
||||
fileExplorerBusy = false;
|
||||
fileExplorerSelectedPath = "";
|
||||
if (mode === "save") {
|
||||
fileExplorerFileName = buildDefaultExportName();
|
||||
} else {
|
||||
fileExplorerFileName = "";
|
||||
}
|
||||
|
||||
await loadFileExplorerDirectory(fileExplorerCurrentPath || undefined);
|
||||
}
|
||||
|
||||
function closeFileExplorer(): void {
|
||||
if (fileExplorerBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileExplorerOpen = false;
|
||||
}
|
||||
|
||||
async function importViaBrowserInput(): Promise<void> {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".csv,text/csv";
|
||||
|
||||
const selectedFile = await new Promise<File | null>((resolve) => {
|
||||
input.onchange = () => resolve(input.files?.[0] ?? null);
|
||||
input.click();
|
||||
});
|
||||
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await importReplayFromFile(selectedFile);
|
||||
}
|
||||
|
||||
async function importReplayFromFile(file: File): Promise<boolean> {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pauseReplayPlayback();
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
let frames: ReplayFrame[] = [];
|
||||
let importedFrameCount = 0;
|
||||
let importedChannelCount = 0;
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
const result = await invoke<SerialImportResult>("serial_import_csv", {
|
||||
fileName: file.name,
|
||||
csvContent: text
|
||||
});
|
||||
frames = result.frames.map((frame) => ({
|
||||
values: frame.data,
|
||||
dtsMs: frame.dtsMs
|
||||
}));
|
||||
importedFrameCount = result.frameCount;
|
||||
importedChannelCount = result.channelCount;
|
||||
} else {
|
||||
frames = parseReplayCsv(text);
|
||||
importedFrameCount = frames.length;
|
||||
importedChannelCount = frames[0]?.values.length ?? 0;
|
||||
}
|
||||
|
||||
applyImportedFrames(file.name, frames, importedFrameCount, importedChannelCount);
|
||||
return true;
|
||||
} catch (error) {
|
||||
connectionNotice = resolveImportNotice(error);
|
||||
connectionNoticeTone = "warn";
|
||||
console.error("Replay import failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importReplayFromPath(path: string): Promise<boolean> {
|
||||
pauseReplayPlayback();
|
||||
|
||||
try {
|
||||
const result = await invoke<SerialImportResult>("serial_import_csv_from_path", {
|
||||
filePath: path
|
||||
});
|
||||
|
||||
const frames = result.frames.map((frame) => ({
|
||||
values: frame.data,
|
||||
dtsMs: frame.dtsMs
|
||||
}));
|
||||
|
||||
applyImportedFrames(result.fileName, frames, result.frameCount, result.channelCount);
|
||||
return true;
|
||||
} catch (error) {
|
||||
connectionNotice = resolveImportNotice(error);
|
||||
connectionNoticeTone = "warn";
|
||||
console.error("Replay import failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopReplayTimer(): void {
|
||||
if (replayTimerId == null || typeof window === "undefined") {
|
||||
return;
|
||||
@@ -980,81 +1231,118 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSerialExport(): Promise<void> {
|
||||
async function runSerialExport(filePath?: string): Promise<boolean> {
|
||||
if (!isTauriRuntime()) {
|
||||
console.warn("[serial] Export is only available inside Tauri.");
|
||||
return;
|
||||
connectionNotice =
|
||||
locale === "zh-CN" ? "当前环境不支持导出到本地路径。" : "Current runtime cannot export to local paths.";
|
||||
connectionNoticeTone = "warn";
|
||||
return false;
|
||||
}
|
||||
|
||||
isExporting = true;
|
||||
fileExplorerBusy = true;
|
||||
|
||||
try {
|
||||
const result = await invoke<SerialExportResult>("serial_export_csv");
|
||||
const result = filePath
|
||||
? await invoke<SerialExportResult>("serial_export_csv_to_path", { filePath })
|
||||
: await invoke<SerialExportResult>("serial_export_csv");
|
||||
|
||||
connectionNotice =
|
||||
locale === "zh-CN"
|
||||
? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
|
||||
: `CSV exported (${result.frameCount} frames): ${result.path}`;
|
||||
connectionNoticeTone = "ok";
|
||||
return true;
|
||||
} catch (error) {
|
||||
connectionNotice = resolveExportNotice(error);
|
||||
connectionNoticeTone = "warn";
|
||||
console.error("Serial export failed:", error);
|
||||
return false;
|
||||
} finally {
|
||||
isExporting = false;
|
||||
fileExplorerBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReplayImport(event: CustomEvent<File>): Promise<void> {
|
||||
const file = event.detail;
|
||||
if (!file) {
|
||||
async function precheckExportRecordData(): Promise<boolean> {
|
||||
if (!isTauriRuntime()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<SerialRecordStateResult>("serial_has_record_data");
|
||||
if (result.hasData) {
|
||||
return true;
|
||||
}
|
||||
|
||||
connectionNotice = resolveExportNotice("NoRecordedData");
|
||||
connectionNoticeTone = "warn";
|
||||
return false;
|
||||
} catch (error) {
|
||||
connectionNotice = resolveExportNotice(error);
|
||||
connectionNoticeTone = "warn";
|
||||
console.error("Export precheck failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSerialExportRequest(): Promise<void> {
|
||||
const hasData = await precheckExportRecordData();
|
||||
if (!hasData) {
|
||||
return;
|
||||
}
|
||||
|
||||
pauseReplayPlayback();
|
||||
await openFileExplorer("save");
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
let frames: ReplayFrame[];
|
||||
let importedFrameCount = 0;
|
||||
let importedChannelCount = 0;
|
||||
async function handleReplayImportRequest(): Promise<void> {
|
||||
await openFileExplorer("open");
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
const result = await invoke<SerialImportResult>("serial_import_csv", {
|
||||
fileName: file.name,
|
||||
csvContent: text
|
||||
});
|
||||
frames = result.frames.map((frame) => ({
|
||||
values: frame.data,
|
||||
dtsMs: frame.dtsMs
|
||||
}));
|
||||
importedFrameCount = result.frameCount;
|
||||
importedChannelCount = result.channelCount;
|
||||
} else {
|
||||
frames = parseReplayCsv(text);
|
||||
importedFrameCount = frames.length;
|
||||
importedChannelCount = frames[0]?.values.length ?? 0;
|
||||
async function handleFileExplorerNavigate(event: CustomEvent<string>): Promise<void> {
|
||||
await loadFileExplorerDirectory(event.detail);
|
||||
}
|
||||
|
||||
async function handleFileExplorerConfirm(): Promise<void> {
|
||||
if (fileExplorerBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileExplorerMode === "open") {
|
||||
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
if (selected.isDir) {
|
||||
await loadFileExplorerDirectory(selected.path);
|
||||
return;
|
||||
}
|
||||
if (!isCsvPath(selected.path)) {
|
||||
connectionNotice = locale === "zh-CN" ? "请先选择 CSV 文件。" : "Please choose a CSV file.";
|
||||
connectionNoticeTone = "warn";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!frames.length) {
|
||||
throw new Error("EmptyReplayData");
|
||||
fileExplorerBusy = true;
|
||||
const ok = await importReplayFromPath(selected.path);
|
||||
fileExplorerBusy = false;
|
||||
if (ok) {
|
||||
fileExplorerOpen = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
replayFrames = frames;
|
||||
replayFileName = file.name;
|
||||
replayCurrentIndex = 0;
|
||||
replayHasDisplayedFrame = false;
|
||||
replayProgress = 0;
|
||||
resetReplayVisualState();
|
||||
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
|
||||
const targetDir = selected?.isDir ? selected.path : fileExplorerCurrentPath;
|
||||
const csvName = ensureCsvSuffix(fileExplorerFileName || buildDefaultExportName());
|
||||
if (!csvName) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionNotice =
|
||||
locale === "zh-CN"
|
||||
? `已导入 ${file.name},共 ${importedFrameCount} 帧 / ${importedChannelCount} 通道,可开始回放。`
|
||||
: `${file.name} loaded (${importedFrameCount} frames / ${importedChannelCount} channels). Ready to replay.`;
|
||||
connectionNoticeTone = "ok";
|
||||
} catch (error) {
|
||||
connectionNotice = resolveImportNotice(error);
|
||||
connectionNoticeTone = "warn";
|
||||
console.error("Replay import failed:", error);
|
||||
const targetPath = joinPath(targetDir, csvName);
|
||||
const ok = await runSerialExport(targetPath);
|
||||
if (ok) {
|
||||
fileExplorerOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1228,8 +1516,9 @@
|
||||
on:configlink={handleConfigLink}
|
||||
on:serialrefresh={handleSerialRefresh}
|
||||
on:serialconnect={handleSerialConnect}
|
||||
on:serialexport={handleSerialExport}
|
||||
on:csvimport={handleReplayImport}
|
||||
on:serialexport={handleSerialExportRequest}
|
||||
on:csvimport={handleReplayImportRequest}
|
||||
on:noticeclear={() => (connectionNotice = "")}
|
||||
/>
|
||||
|
||||
<CenterStage
|
||||
@@ -1288,6 +1577,33 @@
|
||||
</section>
|
||||
</CenterStage>
|
||||
</div>
|
||||
|
||||
<FileExplorerModal
|
||||
open={fileExplorerOpen}
|
||||
mode={fileExplorerMode}
|
||||
title={fileExplorerTitle}
|
||||
currentPath={fileExplorerCurrentPath}
|
||||
parentPath={fileExplorerParentPath}
|
||||
roots={fileExplorerRoots}
|
||||
entries={fileExplorerEntries}
|
||||
bind:selectedPath={fileExplorerSelectedPath}
|
||||
bind:fileName={fileExplorerFileName}
|
||||
pathLabel={uiCopy.fileExplorerPathLabel}
|
||||
fileNameLabel={uiCopy.fileExplorerNameLabel}
|
||||
cancelLabel={uiCopy.fileExplorerCancelLabel}
|
||||
confirmLabel={fileExplorerConfirmLabel}
|
||||
emptyHint={uiCopy.fileExplorerEmptyHint}
|
||||
csvHint={uiCopy.fileExplorerCsvHint}
|
||||
busyLabel={uiCopy.fileExplorerLoadingLabel}
|
||||
upLabel={uiCopy.fileExplorerUpLabel}
|
||||
nameColumnLabel={uiCopy.fileExplorerNameColumnLabel}
|
||||
sizeColumnLabel={uiCopy.fileExplorerSizeColumnLabel}
|
||||
modifiedColumnLabel={uiCopy.fileExplorerModifiedColumnLabel}
|
||||
isBusy={fileExplorerBusy}
|
||||
on:close={closeFileExplorer}
|
||||
on:navigate={handleFileExplorerNavigate}
|
||||
on:confirm={handleFileExplorerConfirm}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user