shuuufu před 1 dnem
rodič
revize
e9f7687ed3

+ 1 - 4
.gitignore

@@ -5,10 +5,7 @@ target/
 .kotlin
 
 ### IntelliJ IDEA ###
-.idea/modules.xml
-.idea/jarRepositories.xml
-.idea/compiler.xml
-.idea/libraries/
+.idea/*
 *.iws
 *.iml
 *.ipr

+ 33 - 0
scripts/build-mac.sh

@@ -21,6 +21,39 @@ MAIN_CLASS="com.fujica.parkingtool.App"
 OUT_DIR="dist/mac"
 RUNTIME_DIR="target/runtime-mac"
 
+# ---------------------------------------------------------------------------
+# 强制把当前会话锁定到 JDK 25,避免 Maven 走到系统默认的 JDK 8 上
+# 报错样例:Fatal error compiling: 无效的标记: --module-path(< JDK 9 的 javac)
+# ---------------------------------------------------------------------------
+detect_jdk25() {
+    # 1) Apple 的 java_home 工具
+    local home
+    if home=$(/usr/libexec/java_home -v 25 2>/dev/null) && [[ -n "${home}" ]]; then
+        echo "${home}"; return 0
+    fi
+    # 2) Fallback:扫 /Library/Java/JavaVirtualMachines/ 与 ~/Library/Java/JavaVirtualMachines/
+    local cand
+    for base in /Library/Java/JavaVirtualMachines "${HOME}/Library/Java/JavaVirtualMachines"; do
+        for cand in "${base}"/jdk-25*.jdk "${base}"/temurin-25*.jdk "${base}"/zulu-25*.jdk; do
+            if [[ -x "${cand}/Contents/Home/bin/javac" ]]; then
+                echo "${cand}/Contents/Home"; return 0
+            fi
+        done
+    done
+    return 1
+}
+
+if ! JAVA_HOME=$(detect_jdk25); then
+    echo "❌ 未找到 JDK 25,请先安装:" >&2
+    echo "     brew install --cask temurin@25" >&2
+    echo "   或从 https://adoptium.net/zh-CN/temurin/releases 下载 25 LTS。" >&2
+    exit 1
+fi
+export JAVA_HOME
+export PATH="${JAVA_HOME}/bin:${PATH}"
+echo "    使用 JDK:${JAVA_HOME}"
+javac -version
+
 require() {
     command -v "$1" >/dev/null 2>&1 || {
         echo "❌ 缺少命令:$1" >&2

+ 5 - 0
scripts/build-win.cmd

@@ -0,0 +1,5 @@
+@echo off
+REM Windows 平台打包入口:转调 PowerShell 脚本(绕过执行策略限制)
+setlocal
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-win.ps1"
+exit /b %ERRORLEVEL%

+ 239 - 0
scripts/build-win.ps1

@@ -0,0 +1,239 @@
+<#
+.SYNOPSIS
+  在 Windows 上把 Parking Simulator 打包成免安装的 app-image(含 zip)。
+
+.DESCRIPTION
+  产物路径:dist\win\ParkingSimulator\           (目录形式,双击 ParkingSimulator.exe 直接运行)
+            dist\win\ParkingSimulator-win-x64.zip(同上目录的 zip 压缩包,便于分发)
+
+  依赖:
+    - JDK 25(含 jlink / jpackage / javac / jar)
+    - Maven 3.9+
+
+  进阶:如果想生成 .exe 安装向导,请先安装 WiX Toolset 3.x,再放开文件末尾被注释的那段。
+
+.EXAMPLE
+  PS> .\build-win.ps1
+#>
+
+$ErrorActionPreference = "Stop"
+$OriginalLocation = Get-Location
+try {
+    Set-Location (Join-Path $PSScriptRoot "..")
+
+    $AppName    = "ParkingSimulator"
+    $AppVersion = "1.0.0"
+    $AppVendor  = "Fujica"
+    $MainModule = "com.fujica.parkingtool"
+    $MainClass  = "com.fujica.parkingtool.App"
+
+    $OutDir     = "dist\win"
+    $RuntimeDir = "target\runtime-win"
+
+    function Require-Cmd([string]$Name) {
+        if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
+            throw "缺少命令:$Name"
+        }
+    }
+
+    Require-Cmd mvn
+    Require-Cmd jlink
+    Require-Cmd jpackage
+    Require-Cmd javac
+    Require-Cmd jar
+
+    # 校验当前会话用的是 JDK 25,避免误用低版本 javac 编译失败
+    # 报错样例:Fatal error compiling: 无效的标记: --module-path
+    $JavacVer = (& javac -version 2>&1) | Out-String
+    if ($JavacVer -notmatch 'javac\s+25(\.|$)') {
+        throw @"
+当前 javac 不是 JDK 25(实际:$($JavacVer.Trim()))。
+请将 JAVA_HOME 指向 JDK 25 后再次运行:
+  `$env:JAVA_HOME = 'C:\Program Files\Eclipse Adoptium\jdk-25.x.x-hotspot'
+  `$env:Path = "`$env:JAVA_HOME\bin;`$env:Path"
+JDK 25 下载:https://adoptium.net/zh-CN/temurin/releases
+"@
+    }
+    Write-Host "    javac: $($JavacVer.Trim())"
+
+    Write-Host "==> [1/5] Maven 构建 + 拉取依赖到 target\modules"
+    & mvn -q -DskipTests clean package
+    if ($LASTEXITCODE -ne 0) { throw "Maven 构建失败" }
+
+    $ModulePath = "target\modules"
+
+    # 防御性清理:删掉 OpenJFX 偶尔会带过来的 < 1KB 空 stub jar
+    Get-ChildItem -Path $ModulePath -Filter "*.jar" |
+        Where-Object { $_.Length -lt 1024 } |
+        ForEach-Object {
+            Write-Host "    清理空 stub: $($_.Name)"
+            Remove-Item -Force $_.FullName
+        }
+
+    # ---------------------------------------------------------------------------
+    # 给 Paho v3(automatic module)补一份真正的 module-info,否则 jlink 拒绝它。
+    # 必须包含:
+    #   - uses     org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory
+    #   - provides ... TCP / SSL / WebSocket / WebSocketSecure 4 个工厂
+    # 否则运行期会抛 ServiceConfigurationError: module ... does not declare `uses`
+    # ---------------------------------------------------------------------------
+    Write-Host "==> [2/5] 给 Paho jar 注入 module-info"
+    $PahoJarFile = Get-ChildItem -Path $ModulePath -Filter "org.eclipse.paho.client.mqttv3-*.jar" |
+        Select-Object -First 1
+    if (-not $PahoJarFile) {
+        throw "找不到 Paho mqttv3 jar 在 $ModulePath\"
+    }
+    $PahoJar = $PahoJarFile.FullName  # 绝对路径,子目录内仍可引用
+
+    $PatchTmp = Join-Path $env:TEMP ("paho-modinfo-" + [System.IO.Path]::GetRandomFileName())
+    New-Item -ItemType Directory -Path $PatchTmp | Out-Null
+    try {
+        $ExpDir = Join-Path $PatchTmp "exploded"
+        New-Item -ItemType Directory -Path $ExpDir | Out-Null
+
+        Push-Location $ExpDir
+        & jar xf $PahoJar
+        $JarExtract = $LASTEXITCODE
+        Pop-Location
+        if ($JarExtract -ne 0) { throw "解压 Paho jar 失败" }
+
+        $ModuleInfo = @"
+module org.eclipse.paho.client.mqttv3 {
+    requires transitive java.logging;
+    requires transitive java.prefs;
+
+    exports org.eclipse.paho.client.mqttv3;
+    exports org.eclipse.paho.client.mqttv3.internal;
+    exports org.eclipse.paho.client.mqttv3.internal.security;
+    exports org.eclipse.paho.client.mqttv3.internal.websocket;
+    exports org.eclipse.paho.client.mqttv3.internal.wire;
+    exports org.eclipse.paho.client.mqttv3.logging;
+    exports org.eclipse.paho.client.mqttv3.persist;
+    exports org.eclipse.paho.client.mqttv3.spi;
+    exports org.eclipse.paho.client.mqttv3.util;
+
+    uses org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory;
+
+    provides org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory with
+        org.eclipse.paho.client.mqttv3.internal.TCPNetworkModuleFactory,
+        org.eclipse.paho.client.mqttv3.internal.SSLNetworkModuleFactory,
+        org.eclipse.paho.client.mqttv3.internal.websocket.WebSocketNetworkModuleFactory,
+        org.eclipse.paho.client.mqttv3.internal.websocket.WebSocketSecureNetworkModuleFactory;
+}
+"@
+        $ModuleInfoFile = Join-Path $PatchTmp "module-info.java"
+        Set-Content -Path $ModuleInfoFile -Value $ModuleInfo -Encoding ASCII
+
+        & javac --patch-module "org.eclipse.paho.client.mqttv3=$ExpDir" -d $PatchTmp $ModuleInfoFile
+        if ($LASTEXITCODE -ne 0) { throw "javac 生成 module-info.class 失败" }
+
+        Push-Location $PatchTmp
+        & jar uf $PahoJar "module-info.class"
+        $JarUpdate = $LASTEXITCODE
+        Pop-Location
+        if ($JarUpdate -ne 0) { throw "回写 Paho jar 失败" }
+
+        Write-Host ("    Paho 已注入 module-info → " + $PahoJarFile.Name)
+    } finally {
+        Remove-Item -Recurse -Force $PatchTmp -ErrorAction SilentlyContinue
+    }
+
+    # 自动发现需要进入 runtime 的模块集合
+    $RuntimeModules = @(
+        $MainModule,
+        "javafx.base", "javafx.graphics", "javafx.controls", "javafx.swing",
+        "org.eclipse.paho.client.mqttv3",
+        "jdk.localedata", "jdk.crypto.ec", "jdk.crypto.cryptoki"
+    ) -join ","
+
+    Write-Host "==> [3/5] jlink 生成最小运行时 → $RuntimeDir"
+    if (Test-Path $RuntimeDir) { Remove-Item -Recurse -Force $RuntimeDir }
+    & jlink `
+        --module-path "$env:JAVA_HOME\jmods;$ModulePath" `
+        --add-modules $RuntimeModules `
+        --no-header-files `
+        --no-man-pages `
+        --strip-debug `
+        --compress=zip-9 `
+        --ignore-signing-information `
+        --output $RuntimeDir
+    if ($LASTEXITCODE -ne 0) { throw "jlink 失败" }
+
+    Write-Host "==> [4/5] jpackage 生成 app-image → $OutDir"
+    if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir }
+    New-Item -ItemType Directory -Path $OutDir | Out-Null
+
+    # ---------------------------------------------------------------------------
+    # 准备 Windows 图标:
+    #   - 期望 src\main\resources\icon.ico(多分辨率:16 / 32 / 48 / 256)
+    #   - 如果只有 icon.png,建议先用 ImageMagick 等工具转换:
+    #       magick icon.png -define icon:auto-resize=256,128,64,48,32,16 icon.ico
+    # ---------------------------------------------------------------------------
+    $IconArgs = @()
+    if (Test-Path "src\main\resources\icon.ico") {
+        $IconArgs = @("--icon", "src\main\resources\icon.ico")
+        Write-Host "    使用图标:src\main\resources\icon.ico"
+    } elseif (Test-Path "src\main\resources\com\fujica\parkingtool\icon.ico") {
+        $IconArgs = @("--icon", "src\main\resources\com\fujica\parkingtool\icon.ico")
+        Write-Host "    使用图标:src\main\resources\com\fujica\parkingtool\icon.ico"
+    } elseif (Test-Path "src\main\resources\icon.png") {
+        Write-Host "    ⚠️ Windows 需要 .ico 格式,未找到 icon.ico,本次将使用默认图标"
+        Write-Host "       可用 ImageMagick 生成:magick icon.png -define icon:auto-resize=256,128,64,48,32,16 icon.ico"
+    }
+
+    $JpackageArgs = @(
+        "--type", "app-image",
+        "--name", $AppName,
+        "--app-version", $AppVersion,
+        "--vendor", $AppVendor,
+        "--module-path", $ModulePath,
+        "--module", "$MainModule/$MainClass",
+        "--runtime-image", $RuntimeDir,
+        "--dest", $OutDir,
+        "--java-options", "-Xms128m",
+        "--java-options", "-Xmx1024m",
+        "--java-options", "-Dprism.lcdtext=false",
+        "--java-options", "--enable-native-access=javafx.graphics"
+    ) + $IconArgs
+
+    & jpackage @JpackageArgs
+    if ($LASTEXITCODE -ne 0) { throw "jpackage 失败" }
+
+    Write-Host "==> [5/5] 压缩成 zip 便于分发"
+    $ZipName = "$AppName-win-x64.zip"
+    $ZipPath = Join-Path $OutDir $ZipName
+    if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath }
+    Compress-Archive -Path (Join-Path $OutDir $AppName) -DestinationPath $ZipPath -CompressionLevel Optimal
+
+    $AppDir  = Join-Path $OutDir $AppName
+    $AppExe  = Join-Path $AppDir "$AppName.exe"
+    $AppSize = "{0:N1} MB" -f ((Get-ChildItem -Recurse $AppDir | Measure-Object -Property Length -Sum).Sum / 1MB)
+    $ZipSize = "{0:N1} MB" -f ((Get-Item $ZipPath).Length / 1MB)
+
+    Write-Host ""
+    Write-Host "✅ 完成"
+    Write-Host "   app:   $AppDir  ($AppSize)"
+    Write-Host "   exe:   $AppExe"
+    Write-Host "   zip:   $ZipPath  ($ZipSize)"
+    Write-Host "   解压 zip 后双击 $AppName.exe 即可运行,无需安装 JDK。"
+
+    # === 可选:生成 .exe 安装向导(需要预装 WiX Toolset 3.x,并把 candle/light 加到 PATH) ===
+    # $InstallerArgs = @(
+    #     "--type", "exe",
+    #     "--name", $AppName,
+    #     "--app-version", $AppVersion,
+    #     "--vendor", $AppVendor,
+    #     "--module-path", $ModulePath,
+    #     "--module", "$MainModule/$MainClass",
+    #     "--runtime-image", $RuntimeDir,
+    #     "--dest", $OutDir,
+    #     "--win-dir-chooser",
+    #     "--win-shortcut",
+    #     "--win-menu",
+    #     "--win-menu-group", $AppVendor
+    # ) + $IconArgs
+    # & jpackage @InstallerArgs
+
+} finally {
+    Set-Location $OriginalLocation
+}

+ 10 - 3
src/main/java/com/fujica/parkingtool/App.java

@@ -35,16 +35,23 @@ public class App extends Application {
         }
 
         MainView root = new MainView();
-        Scene scene = new Scene(root, 1240, 820);
+        // 初始窗口尺寸:高度需要至少 ~900 才能完整放下所有卡片,
+        // 否则右栏 Telemetry 会把上方批量入场 / 通道卡片挤出可视区域。
+        Scene scene = new Scene(root, 1240, 920);
         scene.getStylesheets().add(
                 Objects.requireNonNull(
                         getClass().getResource("/com/fujica/parkingtool/styles/app.css"))
                         .toExternalForm());
 
         stage.setScene(scene);
-        stage.setTitle(APP_TITLE);
+        // 不显示系统标题栏中央的应用标题文字(左上角已有自绘 brand);
+        // EXTENDED 模式下,宿主 OS 仍然会在窗口顶部保留拖动 / 红黄绿按钮区域,
+        // 但视觉上会与下方 header-bar 融为一体("透明"效果)。
+        stage.setTitle("");
         stage.setMinWidth(1080);
-        stage.setMinHeight(720);
+        // 最小高度对齐 telemetry minHeight + 其它卡片合计高度 + chrome,
+        // 保证窗口拖到最小时所有内容仍然可见。
+        stage.setMinHeight(820);
         loadIcon(stage);
 
         // 关闭窗口时释放 MQTT 资源

+ 169 - 0
src/main/java/com/fujica/parkingtool/ui/JsonView.java

@@ -0,0 +1,169 @@
+package com.fujica.parkingtool.ui;
+
+import javafx.geometry.Insets;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.layout.VBox;
+import javafx.scene.text.Text;
+import javafx.scene.text.TextFlow;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 轻量级 JSON 着色查看器。
+ *
+ * <p>把整段 JSON 文本切分成多个 Text 片段,分别套上不同的 CSS class,
+ * 然后塞进可滚动的 TextFlow 里。无需 RichTextFX 等额外依赖。</p>
+ *
+ * <p>支持的 token:</p>
+ * <ul>
+ *   <li><b>json-key</b>     —— "..." 紧跟一个冒号</li>
+ *   <li><b>json-string</b>  —— 普通字符串字面量</li>
+ *   <li><b>json-number</b>  —— 数字(含小数 / 科学计数法)</li>
+ *   <li><b>json-bool</b>    —— true / false</li>
+ *   <li><b>json-null</b>    —— null</li>
+ *   <li><b>json-punct</b>   —— { } [ ] , :</li>
+ *   <li><b>json-default</b> —— 其它(空格 / 换行)</li>
+ * </ul>
+ */
+public class JsonView extends ScrollPane {
+
+    private final TextFlow flow = new TextFlow();
+    private String rawText = "";
+
+    public JsonView() {
+        setFitToWidth(true);
+        getStyleClass().add("json-view");
+
+        flow.getStyleClass().add("json-flow");
+        VBox wrapper = new VBox(flow);
+        wrapper.setPadding(new Insets(10, 12, 10, 12));
+        setContent(wrapper);
+    }
+
+    /** 设置 JSON 文本并重新着色。null/空 串会清空。 */
+    public void setJson(String json) {
+        rawText = json == null ? "" : json;
+        flow.getChildren().setAll(tokenize(rawText));
+        setVvalue(0); // 自动滚到顶部
+    }
+
+    /** 当前展示的原始文本(未着色)。供"复制"按钮使用。 */
+    public String getJson() { return rawText; }
+
+    public void clear() {
+        rawText = "";
+        flow.getChildren().clear();
+    }
+
+    /* ============================================================
+     *  极简 JSON tokenizer:只为着色,不做严格语法校验
+     * ============================================================ */
+    private static List<Text> tokenize(String src) {
+        List<Text> out = new ArrayList<>();
+        if (src == null || src.isEmpty()) return out;
+
+        int n = src.length();
+        int i = 0;
+        StringBuilder neutral = new StringBuilder();
+
+        while (i < n) {
+            char c = src.charAt(i);
+
+            if (c == '"') {
+                flushNeutral(out, neutral);
+                int start = i;
+                i++; // skip opening "
+                while (i < n) {
+                    char ch = src.charAt(i);
+                    if (ch == '\\' && i + 1 < n) { i += 2; continue; }
+                    if (ch == '"') { i++; break; } // closing "
+                    i++;
+                }
+                String literal = src.substring(start, i);
+
+                // 判断是否是 key:跳过 trailing whitespace 看是不是冒号
+                int j = i;
+                while (j < n && Character.isWhitespace(src.charAt(j))) j++;
+                boolean isKey = j < n && src.charAt(j) == ':';
+
+                Text t = new Text(literal);
+                t.getStyleClass().add(isKey ? "json-key" : "json-string");
+                out.add(t);
+
+            } else if (c == '-' || (c >= '0' && c <= '9')) {
+                flushNeutral(out, neutral);
+                int start = i;
+                if (c == '-') i++;
+                while (i < n) {
+                    char ch = src.charAt(i);
+                    if ((ch >= '0' && ch <= '9') || ch == '.'
+                            || ch == 'e' || ch == 'E' || ch == '+' || ch == '-') {
+                        i++;
+                    } else break;
+                }
+                Text t = new Text(src.substring(start, i));
+                t.getStyleClass().add("json-number");
+                out.add(t);
+
+            } else if (matchKeyword(src, i, "true")) {
+                flushNeutral(out, neutral);
+                addKeyword(out, "true", "json-bool");
+                i += 4;
+
+            } else if (matchKeyword(src, i, "false")) {
+                flushNeutral(out, neutral);
+                addKeyword(out, "false", "json-bool");
+                i += 5;
+
+            } else if (matchKeyword(src, i, "null")) {
+                flushNeutral(out, neutral);
+                addKeyword(out, "null", "json-null");
+                i += 4;
+
+            } else if (c == '{' || c == '}' || c == '[' || c == ']'
+                    || c == ':' || c == ',') {
+                flushNeutral(out, neutral);
+                Text t = new Text(String.valueOf(c));
+                t.getStyleClass().add("json-punct");
+                out.add(t);
+                i++;
+
+            } else {
+                neutral.append(c);
+                i++;
+            }
+        }
+        flushNeutral(out, neutral);
+        return out;
+    }
+
+    private static boolean matchKeyword(String src, int i, String kw) {
+        int n = src.length();
+        int len = kw.length();
+        if (i + len > n) return false;
+        for (int k = 0; k < len; k++) {
+            if (src.charAt(i + k) != kw.charAt(k)) return false;
+        }
+        // 词边界:后面不能紧跟字母
+        if (i + len < n) {
+            char next = src.charAt(i + len);
+            if (Character.isLetterOrDigit(next) || next == '_') return false;
+        }
+        return true;
+    }
+
+    private static void addKeyword(List<Text> out, String word, String cls) {
+        Text t = new Text(word);
+        t.getStyleClass().add(cls);
+        out.add(t);
+    }
+
+    private static void flushNeutral(List<Text> out, StringBuilder buf) {
+        if (buf.length() == 0) return;
+        Text t = new Text(buf.toString());
+        t.getStyleClass().add("json-default");
+        out.add(t);
+        buf.setLength(0);
+    }
+}

+ 87 - 57
src/main/java/com/fujica/parkingtool/ui/MainView.java

@@ -86,9 +86,6 @@ public class MainView extends BorderPane {
     /** 标记由代码触发的输入框文本同步,避免无限递归 */
     private boolean syncingPlateField = false;
 
-    private final CheckBox triggerGeo  = new CheckBox("地感");
-    private final CheckBox triggerGate = new CheckBox("道闸");
-
     private final Spinner<Integer> heartbeatSec = intSpinner(5, 600, 60, 80);
     private Button heartbeatBtn;
 
@@ -500,21 +497,19 @@ public class MainView extends BorderPane {
 
         right.getChildren().add(card("通道 · Channel", buildChannelSwitcher()));
 
-        // 单条触发 + 心跳合并到一张卡片
-        Region heartbeatDivider = new Region();
-        heartbeatDivider.getStyleClass().add("divider");
-        VBox singleCard = card("单条触发 · Single & Heartbeat",
-                buildSingleActions(),
-                heartbeatDivider,
-                buildHeartbeatActions());
+        // 单条触发:3 个大按钮 + [地感 / 心跳 / 道闸] 三等分行
+        VBox singleCard = card("单条触发 · Single & Heartbeat", buildSingleActions());
         right.getChildren().add(singleCard);
 
         right.getChildren().add(card("批量入场 · Batch", buildBatchActions()));
 
-        // 运行指标卡片(占满剩余空间,内含可滚动的最近发送列表)
+        // 运行指标卡片:占满剩余空间,内部最近发送 / 报文左右各占 1/2。
+        // minHeight 要保留一个底线,但不能太大——太大会把上方卡片挤出可视区。
+        // 实际显示高度由 vgrow 自动撑开,窗口越高它就越高。
         TelemetryPanel tp = new TelemetryPanel(telemetry);
         VBox.setVgrow(tp, Priority.ALWAYS);
         VBox telemetryCard = card("运行 · Telemetry", tp);
+        telemetryCard.setMinHeight(280);
         VBox.setVgrow(telemetryCard, Priority.ALWAYS);
         right.getChildren().add(telemetryCard);
         return right;
@@ -575,11 +570,6 @@ public class MainView extends BorderPane {
             ((Region) n).setMaxWidth(Double.MAX_VALUE);
         }
 
-        Button sendGeo  = primaryButton("发送");
-        Button sendGate = primaryButton("发送");
-        HBox geoRow  = trigRow(triggerGeo,  sendGeo);
-        HBox gateRow = trigRow(triggerGate, sendGate);
-
         btnRandom.setOnAction(e -> {
             String plate = sampleRandomPlate();
             sendIvsResult(plate);
@@ -596,17 +586,81 @@ public class MainView extends BorderPane {
                 return;
             }
             sendIvsResult(plate);
-            if (triggerGeo.isSelected())  sendGpioInPulse();
-            if (triggerGate.isSelected()) sendGateOpened();
         });
+
+        VBox v = new VBox(10, topRow, buildTriggersAndHeartbeatRow());
+        return v;
+    }
+
+    /**
+     * 三等分单行:[地感|发送] [心跳|间隔|启动] [道闸|发送]
+     * <ul>
+     *   <li>每个 cell 占 1/3 宽度,title 左对齐、按钮右对齐</li>
+     *   <li>地感 / 道闸 不再使用 CheckBox 联动,单独按钮直接触发对应消息</li>
+     *   <li>心跳标题去掉英文 "Heartbeat",更紧凑</li>
+     * </ul>
+     */
+    private Node buildTriggersAndHeartbeatRow() {
+        Button sendGeo = primaryButton("发送");
+        sendGeo.setMinWidth(64);
+        sendGeo.setPrefWidth(64);
         sendGeo.setOnAction(e -> sendGpioInPulse());
+
+        Button sendGate = primaryButton("发送");
+        sendGate.setMinWidth(64);
+        sendGate.setPrefWidth(64);
         sendGate.setOnAction(e -> sendGateOpened());
 
-        VBox v = new VBox(10, topRow, geoRow, gateRow);
-        return v;
+        heartbeatBtn = dangerButton("启动");
+        heartbeatBtn.setOnAction(e -> toggleHeartbeat());
+        heartbeatBtn.setMinWidth(72);
+        heartbeatBtn.setPrefWidth(72);
+        activeChannel.addListener((o, a, b) -> refreshHeartbeatBtn());
+
+        Label hbUnit = new Label("s");
+        hbUnit.getStyleClass().add("field-label");
+
+        HBox geoCell  = inlineCell(makeInlineTitle("地感"), sendGeo);
+        HBox gateCell = inlineCell(makeInlineTitle("道闸"), sendGate);
+        HBox hbCell   = inlineCell(makeInlineTitle("心跳"),
+                                   heartbeatSec, hbUnit, heartbeatBtn);
+
+        // 等宽:每格 1/3
+        for (HBox cell : new HBox[]{geoCell, gateCell, hbCell}) {
+            HBox.setHgrow(cell, Priority.ALWAYS);
+            cell.setMaxWidth(Double.MAX_VALUE);
+        }
+
+        // 顺序:地感(左) → 道闸(中) → 心跳(右)
+        HBox row = new HBox(10, geoCell, gateCell, hbCell);
+        row.setAlignment(Pos.CENTER_LEFT);
+        return row;
+    }
+
+    /**
+     * 一个 inline 单元:左侧标题 → spacer → 右侧 trailing 控件序列。
+     * 每个 cell 自带轻微背景 + 圆角边框({@code .inline-cell}),让三联行视觉上彼此分明。
+     */
+    private HBox inlineCell(Label title, Node... trailing) {
+        Region grow = new Region();
+        HBox.setHgrow(grow, Priority.ALWAYS);
+        HBox cell = new HBox(8);
+        cell.getStyleClass().add("inline-cell");
+        cell.getChildren().add(title);
+        cell.getChildren().add(grow);
+        for (Node n : trailing) cell.getChildren().add(n);
+        cell.setAlignment(Pos.CENTER_LEFT);
+        return cell;
+    }
+
+    private static Label makeInlineTitle(String text) {
+        Label l = new Label(text);
+        l.getStyleClass().add("inline-title");
+        return l;
     }
 
     private Node buildBatchActions() {
+        // 单行:[前缀 …] [数量 …] [间隔 … ms]   ⟶  [▶ 开始]
         Label l1 = new Label("数量");
         l1.getStyleClass().add("field-label");
         Label l2 = new Label("间隔");
@@ -616,43 +670,19 @@ public class MainView extends BorderPane {
         Label l4 = new Label("前缀");
         l4.getStyleClass().add("field-label");
 
-        batchPrefix.setPrefColumnCount(6);
+        batchPrefix.setPrefColumnCount(5);
 
-        HBox row1 = new HBox(8, l4, batchPrefix, l1, batchCount, l2, batchGapMs, l3);
-        row1.setAlignment(Pos.CENTER_LEFT);
-
-        batchBtn = primaryButton("▶ 开始批量入场");
-        batchBtn.setMaxWidth(Double.MAX_VALUE);
+        batchBtn = primaryButton("▶ 开始");
+        batchBtn.setMinWidth(96);
+        batchBtn.setPrefWidth(96);
         batchBtn.setOnAction(e -> toggleBatch());
 
-        VBox v = new VBox(10, row1, batchBtn);
-        return v;
-    }
-
-    private Node buildHeartbeatActions() {
-        Label tag = new Label("心跳 · Heartbeat");
-        tag.getStyleClass().add("subsection-title");
-
-        Label l1 = new Label("间隔");
-        l1.getStyleClass().add("field-label");
-        Label l2 = new Label("s");
-        l2.getStyleClass().add("field-label");
-
-        heartbeatBtn = dangerButton("启动");
-        heartbeatBtn.setOnAction(e -> toggleHeartbeat());
-        heartbeatBtn.setPrefWidth(86);
-
-        activeChannel.addListener((o, a, b) -> refreshHeartbeatBtn());
-
-        HBox row = new HBox(8, l1, heartbeatSec, l2, spacer(), heartbeatBtn);
-        row.setAlignment(Pos.CENTER_LEFT);
-
-        return new VBox(6, tag, row);
-    }
-
-    private HBox trigRow(CheckBox cb, Button send) {
-        send.setMinWidth(86);
-        HBox row = new HBox(10, cb, spacer(), send);
+        HBox row = new HBox(8,
+                l4, batchPrefix,
+                l1, batchCount,
+                l2, batchGapMs, l3,
+                spacer(),
+                batchBtn);
         row.setAlignment(Pos.CENTER_LEFT);
         return row;
     }
@@ -892,7 +922,7 @@ public class MainView extends BorderPane {
     private void toggleBatch() {
         if (batchSender.isRunning()) {
             batchSender.stop();
-            batchBtn.setText("▶ 开始批量入场");
+            batchBtn.setText("▶ 开始");
             return;
         }
         if (!requireConnected()) return;
@@ -902,13 +932,13 @@ public class MainView extends BorderPane {
         String prefix = batchPrefix.getText() == null ? "" : batchPrefix.getText().trim();
         int colorCode = currentPlateColorCode();
 
-        batchBtn.setText("■ 停止批量");
+        batchBtn.setText("■ 停止");
         batchSender.start(ch, total, gap,
                 () -> randomPlateForBatch(prefix),
                 colorCode,
                 DeviceMessageBuilder.TRIGGER_TYPE_NORMAL_PASS,
                 msg -> Platform.runLater(() -> console.log("[" + ch.key().displayName + "] " + msg)),
-                ok -> Platform.runLater(() -> batchBtn.setText("▶ 开始批量入场")));
+                ok -> Platform.runLater(() -> batchBtn.setText("▶ 开始")));
     }
 
     private int currentPlateColorCode() {

+ 48 - 28
src/main/java/com/fujica/parkingtool/ui/TelemetryPanel.java

@@ -8,7 +8,6 @@ import javafx.scene.control.Label;
 import javafx.scene.control.ListCell;
 import javafx.scene.control.ListView;
 import javafx.scene.control.SplitPane;
-import javafx.scene.control.TextArea;
 import javafx.scene.input.Clipboard;
 import javafx.scene.input.ClipboardContent;
 import javafx.scene.layout.HBox;
@@ -22,25 +21,27 @@ import javafx.scene.control.Button;
  * 右下角"运行 · Telemetry"面板。
  *
  * <pre>
- *   最近发送                            (可滚动 ListView,最多 500 条)
- *   ─────────────────────────────────
- *   报文 · Payload          [复制 JSON]
- *   {                                  (TextArea,readonly,等宽字体)
- *     ...
- *   }
+ *   ┌──────────────────┬──────────────────────────────┐
+ *   │ 最近发送           │ 报文 · Payload      [复制 JSON] │
+ *   │ ──────────────── │ ──────────────────────────── │
+ *   │ (可滚动 ListView,  │ {                              │
+ *   │  最多 500 条)      │   ...JsonView 着色             │
+ *   │                  │ }                              │
+ *   └──────────────────┴──────────────────────────────┘
  * </pre>
  *
- * 点击列表里任意一项,下方实时显示该报文的 pretty JSON。
+ * 点击列表里任意一项,右侧实时显示该报文的 pretty JSON。
  */
 public class TelemetryPanel extends VBox {
 
-    private final TextArea detail = new TextArea();
+    private final JsonView detail = new JsonView();
+    private final Label detailPlaceholder = new Label("点击上方任意条目查看 JSON 报文");
 
     public TelemetryPanel(Telemetry telemetry) {
         setSpacing(0);
         getStyleClass().add("telemetry-panel");
 
-        // ----- 上半:最近发送列表 -----
+        // ----- :最近发送列表 -----
         Label listTitle = new Label("最近发送");
         listTitle.getStyleClass().add("subsection-title");
 
@@ -50,18 +51,18 @@ public class TelemetryPanel extends VBox {
         list.setPlaceholder(placeholderLabel("等待发送…"));
         VBox.setVgrow(list, Priority.ALWAYS);
 
-        VBox topBox = new VBox(6, listTitle, list);
+        VBox leftBox = new VBox(6, listTitle, list);
         VBox.setVgrow(list, Priority.ALWAYS);
-        topBox.setPadding(new Insets(0, 0, 6, 0));
+        leftBox.setPadding(new Insets(0, 6, 0, 0));
 
-        // ----- 下半:JSON 详情 -----
+        // ----- :JSON 详情 -----
         Label detailTitle = new Label("报文 · Payload");
         detailTitle.getStyleClass().add("subsection-title");
 
         Button copyBtn = new Button("复制");
         copyBtn.getStyleClass().add("btn-pill");
         copyBtn.setOnAction(e -> {
-            String json = detail.getText();
+            String json = detail.getJson();
             if (json == null || json.isEmpty()) return;
             ClipboardContent c = new ClipboardContent();
             c.putString(json);
@@ -73,33 +74,36 @@ public class TelemetryPanel extends VBox {
         HBox detailHead = new HBox(detailTitle, sp, copyBtn);
         detailHead.setAlignment(Pos.CENTER_LEFT);
 
-        detail.setEditable(false);
-        detail.setWrapText(false);
-        detail.setPromptText("点击上方任意条目查看 JSON 报文");
-        detail.getStyleClass().add("telemetry-detail");
+        detailPlaceholder.getStyleClass().add("telemetry-placeholder");
+        detailPlaceholder.setPadding(new Insets(8, 12, 8, 12));
+
+        detail.setVisible(false);
+        detail.setManaged(false);
+        VBox.setVgrow(detail, Priority.ALWAYS);
+
+        VBox detailHolder = new VBox(detailPlaceholder, detail);
         VBox.setVgrow(detail, Priority.ALWAYS);
+        VBox.setVgrow(detailHolder, Priority.ALWAYS);
 
-        VBox bottomBox = new VBox(6, detailHead, detail);
-        bottomBox.setPadding(new Insets(6, 0, 0, 0));
+        VBox rightBox = new VBox(6, detailHead, detailHolder);
+        rightBox.setPadding(new Insets(0, 0, 0, 6));
 
         list.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> {
-            if (newV == null) { detail.clear(); return; }
-            detail.setText(newV.json() == null ? "" : newV.json());
-            detail.positionCaret(0);
+            if (newV == null) { showJson(null); return; }
+            showJson(newV.json());
         });
 
         // 列表更新自动跟随首条(如果用户没选)
         telemetry.entries().addListener((javafx.collections.ListChangeListener<Telemetry.Entry>) c -> {
             if (list.getSelectionModel().getSelectedItem() == null
                     && !telemetry.entries().isEmpty()) {
-                detail.setText(telemetry.entries().get(0).json());
-                detail.positionCaret(0);
+                showJson(telemetry.entries().get(0).json());
             }
         });
 
-        // ----- 上下分栏:可拖动 -----
-        SplitPane split = new SplitPane(topBox, bottomBox);
-        split.setOrientation(Orientation.VERTICAL);
+        // ----- 左右分栏:可拖动 -----
+        SplitPane split = new SplitPane(leftBox, rightBox);
+        split.setOrientation(Orientation.HORIZONTAL);
         split.setDividerPositions(0.5);
         split.getStyleClass().add("telemetry-split");
         VBox.setVgrow(split, Priority.ALWAYS);
@@ -107,6 +111,22 @@ public class TelemetryPanel extends VBox {
         getChildren().add(split);
     }
 
+    private void showJson(String json) {
+        if (json == null || json.isEmpty()) {
+            detail.clear();
+            detail.setVisible(false);
+            detail.setManaged(false);
+            detailPlaceholder.setVisible(true);
+            detailPlaceholder.setManaged(true);
+            return;
+        }
+        detail.setJson(json);
+        detail.setVisible(true);
+        detail.setManaged(true);
+        detailPlaceholder.setVisible(false);
+        detailPlaceholder.setManaged(false);
+    }
+
     private static Label placeholderLabel(String text) {
         Label l = new Label(text);
         l.getStyleClass().add("telemetry-placeholder");

+ 56 - 6
src/main/resources/com/fujica/parkingtool/styles/app.css

@@ -36,11 +36,11 @@
 
 /* ============ 顶栏(HeaderBar 替代系统标题栏) ============ */
 .header-bar {
-    /* 让顶栏与主背景融为一体,仅保留底边的极细分割线 */
-    -fx-background-color:
-        linear-gradient(to bottom, rgba(255,255,255,0.02), rgba(255,255,255,0));
-    -fx-border-color: -app-panel-bd;
-    -fx-border-width: 0 0 1 0;
+    /* 顶栏完全透明,让 .main-view 的渐变直接延展到窗口最顶部,
+       与 macOS 的 EXTENDED 标题栏 chrome 视觉融合,避免出现独立色块。 */
+    -fx-background-color: transparent;
+    -fx-border-color: transparent;
+    -fx-border-width: 0;
     /* HeaderBar 自身会保留系统按钮(macOS 红黄绿)的安全区域,
        这里只做视觉留白 */
     -fx-padding: 8 14 8 14;
@@ -132,6 +132,32 @@
     -fx-padding: 4 0 0 0;
 }
 
+/* 心跳行的内联标题(与启动按钮同行,需要更突出) */
+.heartbeat-title {
+    -fx-text-fill: -app-text;
+    -fx-font-size: 13px;
+    -fx-font-weight: 800;
+    -fx-letter-spacing: 0.06em;
+}
+
+/* [地感 / 道闸 / 心跳] 三等分行的小标题 —— 突出,左对齐 */
+.inline-title {
+    -fx-text-fill: -app-text;
+    -fx-font-size: 13px;
+    -fx-font-weight: 800;
+    -fx-letter-spacing: 0.08em;
+}
+
+/* [地感 / 道闸 / 心跳] 每个 cell 卡片化,避免视觉糊在一起 */
+.inline-cell {
+    -fx-background-color: rgba(255,255,255,0.035);
+    -fx-background-radius: 10;
+    -fx-border-color: rgba(255,255,255,0.08);
+    -fx-border-radius: 10;
+    -fx-border-width: 1;
+    -fx-padding: 8 10 8 12;
+}
+
 .divider {
     -fx-pref-height: 1;
     -fx-min-height: 1;
@@ -503,7 +529,31 @@
     -fx-pref-height: 1;
 }
 
-/* JSON 详情 TextArea */
+/* JSON 详情:JsonView (ScrollPane + TextFlow,自带 token 着色) */
+.json-view {
+    -fx-background-color: rgba(0,0,0,0.22);
+    -fx-background-radius: 8;
+    -fx-border-color: rgba(255,255,255,0.08);
+    -fx-border-radius: 8;
+    -fx-padding: 0;
+}
+.json-view > .viewport {
+    -fx-background-color: transparent;
+}
+.json-flow {
+    -fx-font-family: "JetBrains Mono", "SF Mono", "Menlo", "Consolas", monospace;
+    -fx-font-size: 12px;
+}
+.json-flow .text { -fx-fill: #d4d4d4; }
+.json-flow .json-key     { -fx-fill: #9cdcfe; }   /* 键 - 蓝 */
+.json-flow .json-string  { -fx-fill: #ce9178; }   /* 字符串 - 橙红 */
+.json-flow .json-number  { -fx-fill: #b5cea8; }   /* 数字 - 浅绿 */
+.json-flow .json-bool    { -fx-fill: #569cd6; }   /* 布尔 - 蓝 */
+.json-flow .json-null    { -fx-fill: #c586c0; }   /* null - 紫 */
+.json-flow .json-punct   { -fx-fill: #d4d4d4; }   /* { } [ ] , : - 中性 */
+.json-flow .json-default { -fx-fill: #d4d4d4; }
+
+/* 旧 TextArea 样式(保留以兼容潜在引用,不再被使用) */
 .telemetry-detail {
     -fx-control-inner-background: rgba(0,0,0,0.18);
     -fx-text-fill: -app-text;