shuuufu 1 일 전
커밋
6640bf03ad
33개의 변경된 파일6876개의 추가작업 그리고 0개의 파일을 삭제
  1. 39 0
      .gitignore
  2. 5 0
      .idea/.gitignore
  3. 6 0
      .idea/CoolRequestSetting.xml
  4. 7 0
      .idea/encodings.xml
  5. 14 0
      .idea/misc.xml
  6. 73 0
      README.md
  7. 1520 0
      mobile_plate_generator.html
  8. 206 0
      pom.xml
  9. 199 0
      scripts/build-mac.sh
  10. 89 0
      scripts/build-windows.bat
  11. 69 0
      src/main/java/com/fujica/parkingtool/App.java
  12. 114 0
      src/main/java/com/fujica/parkingtool/mqtt/BatchSender.java
  13. 43 0
      src/main/java/com/fujica/parkingtool/mqtt/ChannelKey.java
  14. 191 0
      src/main/java/com/fujica/parkingtool/mqtt/DeviceChannel.java
  15. 328 0
      src/main/java/com/fujica/parkingtool/mqtt/DeviceMessageBuilder.java
  16. 181 0
      src/main/java/com/fujica/parkingtool/mqtt/DeviceMqttClient.java
  17. 81 0
      src/main/java/com/fujica/parkingtool/mqtt/HeartbeatScheduler.java
  18. 199 0
      src/main/java/com/fujica/parkingtool/mqtt/MqttSettings.java
  19. 141 0
      src/main/java/com/fujica/parkingtool/oss/OssSettings.java
  20. 169 0
      src/main/java/com/fujica/parkingtool/oss/OssUploader.java
  21. 158 0
      src/main/java/com/fujica/parkingtool/oss/PlateImageService.java
  22. 143 0
      src/main/java/com/fujica/parkingtool/ui/ConsoleView.java
  23. 1101 0
      src/main/java/com/fujica/parkingtool/ui/MainView.java
  24. 274 0
      src/main/java/com/fujica/parkingtool/ui/MqttSettingsDialog.java
  25. 90 0
      src/main/java/com/fujica/parkingtool/ui/PlateColor.java
  26. 277 0
      src/main/java/com/fujica/parkingtool/ui/PlateGenerator.java
  27. 202 0
      src/main/java/com/fujica/parkingtool/ui/PlatePreview.java
  28. 79 0
      src/main/java/com/fujica/parkingtool/ui/Telemetry.java
  29. 160 0
      src/main/java/com/fujica/parkingtool/ui/TelemetryPanel.java
  30. 105 0
      src/main/java/com/fujica/parkingtool/ui/ToggleSwitch.java
  31. 26 0
      src/main/java/module-info.java
  32. 587 0
      src/main/resources/com/fujica/parkingtool/styles/app.css
  33. BIN
      src/main/resources/icon.png

+ 39 - 0
.gitignore

@@ -0,0 +1,39 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+.kotlin
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store

+ 5 - 0
.idea/.gitignore

@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/

+ 6 - 0
.idea/CoolRequestSetting.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CoolRequestSetting">
+    <option name="projectCachePath" value="project-24d4b86a-2014-4e63-96f6-d1dd49856129" />
+  </component>
+</project>

+ 7 - 0
.idea/encodings.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Encoding">
+    <file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
+  </component>
+</project>

+ 14 - 0
.idea/misc.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ExternalStorageConfigurationManager" enabled="true" />
+  <component name="MavenProjectsManager">
+    <option name="originalFiles">
+      <list>
+        <option value="$PROJECT_DIR$/pom.xml" />
+      </list>
+    </option>
+  </component>
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="25" project-jdk-type="JavaSDK">
+    <output url="file://$PROJECT_DIR$/out" />
+  </component>
+</project>

+ 73 - 0
README.md

@@ -0,0 +1,73 @@
+# Parking Simulator
+
+JDK 25 + JavaFX 25 + AtlantaFX 写的本地停车模拟工具 GUI。
+
+- 纯本地、单进程、无外部服务
+- 暗色玻璃风界面(在 AtlantaFX `PrimerDark` 之上叠加自定义 CSS)
+- 跨平台打包:`jlink` + `jpackage`,最终产物**自带运行时**,用户无需安装 JDK
+- 单进程内存目标:默认 `-Xmx256m -XX:+UseSerialGC`,启动后 RSS ≈ 150 MB 左右
+
+## 环境
+
+- JDK **25**(要包含 `jlink` 和 `jpackage`)
+- Maven **3.9+**
+- 打包到 Windows 必须在 **Windows 机器**上执行(`jpackage` 不支持交叉打包)
+- 打包到 macOS 必须在 **macOS 机器**上执行(同上)
+
+## 开发期一键运行
+
+```bash
+mvn javafx:run
+```
+
+## 打包
+
+### macOS(在 macOS 上执行)
+
+```bash
+./scripts/build-mac.sh
+```
+
+产物:`dist/mac/ParkingSimulator.app`(双击运行,免装 JDK)。
+如需 `.dmg` 镜像,取消 `scripts/build-mac.sh` 末尾的注释块。
+
+### Windows(在 Windows 上执行)
+
+```bat
+scripts\build-windows.bat
+```
+
+产物:`dist\win\ParkingSimulator\ParkingSimulator.exe`(整个文件夹拷贝到任意 Windows 机器上双击运行,免装 JDK)。
+如需 `.msi` 安装包,先安装 WiX 3.x,然后取消 `scripts\build-windows.bat` 末尾的注释块。
+
+## 体积说明
+
+- `jlink` 只把真正用到的 JDK 模块裁进运行时
+- `--strip-debug --no-header-files --no-man-pages --strip-native-commands --compress=zip-9` 进一步瘦身
+- 典型产物(含自带 JDK 运行时 + JavaFX):
+  - macOS `.app`:约 **70 ~ 95 MB**
+  - Windows app-image:约 **70 ~ 95 MB**
+
+## 目录结构
+
+```
+src/main/java/com/fujica/parkingtool/
+  App.java                  入口
+  ui/MainView.java          主界面
+  ui/PlatePreview.java      车牌预览组件
+  ui/PlateColor.java        车牌配色枚举
+  ui/ConsoleView.java       控制台输出区域
+src/main/resources/com/fujica/parkingtool/
+  styles/app.css            自定义 CSS
+scripts/                    打包脚本
+dist/                       打包产物
+```
+
+## 后续接入业务
+
+`MainView` 中的按钮回调目前只写日志到控制台占位,业务接入只需替换 `setOnAction` 中的内容,比如:
+- `随机来一个` → 调用真实随机生成器并通过 MQTT/HTTP 发送
+- `模拟触发` → 拼装上行 JSON 并发到对应 topic
+- 心跳 `停止`/启动 → 控制后台心跳线程
+
+`ConsoleView#log` 是线程安全的,可以从任意线程调用。

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1520 - 0
mobile_plate_generator.html


+ 206 - 0
pom.xml

@@ -0,0 +1,206 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.fujica</groupId>
+    <artifactId>parking_tool</artifactId>
+    <version>1.0.0</version>
+    <packaging>jar</packaging>
+    <name>ParkingSimulator</name>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <maven.compiler.release>25</maven.compiler.release>
+
+        <javafx.version>25</javafx.version>
+        <atlantafx.version>2.1.0</atlantafx.version>
+
+        <!-- 当前构建平台 classifier(由下面 profile 自动设置) -->
+        <javafx.platform>mac</javafx.platform>
+
+        <!-- 入口模块/主类 -->
+        <app.module>com.fujica.parkingtool</app.module>
+        <app.mainClass>com.fujica.parkingtool.App</app.mainClass>
+    </properties>
+
+    <!-- 根据当前构建机器自动决定 JavaFX native classifier -->
+    <profiles>
+        <profile>
+            <id>mac-aarch64</id>
+            <activation>
+                <os><family>mac</family><arch>aarch64</arch></os>
+            </activation>
+            <properties>
+                <javafx.platform>mac-aarch64</javafx.platform>
+            </properties>
+        </profile>
+        <profile>
+            <id>mac-x86_64</id>
+            <activation>
+                <os><family>mac</family><arch>x86_64</arch></os>
+            </activation>
+            <properties>
+                <javafx.platform>mac</javafx.platform>
+            </properties>
+        </profile>
+        <profile>
+            <id>win-x86_64</id>
+            <activation>
+                <os><family>windows</family><arch>amd64</arch></os>
+            </activation>
+            <properties>
+                <javafx.platform>win</javafx.platform>
+            </properties>
+        </profile>
+        <profile>
+            <id>win-aarch64</id>
+            <activation>
+                <os><family>windows</family><arch>aarch64</arch></os>
+            </activation>
+            <properties>
+                <javafx.platform>win-aarch64</javafx.platform>
+            </properties>
+        </profile>
+        <profile>
+            <id>linux-x86_64</id>
+            <activation>
+                <os><family>unix</family><name>linux</name><arch>amd64</arch></os>
+            </activation>
+            <properties>
+                <javafx.platform>linux</javafx.platform>
+            </properties>
+        </profile>
+        <profile>
+            <id>linux-aarch64</id>
+            <activation>
+                <os><family>unix</family><name>linux</name><arch>aarch64</arch></os>
+            </activation>
+            <properties>
+                <javafx.platform>linux-aarch64</javafx.platform>
+            </properties>
+        </profile>
+    </profiles>
+
+    <dependencies>
+        <!-- 仅声明 controls(自动传递 base / graphics)。
+             显式带 classifier,确保 dependency-plugin 把当前平台的 native 包拷到 target/modules -->
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-controls</artifactId>
+            <version>${javafx.version}</version>
+            <classifier>${javafx.platform}</classifier>
+        </dependency>
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-graphics</artifactId>
+            <version>${javafx.version}</version>
+            <classifier>${javafx.platform}</classifier>
+        </dependency>
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-base</artifactId>
+            <version>${javafx.version}</version>
+            <classifier>${javafx.platform}</classifier>
+        </dependency>
+        <!-- javafx-swing 用于把 JavaFX 截图 (WritableImage) 转 BufferedImage 后写 PNG -->
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-swing</artifactId>
+            <version>${javafx.version}</version>
+            <classifier>${javafx.platform}</classifier>
+        </dependency>
+
+        <dependency>
+            <groupId>io.github.mkpaz</groupId>
+            <artifactId>atlantafx-base</artifactId>
+            <version>${atlantafx.version}</version>
+        </dependency>
+
+        <!-- MQTT Paho 客户端,与 devicedriveservice 保持一致 (Eclipse Paho v3) -->
+        <dependency>
+            <groupId>org.eclipse.paho</groupId>
+            <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
+            <version>1.2.5</version>
+        </dependency>
+
+        <!-- Jackson 用于构造和序列化 MQTT 上行消息体 -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <version>2.18.2</version>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-core</artifactId>
+            <version>2.18.2</version>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-annotations</artifactId>
+            <version>2.18.2</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <finalName>${project.artifactId}</finalName>
+        <plugins>
+            <!-- 编译为 JDK 25 字节码 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.13.0</version>
+                <configuration>
+                    <release>25</release>
+                </configuration>
+            </plugin>
+
+            <!-- 开发期一键运行:mvn javafx:run -->
+            <plugin>
+                <groupId>org.openjfx</groupId>
+                <artifactId>javafx-maven-plugin</artifactId>
+                <version>0.0.8</version>
+                <configuration>
+                    <mainClass>${app.module}/${app.mainClass}</mainClass>
+                    <launcher>parking</launcher>
+                </configuration>
+            </plugin>
+
+            <!-- 把所有运行期模块(含 JavaFX 当前平台原生库 + AtlantaFX)拷到 target/modules
+                 jpackage 会基于该 module-path 自动 jlink 出最小运行时 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <version>3.8.1</version>
+                <executions>
+                    <execution>
+                        <id>copy-modules</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>copy-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            <outputDirectory>${project.build.directory}/modules</outputDirectory>
+                            <includeScope>runtime</includeScope>
+                            <!-- 只拷直接声明的依赖:避免 OpenJFX 的空 stub jar 与
+                                 AtlantaFX 传递的旧版 javafx-controls 混入 module-path -->
+                            <excludeTransitive>true</excludeTransitive>
+                            <excludeClassifiers>sources,javadoc</excludeClassifiers>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
+            <!-- 同步把自己的 jar 也拷到 target/modules,方便 scripts 中的 jpackage 引用 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-jar-plugin</artifactId>
+                <version>3.4.1</version>
+                <configuration>
+                    <outputDirectory>${project.build.directory}/modules</outputDirectory>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 199 - 0
scripts/build-mac.sh

@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+#
+# 在 macOS 上把 Parking Simulator 打包成可双击运行的 .app(免装 JDK)。
+# 产物路径:dist/mac/ParkingSimulator.app
+#
+# 依赖:
+#   - JDK 25(含 jlink / jpackage)
+#   - Maven 3.9+
+#
+# 进阶:取消最后一段注释可一并生成 .dmg 安装包。
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+APP_NAME="ParkingSimulator"
+APP_VERSION="1.0.0"
+APP_VENDOR="Fujica"
+MAIN_MODULE="com.fujica.parkingtool"
+MAIN_CLASS="com.fujica.parkingtool.App"
+
+OUT_DIR="dist/mac"
+RUNTIME_DIR="target/runtime-mac"
+
+require() {
+    command -v "$1" >/dev/null 2>&1 || {
+        echo "❌ 缺少命令:$1" >&2
+        exit 1
+    }
+}
+
+require mvn
+require jlink
+require jpackage
+require javac
+require jar
+
+echo "==> [1/5] Maven 构建 + 拉取依赖到 target/modules"
+mvn -q -DskipTests clean package
+
+MODULE_PATH="target/modules"
+
+# 防御性清理:删掉 OpenJFX 偶尔会带过来的 < 1KB 空 stub jar,避免污染 module-path
+find "${MODULE_PATH}" -name '*.jar' -size -1k -print -delete || true
+
+# ---------------------------------------------------------------------------
+# 给 Paho v3(automatic module)补一份真正的 module-info,否则 jlink 拒绝它。
+# 关键点:必须包含
+#   - uses    org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory
+#   - provides ...with TCP / SSL / WebSocket / WebSocketSecure 4 个工厂
+# 否则运行期会抛 ServiceConfigurationError: module ... does not declare `uses`
+# ---------------------------------------------------------------------------
+echo "==> [2/5] 给 Paho jar 注入 module-info"
+PAHO_JAR=$(ls "${MODULE_PATH}"/org.eclipse.paho.client.mqttv3-*.jar 2>/dev/null | head -n 1)
+if [[ -z "${PAHO_JAR}" || ! -f "${PAHO_JAR}" ]]; then
+    echo "❌ 找不到 Paho mqttv3 jar 在 ${MODULE_PATH}/" >&2
+    exit 1
+fi
+# 子 shell cd 之后还要能引用,转绝对路径
+PAHO_JAR=$(cd "$(dirname "${PAHO_JAR}")" && pwd)/$(basename "${PAHO_JAR}")
+
+PATCH_TMP=$(mktemp -d -t paho-modinfo.XXXXXX)
+trap 'rm -rf "${PATCH_TMP}"' EXIT
+EXP_DIR="${PATCH_TMP}/exploded"
+mkdir -p "${EXP_DIR}"
+( cd "${EXP_DIR}" && jar xf "${PAHO_JAR}" )
+
+cat > "${PATCH_TMP}/module-info.java" <<'PAHO_MOD_INFO'
+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;
+}
+PAHO_MOD_INFO
+
+# 用解压后的目录做 patch,避免 javac 在 jar 上看不到内嵌包
+javac --patch-module org.eclipse.paho.client.mqttv3="${EXP_DIR}" \
+      -d "${PATCH_TMP}" "${PATCH_TMP}/module-info.java"
+# 注入进 jar;如有旧的同名条目则覆盖
+( cd "${PATCH_TMP}" && jar uf "${PAHO_JAR}" module-info.class )
+echo "    Paho 已注入 module-info → $(basename "${PAHO_JAR}")"
+
+# 自动发现需要进入 runtime 的模块集合
+RUNTIME_MODULES="${MAIN_MODULE},javafx.base,javafx.graphics,javafx.controls,javafx.swing,org.eclipse.paho.client.mqttv3,jdk.localedata,jdk.crypto.ec,jdk.crypto.cryptoki"
+
+echo "==> [3/5] jlink 生成最小运行时 → ${RUNTIME_DIR}"
+rm -rf "${RUNTIME_DIR}"
+jlink \
+    --module-path "${JAVA_HOME}/jmods:${MODULE_PATH}" \
+    --add-modules "${RUNTIME_MODULES}" \
+    --no-header-files \
+    --no-man-pages \
+    --strip-debug \
+    --compress=zip-9 \
+    --ignore-signing-information \
+    --output "${RUNTIME_DIR}"
+
+echo "==> [4/5] jpackage 生成 .app → ${OUT_DIR}"
+rm -rf "${OUT_DIR}"
+mkdir -p "${OUT_DIR}"
+
+# ---------------------------------------------------------------------------
+# 自动准备 Dock 图标:
+#   1) 优先使用 src/main/resources/icon.icns(如果用户已自备多分辨率图标)
+#   2) 否则从 src/main/resources/icon.png 现转:
+#      - 用 sips 生成 16 / 32 / 64 / 128 / 256 / 512 / 1024 + @2x 共 10 张
+#      - 用 iconutil 打包成 icon.icns
+#      建议源图 ≥ 1024×1024;尺寸过小会被强行放大、Dock 上糊
+# ---------------------------------------------------------------------------
+ICON_ARG=()
+if [[ -f "src/main/resources/icon.icns" ]]; then
+    ICON_ARG+=(--icon "src/main/resources/icon.icns")
+    echo "    使用图标:src/main/resources/icon.icns"
+elif [[ -f "src/main/resources/com/fujica/parkingtool/icon.icns" ]]; then
+    ICON_ARG+=(--icon "src/main/resources/com/fujica/parkingtool/icon.icns")
+    echo "    使用图标:src/main/resources/com/fujica/parkingtool/icon.icns"
+elif [[ -f "src/main/resources/icon.png" ]]; then
+    SRC_PNG="src/main/resources/icon.png"
+    ICONSET="target/icon.iconset"
+    OUT_ICNS="target/icon.icns"
+    rm -rf "${ICONSET}"
+    mkdir -p "${ICONSET}"
+    # 注意:尺寸 = base × scale;命名规则参考 iconutil 文档
+    declare -a SIZES=(16 32 32 64 128 256 256 512 512 1024)
+    declare -a NAMES=(
+        "icon_16x16.png"     "icon_16x16@2x.png"
+        "icon_32x32.png"     "icon_32x32@2x.png"
+        "icon_128x128.png"   "icon_128x128@2x.png"
+        "icon_256x256.png"   "icon_256x256@2x.png"
+        "icon_512x512.png"   "icon_512x512@2x.png"
+    )
+    for i in "${!SIZES[@]}"; do
+        sips -z "${SIZES[$i]}" "${SIZES[$i]}" "${SRC_PNG}" --out "${ICONSET}/${NAMES[$i]}" >/dev/null
+    done
+    rm -f "${OUT_ICNS}"
+    iconutil --convert icns --output "${OUT_ICNS}" "${ICONSET}"
+    ICON_ARG+=(--icon "${OUT_ICNS}")
+    echo "    已从 ${SRC_PNG} 自动生成 → ${OUT_ICNS}"
+    SRC_W=$(sips -g pixelWidth "${SRC_PNG}" | awk '/pixelWidth/ {print $2}')
+    if [[ -n "${SRC_W}" && "${SRC_W}" -lt 512 ]]; then
+        echo "    ⚠️ 源图仅 ${SRC_W}px,Dock 会被放大显示,建议提供 ≥1024×1024 的 icon.png"
+    fi
+fi
+
+jpackage \
+    --type app-image \
+    --name "${APP_NAME}" \
+    --app-version "${APP_VERSION}" \
+    --vendor "${APP_VENDOR}" \
+    --module-path "${MODULE_PATH}" \
+    --module "${MAIN_MODULE}/${MAIN_CLASS}" \
+    --runtime-image "${RUNTIME_DIR}" \
+    --dest "${OUT_DIR}" \
+    --java-options "-Xms128m" \
+    --java-options "-Xmx1024m" \
+    --java-options "-Dprism.lcdtext=false" \
+    --java-options "--enable-native-access=javafx.graphics" \
+    ${ICON_ARG[@]+"${ICON_ARG[@]}"}
+
+echo "==> [5/5] 压缩成 zip 便于分发"
+ARCH=$(uname -m)
+ZIP_NAME="${APP_NAME}-mac-${ARCH}.zip"
+( cd "${OUT_DIR}" && rm -f "${ZIP_NAME}" && ditto -ck --rsrc --keepParent "${APP_NAME}.app" "${ZIP_NAME}" )
+
+echo ""
+echo "✅ 完成"
+du -sh "${OUT_DIR}/${APP_NAME}.app" || true
+du -sh "${OUT_DIR}/${ZIP_NAME}" || true
+echo "   .app:  ${OUT_DIR}/${APP_NAME}.app"
+echo "   .zip:  ${OUT_DIR}/${ZIP_NAME}"
+echo "   双击 .app 运行即可,无需安装 JDK。"
+
+# === 可选:生成 .dmg 安装镜像 ===
+# jpackage \
+#     --type dmg \
+#     --name "${APP_NAME}" \
+#     --app-version "${APP_VERSION}" \
+#     --vendor "${APP_VENDOR}" \
+#     --module-path "${MODULE_PATH}" \
+#     --module "${MAIN_MODULE}/${MAIN_CLASS}" \
+#     --runtime-image "${RUNTIME_DIR}" \
+#     --dest "${OUT_DIR}" \
+#     "${ICON_ARG[@]}"

+ 89 - 0
scripts/build-windows.bat

@@ -0,0 +1,89 @@
+@echo off
+REM ============================================================
+REM  在 Windows 上把 Parking Simulator 打包成绿色免安装目录。
+REM  产物:dist\win\ParkingSimulator\ParkingSimulator.exe
+REM
+REM  依赖:
+REM    - JDK 25 (含 jlink / jpackage)
+REM    - Maven 3.9+
+REM
+REM  jpackage --type app-image 不需要 WiX;如需 .msi/.exe 安装包
+REM  把脚本末尾的注释段取消即可(需要先安装 WiX 3.x)。
+REM ============================================================
+
+setlocal EnableExtensions EnableDelayedExpansion
+cd /d "%~dp0\.."
+
+set APP_NAME=ParkingSimulator
+set APP_VERSION=1.0.0
+set APP_VENDOR=Fujica
+set MAIN_MODULE=com.fujica.parkingtool
+set MAIN_CLASS=com.fujica.parkingtool.App
+
+set OUT_DIR=dist\win
+set RUNTIME_DIR=target\runtime-win
+set MODULE_PATH=target\modules
+
+set RUNTIME_MODULES=%MAIN_MODULE%,javafx.base,javafx.graphics,javafx.controls,jdk.localedata,jdk.crypto.ec,jdk.crypto.cryptoki
+
+where mvn      >nul 2>nul || (echo [ERR] 找不到 mvn         & exit /b 1)
+where jlink    >nul 2>nul || (echo [ERR] 找不到 jlink       & exit /b 1)
+where jpackage >nul 2>nul || (echo [ERR] 找不到 jpackage    & exit /b 1)
+
+echo ==^> [1/4] Maven 构建 + 拉取依赖到 target\modules
+call mvn -q -DskipTests clean package || exit /b 1
+
+echo ==^> [2/4] jlink 生成最小运行时 -> %RUNTIME_DIR%
+if exist "%RUNTIME_DIR%" rmdir /s /q "%RUNTIME_DIR%"
+jlink ^
+    --module-path "%JAVA_HOME%\jmods;%MODULE_PATH%" ^
+    --add-modules %RUNTIME_MODULES% ^
+    --no-header-files ^
+    --no-man-pages ^
+    --strip-debug ^
+    --strip-native-commands ^
+    --compress=zip-9 ^
+    --output "%RUNTIME_DIR%" || exit /b 1
+
+echo ==^> [3/4] jpackage 生成绿色目录 -> %OUT_DIR%
+if exist "%OUT_DIR%" rmdir /s /q "%OUT_DIR%"
+mkdir "%OUT_DIR%"
+
+set ICON_ARG=
+if exist "src\main\resources\com\fujica\parkingtool\icon.ico" (
+    set ICON_ARG=--icon src\main\resources\com\fujica\parkingtool\icon.ico
+)
+
+jpackage ^
+    --type app-image ^
+    --name %APP_NAME% ^
+    --app-version %APP_VERSION% ^
+    --vendor "%APP_VENDOR%" ^
+    --module-path "%MODULE_PATH%" ^
+    --module "%MAIN_MODULE%/%MAIN_CLASS%" ^
+    --runtime-image "%RUNTIME_DIR%" ^
+    --dest "%OUT_DIR%" ^
+    --java-options "-Xms32m" ^
+    --java-options "-Xmx256m" ^
+    --java-options "-XX:+UseSerialGC" ^
+    --java-options "-Dprism.lcdtext=false" ^
+    %ICON_ARG% || exit /b 1
+
+echo ==^> [4/4] 完成
+echo [OK] 产物:%OUT_DIR%\%APP_NAME%\%APP_NAME%.exe
+echo      整个 %OUT_DIR%\%APP_NAME% 文件夹可拷贝到任意 Windows 机器上双击运行。
+
+REM === 可选:打包 .msi 安装程序(需要预装 WiX 3.x) ===
+REM jpackage ^
+REM     --type msi ^
+REM     --name %APP_NAME% ^
+REM     --app-version %APP_VERSION% ^
+REM     --vendor "%APP_VENDOR%" ^
+REM     --module-path "%MODULE_PATH%" ^
+REM     --module "%MAIN_MODULE%/%MAIN_CLASS%" ^
+REM     --runtime-image "%RUNTIME_DIR%" ^
+REM     --dest "%OUT_DIR%" ^
+REM     --win-dir-chooser --win-shortcut --win-menu ^
+REM     %ICON_ARG%
+
+endlocal

+ 69 - 0
src/main/java/com/fujica/parkingtool/App.java

@@ -0,0 +1,69 @@
+package com.fujica.parkingtool;
+
+import atlantafx.base.theme.PrimerDark;
+import com.fujica.parkingtool.ui.MainView;
+import javafx.application.Application;
+import javafx.scene.Scene;
+import javafx.scene.image.Image;
+import javafx.stage.Stage;
+import javafx.stage.StageStyle;
+
+import java.io.InputStream;
+import java.util.Objects;
+
+/**
+ * 应用入口。
+ * 基于 JDK 25 + JavaFX 25 + AtlantaFX。
+ */
+public class App extends Application {
+
+    public static final String APP_TITLE = "富士云停车模拟器";
+
+    @Override
+    public void start(Stage stage) {
+        // AtlantaFX 暗色主题作为底,再叠加自定义炫酷样式
+        Application.setUserAgentStylesheet(new PrimerDark().getUserAgentStylesheet());
+
+        // 扩展窗口样式:去掉系统厚标题栏,让内容延伸到窗口顶部,
+        // 同时保留 macOS 红黄绿 / Windows 关闭按钮等系统装饰。
+        // 注意:JavaFX 25 中 EXTENDED 已稳定,HeaderBar 仍是 preview(这里没用到)。
+        // 老版本 / 不支持的环境会静默回退到普通带标题栏窗口。
+        try {
+            stage.initStyle(StageStyle.EXTENDED);
+        } catch (Throwable ignore) {
+            // ignore:使用系统默认带标题栏样式
+        }
+
+        MainView root = new MainView();
+        Scene scene = new Scene(root, 1240, 820);
+        scene.getStylesheets().add(
+                Objects.requireNonNull(
+                        getClass().getResource("/com/fujica/parkingtool/styles/app.css"))
+                        .toExternalForm());
+
+        stage.setScene(scene);
+        stage.setTitle(APP_TITLE);
+        stage.setMinWidth(1080);
+        stage.setMinHeight(720);
+        loadIcon(stage);
+
+        // 关闭窗口时释放 MQTT 资源
+        stage.setOnCloseRequest(e -> root.shutdown());
+
+        stage.show();
+    }
+
+    private void loadIcon(Stage stage) {
+        try (InputStream in = getClass().getResourceAsStream("/com/fujica/parkingtool/icon.png")) {
+            if (in != null) {
+                stage.getIcons().add(new Image(in));
+            }
+        } catch (Exception ignored) {
+            // 图标缺失不影响运行
+        }
+    }
+
+    public static void main(String[] args) {
+        launch(args);
+    }
+}

+ 114 - 0
src/main/java/com/fujica/parkingtool/mqtt/BatchSender.java

@@ -0,0 +1,114 @@
+package com.fujica.parkingtool.mqtt;
+
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * 批量造数据:按固定间隔,向指定通道循环发送 N 条 ivs_result(仅车牌不同)。
+ * 任意时刻只能有一个批量任务在运行;再次 start 会先 stop。
+ */
+public class BatchSender implements AutoCloseable {
+
+    private final ScheduledExecutorService executor;
+    private ScheduledFuture<?> task;
+    private DeviceChannel current;
+
+    public BatchSender() {
+        this.executor = Executors.newSingleThreadScheduledExecutor(r -> {
+            Thread t = new Thread(r, "parking-batch");
+            t.setDaemon(true);
+            return t;
+        });
+    }
+
+    /**
+     * 启动批量任务。
+     *
+     * @param channel      目标通道(必须已连接)
+     * @param total        总数量
+     * @param intervalMs   每条间隔毫秒(>=10)
+     * @param plateSupplier 每条调用一次以获取车牌号
+     * @param plateColor   车牌颜色,使用 {@link DeviceMessageBuilder#PLATE_COLOR_BLUE} 等
+     * @param triggerType  触发类型
+     * @param logger       日志回调
+     * @param onComplete   全部发送完成或被中止后的回调(true = 正常完成)
+     */
+    public synchronized void start(DeviceChannel channel,
+                                   int total,
+                                   int intervalMs,
+                                   Supplier<String> plateSupplier,
+                                   int plateColor,
+                                   int triggerType,
+                                   Consumer<String> logger,
+                                   Consumer<Boolean> onComplete) {
+        if (channel == null || !channel.isConnected()) {
+            log(logger, "⚠️ 批量发送:通道未连接");
+            if (onComplete != null) onComplete.accept(false);
+            return;
+        }
+        if (total <= 0) {
+            log(logger, "⚠️ 批量数量必须 > 0");
+            if (onComplete != null) onComplete.accept(false);
+            return;
+        }
+        int interval = Math.max(10, intervalMs);
+        stop();
+        current = channel;
+
+        AtomicInteger counter = new AtomicInteger(0);
+        log(logger, "▶ 开始批量入场:通道=" + channel.key().displayName
+                + ",数量=" + total + ",间隔=" + interval + "ms");
+
+        task = executor.scheduleWithFixedDelay(() -> {
+            int idx = counter.incrementAndGet();
+            try {
+                String plate = plateSupplier.get();
+                Map<String, Object> msg = DeviceMessageBuilder.buildIvsResult(
+                        channel.settings(),
+                        plate,
+                        plateColor,
+                        95,
+                        triggerType,
+                        System.currentTimeMillis());
+                channel.publishIvsResult(msg, plate);
+                log(logger, "  [" + idx + "/" + total + "] " + plate);
+            } catch (Throwable t) {
+                log(logger, "  [" + idx + "/" + total + "] 失败:" + t.getMessage());
+            }
+            if (idx >= total) {
+                stop();
+                log(logger, "✔ 批量入场完成");
+                if (onComplete != null) onComplete.accept(true);
+            }
+        }, 0, interval, TimeUnit.MILLISECONDS);
+    }
+
+    /** 取消当前批量任务(幂等)。 */
+    public synchronized void stop() {
+        if (task != null) {
+            task.cancel(false);
+            task = null;
+        }
+        current = null;
+    }
+
+    public synchronized boolean isRunning() {
+        return task != null && !task.isCancelled();
+    }
+
+    private static void log(Consumer<String> logger, String msg) {
+        if (logger != null) logger.accept(msg);
+    }
+
+    @Override
+    public void close() {
+        stop();
+        executor.shutdownNow();
+    }
+}

+ 43 - 0
src/main/java/com/fujica/parkingtool/mqtt/ChannelKey.java

@@ -0,0 +1,43 @@
+package com.fujica.parkingtool.mqtt;
+
+/**
+ * 模拟设备的车道角色(一般停车场入口/出口分别布一台设备)。
+ *
+ * <p>该枚举只描述身份,不携带连接逻辑;具体配置见 {@link MqttSettings},
+ * 它会根据 key 在 {@link java.util.prefs.Preferences} 中使用独立命名空间,
+ * 从而做到入口/出口两套独立的 broker/SN/IP 设置。
+ */
+public enum ChannelKey {
+
+    /** 入场 */
+    ENTRY("entry", "入口", "265e1040-85e01fb7", "192.168.13.22", 0,
+            "parking-sim-entry"),
+
+    /** 出场 */
+    EXIT("exit",  "出口", "265e1040-85e01fc8", "192.168.13.23", 1,
+            "parking-sim-exit");
+
+    /** Preferences 子节点名 / 内部 key */
+    public final String key;
+    /** 中文显示名 */
+    public final String displayName;
+    /** 默认 SN */
+    public final String defaultSn;
+    /** 默认设备 IP */
+    public final String defaultDeviceIp;
+    /** 默认上报 channel 字段 */
+    public final int defaultChannel;
+    /** 默认 MQTT clientId 前缀 */
+    public final String defaultClientIdPrefix;
+
+    ChannelKey(String key, String displayName, String defaultSn,
+               String defaultDeviceIp, int defaultChannel,
+               String defaultClientIdPrefix) {
+        this.key = key;
+        this.displayName = displayName;
+        this.defaultSn = defaultSn;
+        this.defaultDeviceIp = defaultDeviceIp;
+        this.defaultChannel = defaultChannel;
+        this.defaultClientIdPrefix = defaultClientIdPrefix;
+    }
+}

+ 191 - 0
src/main/java/com/fujica/parkingtool/mqtt/DeviceChannel.java

@@ -0,0 +1,191 @@
+package com.fujica.parkingtool.mqtt;
+
+import org.eclipse.paho.client.mqttv3.MqttException;
+
+import java.util.Map;
+import java.util.function.Consumer;
+
+/**
+ * 单个车道(入口 / 出口)的运行时聚合体:
+ * 持有该通道的 {@link MqttSettings}、{@link DeviceMqttClient}、{@link HeartbeatScheduler},
+ * 把 UI 层的"按通道操作"抽象成一组方法。
+ *
+ * <p>所有日志输出都自动加上通道前缀(如 <code>[入口]</code>),便于在共享控制台里区分来源。
+ */
+public class DeviceChannel implements AutoCloseable {
+
+    /** 业务指标监听器:每次 publish 成功后回调一次。 */
+    @FunctionalInterface
+    public interface MetricsListener {
+        /**
+         * @param key     通道
+         * @param name    消息名(ivs_result / gpio_in / barr_gate_status / keep_alive)
+         * @param summary 简要业务描述(车牌等,可为 null/空)
+         * @param json    完整报文的 pretty JSON 字符串,可在 UI 详情面板展示
+         */
+        void onSent(ChannelKey key, String name, String summary, String json);
+    }
+
+    private MqttSettings settings;
+    private final Consumer<String> rawLogger;
+    private final Consumer<Boolean> connectionListener;
+    private volatile MetricsListener metricsListener;
+
+    private DeviceMqttClient client;
+    private HeartbeatScheduler heartbeat;
+
+    /**
+     * @param settings           初始配置(含通道身份)
+     * @param logger             控制台日志(已在 FX 线程上回调;这里不再切换线程)
+     * @param connectionListener 连接状态变更回调(带通道实例自身)
+     */
+    public DeviceChannel(MqttSettings settings,
+                         Consumer<String> logger,
+                         Consumer<Boolean> connectionListener) {
+        this.settings = settings;
+        this.rawLogger = logger != null ? logger : msg -> {};
+        this.connectionListener = connectionListener != null ? connectionListener : f -> {};
+    }
+
+    public void setMetricsListener(MetricsListener l) {
+        this.metricsListener = l;
+    }
+
+    public ChannelKey key() {
+        return settings.getChannelKey();
+    }
+
+    public MqttSettings settings() {
+        return settings;
+    }
+
+    /** 用新配置覆盖。如果当前已连接,外部需自行决定是否重连。 */
+    public void updateSettings(MqttSettings newSettings) {
+        if (newSettings == null) return;
+        if (newSettings.getChannelKey() != settings.getChannelKey()) {
+            throw new IllegalArgumentException("通道身份不匹配");
+        }
+        this.settings = newSettings;
+    }
+
+    public boolean isConnected() {
+        return client != null && client.isConnected();
+    }
+
+    /** 连接 MQTT;已连接时是 no-op。 */
+    public synchronized void connect() throws MqttException {
+        if (isConnected()) {
+            log("已处于连接状态");
+            return;
+        }
+        // 重置一下 client,确保用最新配置
+        if (client != null) {
+            client.disconnect();
+        }
+        client = new DeviceMqttClient(
+                settings,
+                this::log,
+                connectionListener);
+        client.connect();
+    }
+
+    /** 断开 MQTT 并停止心跳。 */
+    public synchronized void disconnect() {
+        if (heartbeat != null && heartbeat.isRunning()) {
+            heartbeat.stop();
+        }
+        if (client != null) {
+            client.disconnect();
+        }
+    }
+
+    /* ------------- 业务发送 ------------- */
+
+    public void publishIvsResult(Map<String, Object> message) throws MqttException {
+        publishIvsResult(message, null);
+    }
+
+    /** @param summary 车牌等业务摘要,仅用于 metrics(不会影响真实报文) */
+    public void publishIvsResult(Map<String, Object> message, String summary) throws MqttException {
+        ensureClient().publishIvsResult(message);
+        notifySent("ivs_result", summary, message);
+    }
+
+    public void publishGpioIn(Map<String, Object> message) throws MqttException {
+        ensureClient().publishGpioIn(message);
+        notifySent("gpio_in", null, message);
+    }
+
+    public void publishGateStatus(Map<String, Object> message) throws MqttException {
+        ensureClient().publishGateStatus(message);
+        notifySent("barr_gate_status", null, message);
+    }
+
+    /** 心跳调度器调用:内部构造 keep_alive 报文并 publish。 */
+    public void publishKeepAlive() throws MqttException {
+        Map<String, Object> msg = DeviceMessageBuilder.buildKeepAlive(settings);
+        ensureClient().publishKeepAlive(msg);
+        notifySent("keep_alive", null, msg);
+    }
+
+    private void notifySent(String name, String summary, Object payload) {
+        MetricsListener l = metricsListener;
+        if (l == null) return;
+        String json;
+        try {
+            json = DeviceMessageBuilder.toPrettyJson(payload);
+        } catch (Throwable t) {
+            json = "/* json 序列化失败: " + t.getMessage() + " */";
+        }
+        try { l.onSent(settings.getChannelKey(), name, summary, json); }
+        catch (Throwable ignored) { /* metrics 失败不影响业务 */ }
+    }
+
+    /* ------------- 心跳 ------------- */
+
+    public synchronized void startHeartbeat(int intervalSec) {
+        if (!isConnected()) {
+            log("⚠️ 未连接 MQTT,无法启动心跳");
+            return;
+        }
+        if (heartbeat == null) {
+            heartbeat = new HeartbeatScheduler(this, this::log);
+        }
+        heartbeat.start(intervalSec);
+    }
+
+    public synchronized void stopHeartbeat() {
+        if (heartbeat != null) {
+            heartbeat.stop();
+        }
+    }
+
+    public synchronized boolean isHeartbeatRunning() {
+        return heartbeat != null && heartbeat.isRunning();
+    }
+
+    /* ------------- 内部 ------------- */
+
+    private DeviceMqttClient ensureClient() throws MqttException {
+        if (!isConnected()) {
+            throw new MqttException(MqttException.REASON_CODE_CLIENT_NOT_CONNECTED);
+        }
+        return client;
+    }
+
+    private void log(String msg) {
+        rawLogger.accept("[" + settings.getChannelKey().displayName + "] " + msg);
+    }
+
+    @Override
+    public void close() {
+        if (heartbeat != null) {
+            heartbeat.close();
+            heartbeat = null;
+        }
+        if (client != null) {
+            client.disconnect();
+            client = null;
+        }
+    }
+}

+ 328 - 0
src/main/java/com/fujica/parkingtool/mqtt/DeviceMessageBuilder.java

@@ -0,0 +1,328 @@
+package com.fujica.parkingtool.mqtt;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 设备上行 MQTT 消息构造器。
+ *
+ * <p>参考 devicedriveservice 中的 {@code MqttSubscriptionMessage} / {@code IvsResultPayload}
+ * / {@code GpioInPayload} / {@code GateStatusPayload} / {@code KeepAlivePayload},
+ * 完整复刻字段结构,供本工具模拟设备主动上传。
+ *
+ * <p>消息体一律使用 LinkedHashMap 保留字段顺序,便于人眼对照设备实际报文。
+ */
+public final class DeviceMessageBuilder {
+
+    /** 触发类型:机动车通行(正常进出场) */
+    public static final int TRIGGER_TYPE_NORMAL_PASS = 67;
+    /** 触发类型:自动触发(无明显业务语义,但兼容设备默认) */
+    public static final int TRIGGER_TYPE_AUTO = 1;
+
+    /** 车牌颜色:蓝 */
+    public static final int PLATE_COLOR_BLUE   = 1;
+    /** 车牌颜色:黄 */
+    public static final int PLATE_COLOR_YELLOW = 2;
+    /** 车牌颜色:白 */
+    public static final int PLATE_COLOR_WHITE  = 3;
+    /** 车牌颜色:黑 */
+    public static final int PLATE_COLOR_BLACK  = 4;
+    /** 车牌颜色:绿 */
+    public static final int PLATE_COLOR_GREEN  = 5;
+
+    /** IO 状态:通 */
+    public static final int IO_STATE_ON    = 1;
+    /** IO 状态:断 */
+    public static final int IO_STATE_OFF   = 0;
+    /** IO 状态:先通后断(脉冲) */
+    public static final int IO_STATE_PULSE = 2;
+
+    /** 道闸状态:关到位 */
+    public static final int GATE_STATUS_CLOSED  = 0;
+    /** 道闸状态:开到位 */
+    public static final int GATE_STATUS_OPENED  = 1;
+    /** 道闸状态:中间状态 */
+    public static final int GATE_STATUS_MIDDLE  = 2;
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+    private static final char[] ID_ALPHABET =
+            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray();
+
+    private static final ObjectMapper MAPPER = new ObjectMapper()
+            .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true);
+
+    private DeviceMessageBuilder() {
+    }
+
+    /** 生成 16 位随机消息 ID,参照设备样例(如 "iUA4fO7yCGrHZO2C")。 */
+    public static String randomMessageId() {
+        char[] buf = new char[16];
+        for (int i = 0; i < buf.length; i++) {
+            buf[i] = ID_ALPHABET[RANDOM.nextInt(ID_ALPHABET.length)];
+        }
+        return new String(buf);
+    }
+
+    /** 当前秒级时间戳。 */
+    public static long nowSeconds() {
+        return System.currentTimeMillis() / 1000L;
+    }
+
+    /** Base64 编码字符串(按 GBK 编码,与设备保持一致;为简单起见这里用 UTF-8 也兼容ASCII场景)。 */
+    public static String base64(String value) {
+        if (value == null) return "";
+        return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8));
+    }
+
+    /** 把任意对象序列化为 JSON 字符串(紧凑格式,与设备实际报文一致)。 */
+    public static String toJson(Object obj) {
+        try {
+            return MAPPER.writeValueAsString(obj);
+        } catch (Exception e) {
+            throw new IllegalStateException("序列化 JSON 失败", e);
+        }
+    }
+
+    /** 同 {@link #toJson(Object)} 但带缩进美化,便于在 UI 详情面板里阅读。 */
+    public static String toPrettyJson(Object obj) {
+        try {
+            return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
+        } catch (Exception e) {
+            return "/* 序列化失败: " + e.getMessage() + " */";
+        }
+    }
+
+    /* =============================================================
+     *  通用消息壳
+     * =============================================================*/
+
+    /**
+     * 构造通用上行消息壳:id / sn / name / version / timestamp / bv / payload。
+     */
+    private static Map<String, Object> baseMessage(MqttSettings cfg, String name, Object payload) {
+        Map<String, Object> root = new LinkedHashMap<>();
+        root.put("id", randomMessageId());
+        root.put("bv", cfg.getBoardVersion());
+        root.put("sn", cfg.getSn());
+        root.put("name", name);
+        root.put("version", "1.0");
+        root.put("timestamp", nowSeconds());
+        root.put("payload", payload);
+        return root;
+    }
+
+    /* =============================================================
+     *  车牌识别结果 ivs_result
+     * =============================================================*/
+
+    /**
+     * 构造车牌识别结果消息(车辆驶入/驶出主报文)。无图片版本。
+     */
+    public static Map<String, Object> buildIvsResult(MqttSettings cfg,
+                                                     String plate,
+                                                     int plateColor,
+                                                     int confidence,
+                                                     int triggerType,
+                                                     long triggerTimeMs) {
+        return buildIvsResult(cfg, plate, plateColor, confidence,
+                triggerType, triggerTimeMs, null, null);
+    }
+
+    /**
+     * 构造车牌识别结果消息(车辆驶入/驶出主报文)。
+     *
+     * @param cfg          连接 / 设备身份配置
+     * @param plate        车牌号明文(如 "粤B29082"),无牌车传 "无牌车"
+     * @param plateColor   车牌颜色,使用 PLATE_COLOR_* 常量
+     * @param confidence   识别可信度 0~100
+     * @param triggerType  触发类型,使用 TRIGGER_TYPE_* 常量
+     * @param triggerTimeMs 事件触发毫秒时间戳,传 0 取当前时间
+     * @param imageAbsoluteUrl 特写图绝对 URL(OSS 公网),null 不填
+     * @param imageRelativeKey 特写图相对 key(OSS object key),null 不填
+     */
+    public static Map<String, Object> buildIvsResult(MqttSettings cfg,
+                                                     String plate,
+                                                     int plateColor,
+                                                     int confidence,
+                                                     int triggerType,
+                                                     long triggerTimeMs,
+                                                     String imageAbsoluteUrl,
+                                                     String imageRelativeKey) {
+        long ts = triggerTimeMs > 0 ? triggerTimeMs : System.currentTimeMillis();
+
+        Map<String, Object> location = new LinkedHashMap<>();
+        location.put("height", 30);
+        location.put("left",   400);
+        location.put("top",    240);
+        location.put("width",  120);
+
+        Map<String, Object> carLocation = new LinkedHashMap<>();
+        carLocation.put("height", 220);
+        carLocation.put("left",   200);
+        carLocation.put("top",    100);
+        carLocation.put("width",  520);
+
+        Map<String, Object> carBrand = new LinkedHashMap<>();
+        carBrand.put("brand", 0);
+        carBrand.put("subBrand", 0);
+        carBrand.put("year", 0);
+
+        Map<String, Object> timeStamp = new LinkedHashMap<>();
+        timeStamp.put("Timeval", Map.of(
+                "sec",  ts / 1000L,
+                "usec", (ts % 1000L) * 1000L));
+
+        Map<String, Object> plateResult = new LinkedHashMap<>();
+        plateResult.put("bright",       128);
+        plateResult.put("carBright",    160);
+        plateResult.put("carColor",     0);
+        plateResult.put("car_brand",    carBrand);
+        plateResult.put("car_location", carLocation);
+        plateResult.put("license",      base64(plate));
+        plateResult.put("colorType",    plateColor);
+        plateResult.put("colorValue",   plateColor);
+        plateResult.put("confidence",   confidence);
+        plateResult.put("direction",    2);
+        plateResult.put("feature_code", 0);
+        plateResult.put("is_fake_plate",0);
+        plateResult.put("is_encrypted", 0);
+        plateResult.put("isoffline",    0);
+        plateResult.put("location",     location);
+        plateResult.put("plate_distance",   300);
+        plateResult.put("plate_true_width", 44);
+        plateResult.put("plateid",      ts);
+        plateResult.put("timeStamp",    timeStamp);
+        plateResult.put("timeUsed",     35);
+        plateResult.put("triggerType",  triggerType);
+        plateResult.put("type",         0);
+        plateResult.put("begin_time",   ts / 1000L);
+        plateResult.put("end_time",     ts / 1000L);
+        plateResult.put("start_time",   ts);
+        plateResult.put("unique_id",    cfg.getSn() + "_" + ts);
+        plateResult.put("plates",       List.of(Map.of(
+                "license", base64(plate),
+                "colorType", plateColor,
+                "confidence", confidence)));
+
+        // 车牌图片(OSS 路径,全部 base64 编码后传输;为空时不放入字段)
+        if (imageAbsoluteUrl != null && !imageAbsoluteUrl.isEmpty()) {
+            plateResult.put("imageFragmentAbsolutePath", base64(imageAbsoluteUrl));
+            // 背景图字段也用同一张(车牌渲染图既作背景也作特写)
+            plateResult.put("imageAbsolutePath",         base64(imageAbsoluteUrl));
+        }
+        if (imageRelativeKey != null && !imageRelativeKey.isEmpty()) {
+            plateResult.put("imageFragmentRelativePath", base64(imageRelativeKey));
+            plateResult.put("imageRelativePath",         base64(imageRelativeKey));
+        }
+
+        Map<String, Object> resultWrapper = new LinkedHashMap<>();
+        resultWrapper.put("PlateResult", plateResult);
+
+        Map<String, Object> alarmInfoPlate = new LinkedHashMap<>();
+        alarmInfoPlate.put("channel",    cfg.getChannel());
+        alarmInfoPlate.put("deviceName", base64(cfg.getDeviceName()));
+        alarmInfoPlate.put("ipaddr",     cfg.getDeviceIp());
+        alarmInfoPlate.put("serialno",   cfg.getSn());
+        alarmInfoPlate.put("user_data",  base64("parking-simulator"));
+        alarmInfoPlate.put("rule_id",    1);
+        alarmInfoPlate.put("lane_line",  0);
+        alarmInfoPlate.put("reco_id",    ts);
+        alarmInfoPlate.put("trigger_id", ts);
+        alarmInfoPlate.put("user_lane",  0);
+        alarmInfoPlate.put("result",     resultWrapper);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("AlarmInfoPlate", alarmInfoPlate);
+
+        return baseMessage(cfg, "ivs_result", payload);
+    }
+
+    /* =============================================================
+     *  GPIO 输入事件 gpio_in (地感触发)
+     * =============================================================*/
+
+    /**
+     * 构造 GPIO 输入事件(即地感线圈触发)。
+     *
+     * @param cfg     配置
+     * @param ioIndex IO 编号,地感通常为 0
+     * @param ioState IO 状态,使用 IO_STATE_* 常量
+     */
+    public static Map<String, Object> buildGpioIn(MqttSettings cfg, int ioIndex, int ioState) {
+        Map<String, Object> trigger = new LinkedHashMap<>();
+        trigger.put("source", ioIndex);
+        trigger.put("value",  ioState);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("TriggerResult", trigger);
+
+        Map<String, Object> alarm = new LinkedHashMap<>();
+        alarm.put("deviceName", base64(cfg.getDeviceName()));
+        alarm.put("ipaddr",     cfg.getDeviceIp());
+        alarm.put("result",     result);
+        alarm.put("serialno",   cfg.getSn());
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("AlarmGioIn", alarm);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("body", body);
+
+        return baseMessage(cfg, "gpio_in", payload);
+    }
+
+    /* =============================================================
+     *  道闸状态 barr_gate_status
+     * =============================================================*/
+
+    /**
+     * 构造道闸状态消息。
+     *
+     * @param cfg           配置
+     * @param gateCtrlId    道闸控制器型号编号
+     * @param gateStatus    GATE_STATUS_* 常量
+     * @param connectStatus 0未连 1已连
+     * @param enable        0关闭 1开启
+     */
+    public static Map<String, Object> buildGateStatus(MqttSettings cfg,
+                                                      int gateCtrlId,
+                                                      int gateStatus,
+                                                      int connectStatus,
+                                                      int enable) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("gate_ctrl_id",   gateCtrlId);
+        body.put("gate_status",    gateStatus);
+        body.put("connect_status", connectStatus);
+        body.put("enable",         enable);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("body", body);
+
+        return baseMessage(cfg, "barr_gate_status", payload);
+    }
+
+    /* =============================================================
+     *  心跳 keep_alive
+     * =============================================================*/
+
+    /**
+     * 构造业务心跳包。注意业务心跳走的是
+     * <code>$/device/{sn}/message/up/keep_alive</code> 这一特殊主题。
+     */
+    public static Map<String, Object> buildKeepAlive(MqttSettings cfg) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("timestamp", System.currentTimeMillis());
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("body", body);
+
+        return baseMessage(cfg, "keep_alive", payload);
+    }
+}

+ 181 - 0
src/main/java/com/fujica/parkingtool/mqtt/DeviceMqttClient.java

@@ -0,0 +1,181 @@
+package com.fujica.parkingtool.mqtt;
+
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * 模拟设备端 MQTT 客户端,单连接 + 单发布 publisher,所有消息都向上行 topic 发送。
+ *
+ * <p>topic 规则对齐 devicedriveservice:
+ * <ul>
+ *     <li>识别 / GPIO / 道闸状态:<code>device/{sn}/message/up/{name}</code></li>
+ *     <li>心跳特殊主题:<code>$/device/{sn}/message/up/keep_alive</code></li>
+ * </ul>
+ */
+public class DeviceMqttClient implements AutoCloseable {
+
+    /** 业务心跳特殊主题前缀(参考 mqtt.emqx.topicheartbeat=$/device/+/message/up/keep_alive) */
+    private static final String HEARTBEAT_TOPIC_PREFIX = "$";
+
+    private final MqttSettings settings;
+    private final Consumer<String> logger;
+    private final Consumer<Boolean> connectionListener;
+
+    private MqttClient client;
+
+    public DeviceMqttClient(MqttSettings settings,
+                            Consumer<String> logger,
+                            Consumer<Boolean> connectionListener) {
+        this.settings = Objects.requireNonNull(settings, "settings");
+        this.logger = logger != null ? logger : msg -> {};
+        this.connectionListener = connectionListener != null ? connectionListener : flag -> {};
+    }
+
+    /** 建立连接。同步阻塞至连接完成或抛出异常。 */
+    public synchronized void connect() throws MqttException {
+        if (client != null && client.isConnected()) {
+            log("已处于已连接状态,跳过 connect()");
+            return;
+        }
+
+        String uri = settings.brokerUri();
+        log("连接 MQTT Broker: " + uri + "  clientId=" + settings.getClientId());
+
+        client = new MqttClient(uri, settings.getClientId(), new MemoryPersistence());
+
+        MqttConnectOptions opts = new MqttConnectOptions();
+        opts.setServerURIs(new String[]{uri});
+        opts.setUserName(settings.getUsername());
+        opts.setPassword(settings.getPassword() == null
+                ? new char[0] : settings.getPassword().toCharArray());
+        opts.setKeepAliveInterval(settings.getKeepAliveSec());
+        opts.setCleanSession(false);
+        opts.setAutomaticReconnect(true);
+        opts.setConnectionTimeout(30);
+        opts.setMaxInflight(10);
+
+        client.setCallback(new MqttCallbackExtended() {
+            @Override
+            public void connectComplete(boolean reconnect, String serverURI) {
+                log((reconnect ? "MQTT 重连成功: " : "MQTT 连接成功: ") + serverURI);
+                connectionListener.accept(true);
+            }
+
+            @Override
+            public void connectionLost(Throwable cause) {
+                log("MQTT 连接丢失: " + cause);
+                connectionListener.accept(false);
+            }
+
+            @Override
+            public void messageArrived(String topic, MqttMessage message) {
+                log("收到消息 topic=" + topic + " payload=" + new String(message.getPayload()));
+            }
+
+            @Override
+            public void deliveryComplete(IMqttDeliveryToken token) {
+                // 发送回调不必单独打日志,避免噪音
+            }
+        });
+
+        client.connect(opts);
+        connectionListener.accept(client.isConnected());
+    }
+
+    /** 断开连接(幂等)。 */
+    public synchronized void disconnect() {
+        if (client == null) {
+            return;
+        }
+        try {
+            if (client.isConnected()) {
+                client.disconnect();
+                log("已断开 MQTT");
+            }
+        } catch (MqttException e) {
+            log("断开 MQTT 失败:" + e.getMessage());
+        } finally {
+            try {
+                client.close();
+            } catch (MqttException ignored) {
+            }
+            client = null;
+            connectionListener.accept(false);
+        }
+    }
+
+    public synchronized boolean isConnected() {
+        return client != null && client.isConnected();
+    }
+
+    public MqttSettings getSettings() {
+        return settings;
+    }
+
+    /* =============================================================
+     *  业务发布方法 - 接收已构造好的消息 Map
+     * =============================================================*/
+
+    /** 发布车牌识别结果(ivs_result)。 */
+    public void publishIvsResult(Map<String, Object> message) throws MqttException {
+        String topic = "device/" + settings.getSn() + "/message/up/ivs_result";
+        publish(topic, message);
+    }
+
+    /** 发布 GPIO 输入事件(地感触发等)。 */
+    public void publishGpioIn(Map<String, Object> message) throws MqttException {
+        String topic = "device/" + settings.getSn() + "/message/up/gpio_in";
+        publish(topic, message);
+    }
+
+    /** 发布道闸状态变化。 */
+    public void publishGateStatus(Map<String, Object> message) throws MqttException {
+        String topic = "device/" + settings.getSn() + "/message/up/barr_gate_status";
+        publish(topic, message);
+    }
+
+    /** 发布业务心跳,topic 多一个前缀 $。 */
+    public void publishKeepAlive(Map<String, Object> message) throws MqttException {
+        String topic = HEARTBEAT_TOPIC_PREFIX
+                + "/device/" + settings.getSn() + "/message/up/keep_alive";
+        publish(topic, message);
+    }
+
+    /* =============================================================
+     *  内部 - 实际发布
+     * =============================================================*/
+
+    private void publish(String topic, Map<String, Object> message) throws MqttException {
+        ensureConnected();
+        String json = DeviceMessageBuilder.toJson(message);
+        MqttMessage m = new MqttMessage(json.getBytes());
+        m.setQos(settings.getQos());
+        m.setRetained(false);
+        client.publish(topic, m);
+        log("→ " + topic + "\n" + json);
+    }
+
+    private void ensureConnected() throws MqttException {
+        if (client == null || !client.isConnected()) {
+            throw new MqttException(MqttException.REASON_CODE_CLIENT_NOT_CONNECTED);
+        }
+    }
+
+    private void log(String msg) {
+        logger.accept(msg);
+    }
+
+    @Override
+    public void close() {
+        disconnect();
+    }
+}

+ 81 - 0
src/main/java/com/fujica/parkingtool/mqtt/HeartbeatScheduler.java

@@ -0,0 +1,81 @@
+package com.fujica.parkingtool.mqtt;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * 业务心跳调度器:按指定间隔(秒)周期性发送 keep_alive 消息。
+ * 与 MQTT 协议层 keep-alive 解耦,由调用方独立控制。
+ */
+public class HeartbeatScheduler implements AutoCloseable {
+
+    private final DeviceChannel channel;
+    private final Consumer<String> logger;
+    private final ScheduledExecutorService executor;
+
+    private ScheduledFuture<?> task;
+    private int intervalSec;
+
+    public HeartbeatScheduler(DeviceChannel channel, Consumer<String> logger) {
+        this.channel = channel;
+        this.logger = logger != null ? logger : msg -> {};
+        this.executor = Executors.newSingleThreadScheduledExecutor(r -> {
+            Thread t = new Thread(r, "parking-heartbeat");
+            t.setDaemon(true);
+            return t;
+        });
+    }
+
+    /** 启动周期心跳。重复调用会先取消旧任务再以新间隔启动。 */
+    public synchronized void start(int intervalSec) {
+        if (intervalSec <= 0) {
+            throw new IllegalArgumentException("心跳间隔必须大于 0 秒");
+        }
+        stop();
+        this.intervalSec = intervalSec;
+        this.task = executor.scheduleAtFixedRate(this::tick, 0, intervalSec, TimeUnit.SECONDS);
+        log("心跳已启动,间隔 " + intervalSec + "s");
+    }
+
+    /** 停止心跳(幂等)。 */
+    public synchronized void stop() {
+        if (task != null) {
+            task.cancel(false);
+            task = null;
+            log("心跳已停止");
+        }
+    }
+
+    public synchronized boolean isRunning() {
+        return task != null && !task.isCancelled();
+    }
+
+    public synchronized int getIntervalSec() {
+        return intervalSec;
+    }
+
+    private void tick() {
+        try {
+            if (!channel.isConnected()) {
+                log("⚠️ 心跳:MQTT 未连接,跳过本次发送");
+                return;
+            }
+            channel.publishKeepAlive();
+        } catch (Throwable t) {
+            log("心跳发送失败:" + t.getMessage());
+        }
+    }
+
+    private void log(String msg) {
+        logger.accept(msg);
+    }
+
+    @Override
+    public void close() {
+        stop();
+        executor.shutdownNow();
+    }
+}

+ 199 - 0
src/main/java/com/fujica/parkingtool/mqtt/MqttSettings.java

@@ -0,0 +1,199 @@
+package com.fujica.parkingtool.mqtt;
+
+import java.util.Objects;
+import java.util.prefs.Preferences;
+
+/**
+ * MQTT 连接 + 设备身份配置。
+ *
+ * <p>对齐 devicedriveservice 中 <code>application-local.yml</code> 的
+ * <code>mqtt.emqx.*</code> 配置项与 topic 模式:
+ * <ul>
+ *   <li>上行:<code>device/{sn}/message/up/{name}</code></li>
+ *   <li>心跳:<code>$/device/{sn}/message/up/keep_alive</code></li>
+ * </ul>
+ *
+ * <p>配置按通道(入口/出口)独立保存在用户 Preferences 不同命名空间下。
+ */
+public final class MqttSettings {
+
+    private static final String PREF_ROOT = "/com/fujica/parkingtool/mqtt";
+
+    private static final String KEY_HOST            = "host";
+    private static final String KEY_PORT            = "port";
+    private static final String KEY_USERNAME        = "username";
+    private static final String KEY_PASSWORD        = "password";
+    private static final String KEY_CLIENT_ID       = "clientId";
+    private static final String KEY_SN              = "sn";
+    private static final String KEY_BOARD_VERSION   = "boardVersion";
+    private static final String KEY_DEVICE_IP       = "deviceIp";
+    private static final String KEY_DEVICE_NAME     = "deviceName";
+    private static final String KEY_CHANNEL         = "channel";
+    private static final String KEY_KEEP_ALIVE_SEC  = "keepAliveSec";
+    private static final String KEY_QOS             = "qos";
+
+    /** 所属通道:仅作为身份标识,不参与序列化 */
+    private final ChannelKey channelKey;
+
+    /** EMQX 主机。 */
+    private String host = "192.168.2.92";
+    /** EMQX 端口。 */
+    private int port = 1883;
+    /** EMQX 用户名。 */
+    private String username = "admin";
+    /** EMQX 密码。 */
+    private String password = "public";
+    /** 客户端 ID,缺省随机生成。 */
+    private String clientId;
+
+    /** 模拟设备的 SN,进入/退出 topic 中需要 */
+    private String sn;
+    /** 开发板版本号 bv */
+    private int boardVersion = 12378;
+    /** 设备 IP,写入 payload.AlarmInfoPlate.ipaddr 等字段 */
+    private String deviceIp;
+    /** 设备名(裸明文,由消息构造器 Base64 编码后写入 deviceName) */
+    private String deviceName = "IVS";
+    /** 上报通道号 */
+    private int channel;
+
+    /** MQTT 协议层 keep-alive 间隔(秒),即 Paho 心跳,非业务心跳 */
+    private int keepAliveSec = 60;
+    /** 发布消息的 QoS(与设备驱动服务保持一致默认 1) */
+    private int qos = 1;
+
+    public MqttSettings(ChannelKey channelKey) {
+        this.channelKey = Objects.requireNonNull(channelKey);
+        this.sn = channelKey.defaultSn;
+        this.deviceIp = channelKey.defaultDeviceIp;
+        this.channel = channelKey.defaultChannel;
+        this.clientId = channelKey.defaultClientIdPrefix + "-"
+                + Long.toHexString(System.nanoTime());
+    }
+
+    public ChannelKey getChannelKey() { return channelKey; }
+
+    public String getHost() { return host; }
+    public void setHost(String host) { this.host = host; }
+
+    public int getPort() { return port; }
+    public void setPort(int port) { this.port = port; }
+
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+
+    public String getClientId() { return clientId; }
+    public void setClientId(String clientId) { this.clientId = clientId; }
+
+    public String getSn() { return sn; }
+    public void setSn(String sn) { this.sn = sn; }
+
+    public int getBoardVersion() { return boardVersion; }
+    public void setBoardVersion(int boardVersion) { this.boardVersion = boardVersion; }
+
+    public String getDeviceIp() { return deviceIp; }
+    public void setDeviceIp(String deviceIp) { this.deviceIp = deviceIp; }
+
+    public String getDeviceName() { return deviceName; }
+    public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
+
+    public int getChannel() { return channel; }
+    public void setChannel(int channel) { this.channel = channel; }
+
+    public int getKeepAliveSec() { return keepAliveSec; }
+    public void setKeepAliveSec(int keepAliveSec) { this.keepAliveSec = keepAliveSec; }
+
+    public int getQos() { return qos; }
+    public void setQos(int qos) { this.qos = qos; }
+
+    /** 拼装 tcp:// 协议 broker URI */
+    public String brokerUri() {
+        return "tcp://" + host + ":" + port;
+    }
+
+    /** 读取指定通道已保存的设置,未设置时返回该通道默认值。 */
+    public static MqttSettings load(ChannelKey key) {
+        MqttSettings s = new MqttSettings(key);
+        Preferences p = nodeFor(key);
+        s.host = p.get(KEY_HOST, s.host);
+        s.port = p.getInt(KEY_PORT, s.port);
+        s.username = p.get(KEY_USERNAME, s.username);
+        s.password = p.get(KEY_PASSWORD, s.password);
+        s.clientId = p.get(KEY_CLIENT_ID, s.clientId);
+        s.sn = p.get(KEY_SN, s.sn);
+        s.boardVersion = p.getInt(KEY_BOARD_VERSION, s.boardVersion);
+        s.deviceIp = p.get(KEY_DEVICE_IP, s.deviceIp);
+        s.deviceName = p.get(KEY_DEVICE_NAME, s.deviceName);
+        s.channel = p.getInt(KEY_CHANNEL, s.channel);
+        s.keepAliveSec = p.getInt(KEY_KEEP_ALIVE_SEC, s.keepAliveSec);
+        s.qos = p.getInt(KEY_QOS, s.qos);
+        return s;
+    }
+
+    /** 写入到对应通道的首选项。 */
+    public void save() {
+        Preferences p = nodeFor(channelKey);
+        p.put(KEY_HOST, host);
+        p.putInt(KEY_PORT, port);
+        p.put(KEY_USERNAME, username);
+        p.put(KEY_PASSWORD, password);
+        p.put(KEY_CLIENT_ID, clientId);
+        p.put(KEY_SN, sn);
+        p.putInt(KEY_BOARD_VERSION, boardVersion);
+        p.put(KEY_DEVICE_IP, deviceIp);
+        p.put(KEY_DEVICE_NAME, deviceName);
+        p.putInt(KEY_CHANNEL, channel);
+        p.putInt(KEY_KEEP_ALIVE_SEC, keepAliveSec);
+        p.putInt(KEY_QOS, qos);
+    }
+
+    /** 复制一份,避免外部修改影响内部状态 */
+    public MqttSettings copy() {
+        MqttSettings s = new MqttSettings(channelKey);
+        s.host = this.host;
+        s.port = this.port;
+        s.username = this.username;
+        s.password = this.password;
+        s.clientId = this.clientId;
+        s.sn = this.sn;
+        s.boardVersion = this.boardVersion;
+        s.deviceIp = this.deviceIp;
+        s.deviceName = this.deviceName;
+        s.channel = this.channel;
+        s.keepAliveSec = this.keepAliveSec;
+        s.qos = this.qos;
+        return s;
+    }
+
+    private static Preferences nodeFor(ChannelKey key) {
+        return Preferences.userRoot().node(PREF_ROOT + "/" + key.key);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof MqttSettings that)) return false;
+        return port == that.port
+                && boardVersion == that.boardVersion
+                && channel == that.channel
+                && keepAliveSec == that.keepAliveSec
+                && qos == that.qos
+                && channelKey == that.channelKey
+                && Objects.equals(host, that.host)
+                && Objects.equals(username, that.username)
+                && Objects.equals(password, that.password)
+                && Objects.equals(clientId, that.clientId)
+                && Objects.equals(sn, that.sn)
+                && Objects.equals(deviceIp, that.deviceIp)
+                && Objects.equals(deviceName, that.deviceName);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(channelKey, host, port, username, password, clientId,
+                sn, boardVersion, deviceIp, deviceName, channel, keepAliveSec, qos);
+    }
+}

+ 141 - 0
src/main/java/com/fujica/parkingtool/oss/OssSettings.java

@@ -0,0 +1,141 @@
+package com.fujica.parkingtool.oss;
+
+import java.util.Objects;
+import java.util.prefs.Preferences;
+
+/**
+ * 阿里云 OSS 配置。
+ *
+ * <p>4 项核心字段:address / accessKey / secretKey / bucket。
+ * 全部不为空且 address 是合法主机时被视作"有效配置",否则 {@link #isUsable()} 返回 false,
+ * 用于让上层选择"不传图片"。</p>
+ *
+ * <p>address 不需要带协议前缀;不含 bucket 子域名(即只是 OSS endpoint,
+ * 例如 <code>oss-cn-hangzhou.aliyuncs.com</code>)。
+ * 上传时会自动拼成 <code>https://{bucket}.{address}/{key}</code>。</p>
+ *
+ * <p>配置持久化在用户 Preferences 节点 <code>/com/fujica/parkingtool/oss</code> 下。</p>
+ */
+public final class OssSettings {
+
+    private static final String PREF_ROOT = "/com/fujica/parkingtool/oss";
+
+    private static final String KEY_ADDRESS    = "address";
+    private static final String KEY_ACCESS_KEY = "accessKey";
+    private static final String KEY_SECRET_KEY = "secretKey";
+    private static final String KEY_BUCKET     = "bucket";
+    private static final String KEY_PATH_PREFIX= "pathPrefix";
+
+    /** OSS endpoint,不带 https://,不带 bucket 子域名 */
+    private String address = "";
+    private String accessKey = "";
+    private String secretKey = "";
+    private String bucket = "";
+    /** 上传路径前缀,默认 parking-simulator */
+    private String pathPrefix = "parking-simulator";
+
+    public OssSettings() { }
+
+    public String getAddress() { return address; }
+    public void setAddress(String address) { this.address = address == null ? "" : address.trim(); }
+
+    public String getAccessKey() { return accessKey; }
+    public void setAccessKey(String accessKey) { this.accessKey = accessKey == null ? "" : accessKey.trim(); }
+
+    public String getSecretKey() { return secretKey; }
+    public void setSecretKey(String secretKey) { this.secretKey = secretKey == null ? "" : secretKey; }
+
+    public String getBucket() { return bucket; }
+    public void setBucket(String bucket) { this.bucket = bucket == null ? "" : bucket.trim(); }
+
+    public String getPathPrefix() { return pathPrefix; }
+    public void setPathPrefix(String pathPrefix) {
+        if (pathPrefix == null) { this.pathPrefix = ""; return; }
+        String p = pathPrefix.trim();
+        // 去除首尾的多余斜杠
+        while (p.startsWith("/")) p = p.substring(1);
+        while (p.endsWith("/"))   p = p.substring(0, p.length() - 1);
+        this.pathPrefix = p;
+    }
+
+    /** 4 项核心字段全部非空,且 address 形如 host[:port] 才算有效。 */
+    public boolean isUsable() {
+        if (isBlank(address) || isBlank(accessKey) || isBlank(secretKey) || isBlank(bucket)) {
+            return false;
+        }
+        String cleaned = cleanAddress(address);
+        // 简单校验:不能是带协议的 URL;必须包含一个点
+        return !cleaned.contains("://") && cleaned.contains(".");
+    }
+
+    /** 去除可能错误粘进来的协议前缀和尾部斜杠,返回纯 host[:port] */
+    public static String cleanAddress(String raw) {
+        if (raw == null) return "";
+        String s = raw.trim();
+        if (s.startsWith("https://")) s = s.substring(8);
+        if (s.startsWith("http://"))  s = s.substring(7);
+        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
+        return s;
+    }
+
+    /** 拼装最终公网 URL(https):{bucket}.{address}/{key} */
+    public String publicUrl(String key) {
+        String addr = cleanAddress(address);
+        String k = key == null ? "" : (key.startsWith("/") ? key.substring(1) : key);
+        return "https://" + bucket + "." + addr + "/" + k;
+    }
+
+    private static boolean isBlank(String s) {
+        return s == null || s.trim().isEmpty();
+    }
+
+    public static OssSettings load() {
+        OssSettings s = new OssSettings();
+        Preferences p = node();
+        s.address    = p.get(KEY_ADDRESS,    s.address);
+        s.accessKey  = p.get(KEY_ACCESS_KEY, s.accessKey);
+        s.secretKey  = p.get(KEY_SECRET_KEY, s.secretKey);
+        s.bucket     = p.get(KEY_BUCKET,     s.bucket);
+        s.pathPrefix = p.get(KEY_PATH_PREFIX,s.pathPrefix);
+        return s;
+    }
+
+    public void save() {
+        Preferences p = node();
+        p.put(KEY_ADDRESS,    address);
+        p.put(KEY_ACCESS_KEY, accessKey);
+        p.put(KEY_SECRET_KEY, secretKey);
+        p.put(KEY_BUCKET,     bucket);
+        p.put(KEY_PATH_PREFIX,pathPrefix);
+    }
+
+    public OssSettings copy() {
+        OssSettings s = new OssSettings();
+        s.address    = address;
+        s.accessKey  = accessKey;
+        s.secretKey  = secretKey;
+        s.bucket     = bucket;
+        s.pathPrefix = pathPrefix;
+        return s;
+    }
+
+    private static Preferences node() {
+        return Preferences.userRoot().node(PREF_ROOT);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof OssSettings that)) return false;
+        return Objects.equals(address, that.address)
+                && Objects.equals(accessKey, that.accessKey)
+                && Objects.equals(secretKey, that.secretKey)
+                && Objects.equals(bucket, that.bucket)
+                && Objects.equals(pathPrefix, that.pathPrefix);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(address, accessKey, secretKey, bucket, pathPrefix);
+    }
+}

+ 169 - 0
src/main/java/com/fujica/parkingtool/oss/OssUploader.java

@@ -0,0 +1,169 @@
+package com.fujica.parkingtool.oss;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * 极简阿里云 OSS PUT 上传客户端。
+ *
+ * <p>不依赖 aliyun-sdk-oss,只用 JDK 内置 {@link HttpClient} + 阿里云 V1 签名(HMAC-SHA1)。
+ * 仅实现单次 PUT Object 接口,足以满足"上传车牌截图"的需求。</p>
+ *
+ * <h3>签名规则(OSS V1)</h3>
+ * <pre>
+ *   Authorization: OSS {AccessKey}:{Signature}
+ *   Signature     = base64(HMAC-SHA1(SecretKey, StringToSign))
+ *   StringToSign  = HTTP-Verb + "\n"
+ *                 + Content-MD5 + "\n"
+ *                 + Content-Type + "\n"
+ *                 + Date(RFC1123/GMT) + "\n"
+ *                 + CanonicalizedOSSHeaders   (空时省略)
+ *                 + CanonicalizedResource     (/Bucket/Key)
+ * </pre>
+ */
+public final class OssUploader {
+
+    private static final HttpClient HTTP = HttpClient.newBuilder()
+            .connectTimeout(Duration.ofSeconds(10))
+            .build();
+
+    /** GMT 日期格式(RFC 1123) */
+    private static final DateTimeFormatter GMT =
+            DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH)
+                    .withZone(ZoneId.of("GMT"));
+
+    private OssUploader() { }
+
+    /**
+     * 同步 PUT 上传 bytes 到 OSS。
+     *
+     * @param settings   OSS 配置;必须 {@link OssSettings#isUsable()} = true,否则抛 IAE
+     * @param key        对象 key(不含 bucket,不要以 / 开头),如 <code>parking-simulator/2026/06/abc.png</code>
+     * @param data       字节内容
+     * @param contentType MIME,如 <code>image/png</code>
+     * @return 上传结果(含 absoluteUrl & relativeKey),上传失败抛 {@link IOException}
+     */
+    public static UploadResult put(OssSettings settings, String key, byte[] data, String contentType)
+            throws IOException {
+        Objects.requireNonNull(settings, "settings");
+        Objects.requireNonNull(key,      "key");
+        Objects.requireNonNull(data,     "data");
+        if (!settings.isUsable()) {
+            throw new IllegalArgumentException("OSS 配置不完整或无效");
+        }
+        if (contentType == null || contentType.isBlank()) {
+            contentType = "application/octet-stream";
+        }
+        String objectKey = key.startsWith("/") ? key.substring(1) : key;
+        String address   = OssSettings.cleanAddress(settings.getAddress());
+        String bucket    = settings.getBucket();
+
+        String date = GMT.format(ZonedDateTime.now(ZoneId.of("GMT")));
+        String canonicalResource = "/" + bucket + "/" + objectKey;
+        String stringToSign = "PUT\n"          // HTTP Verb
+                + "\n"                          // Content-MD5(留空)
+                + contentType + "\n"            // Content-Type
+                + date + "\n"                   // Date
+                + canonicalResource;            // 无自定义 x-oss-* 头时直接拼资源
+        String signature = hmacSha1Base64(settings.getSecretKey(), stringToSign);
+        String authorization = "OSS " + settings.getAccessKey() + ":" + signature;
+
+        // URI.create 与 HttpRequest.newBuilder 的失败原因要分开报,避免误以为 endpoint 不合法
+        final URI uri;
+        try {
+            uri = URI.create("https://" + bucket + "." + address + "/" + encodeKey(objectKey));
+        } catch (IllegalArgumentException e) {
+            throw new IOException("OSS endpoint 不合法:" + bucket + "." + address
+                    + "/" + objectKey + " - " + e.getMessage(), e);
+        }
+
+        HttpRequest req;
+        try {
+            // 注意:HttpClient 把 Content-Length 列在受限头白名单里,手动设置会抛
+            // IllegalArgumentException。BodyPublishers.ofByteArray 已经会自动填,
+            // 所以这里不再显式设置。
+            req = HttpRequest.newBuilder(uri)
+                    .timeout(Duration.ofSeconds(15))
+                    .header("Date",          date)
+                    .header("Content-Type",  contentType)
+                    .header("Authorization", authorization)
+                    .PUT(HttpRequest.BodyPublishers.ofByteArray(data))
+                    .build();
+        } catch (IllegalArgumentException e) {
+            throw new IOException("构造 OSS PUT 请求失败 (" + uri + "):" + e.getMessage(), e);
+        }
+
+        HttpResponse<String> res;
+        try {
+            res = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+        } catch (InterruptedException ie) {
+            Thread.currentThread().interrupt();
+            throw new IOException("上传被中断", ie);
+        }
+
+        int status = res.statusCode();
+        if (status / 100 != 2) {
+            throw new IOException("OSS 上传失败:HTTP " + status + " - " + briefBody(res.body()));
+        }
+        return new UploadResult(settings.publicUrl(objectKey), objectKey);
+    }
+
+    private static String briefBody(String body) {
+        if (body == null) return "";
+        return body.length() > 240 ? body.substring(0, 240) + "..." : body;
+    }
+
+    /** 对 key 做 URL 编码,但保留路径分隔符 '/'。 */
+    private static String encodeKey(String key) {
+        StringBuilder out = new StringBuilder();
+        for (int i = 0; i < key.length(); i++) {
+            char c = key.charAt(i);
+            if (c == '/') { out.append('/'); continue; }
+            // 安全字符直接保留
+            if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
+                    || (c >= '0' && c <= '9')
+                    || c == '-' || c == '_' || c == '.' || c == '~') {
+                out.append(c);
+            } else {
+                byte[] bytes = String.valueOf(c).getBytes(StandardCharsets.UTF_8);
+                for (byte b : bytes) {
+                    out.append('%')
+                       .append(hex((b >> 4) & 0xF))
+                       .append(hex(b & 0xF));
+                }
+            }
+        }
+        return out.toString();
+    }
+
+    private static char hex(int v) {
+        return (char) (v < 10 ? '0' + v : 'A' + v - 10);
+    }
+
+    private static String hmacSha1Base64(String secret, String data) throws IOException {
+        try {
+            Mac mac = Mac.getInstance("HmacSHA1");
+            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
+            byte[] sig = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(sig);
+        } catch (Exception e) {
+            throw new IOException("HMAC-SHA1 计算失败", e);
+        }
+    }
+
+    /** 上传结果。 */
+    public record UploadResult(String absoluteUrl, String relativeKey) { }
+}

+ 158 - 0
src/main/java/com/fujica/parkingtool/oss/PlateImageService.java

@@ -0,0 +1,158 @@
+package com.fujica.parkingtool.oss;
+
+import javafx.embed.swing.SwingFXUtils;
+import javafx.scene.Node;
+import javafx.scene.SnapshotParameters;
+import javafx.scene.image.WritableImage;
+import javafx.scene.paint.Color;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+
+/**
+ * 车牌截图 + 上传 OSS 的服务。
+ *
+ * <p>核心方法 {@link #uploadIfConfigured(PlatePreview, String, String, Consumer)} 在调用线程
+ * 完成"快照 → PNG → 上传 OSS"全过程;如果 {@link OssSettings#isUsable()} 为 false 直接返回 null。
+ * 内部带一个简单缓存(plate + sn 维度),24 小时内对同一标识不会重复上传。</p>
+ *
+ * <p>JavaFX 的 {@link javafx.scene.Node#snapshot} 必须在 FX 线程调用,
+ * 因此调用方需要先在 FX 线程拿到 {@link WritableImage} 再丢给本服务做 PNG 编码 + 上传。</p>
+ */
+public class PlateImageService {
+
+    private static final DateTimeFormatter DATE_DIR = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.ENGLISH);
+
+    /** 缓存:plateKey(sn|plate) -> (UploadResult, expiresAt-ms)。10 分钟过期。 */
+    private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>(64);
+    private static final long CACHE_TTL_MS = 10L * 60 * 1000;
+
+    /** OSS 配置(运行时可热更新) */
+    private volatile OssSettings settings;
+
+    public PlateImageService(OssSettings settings) {
+        this.settings = settings == null ? new OssSettings() : settings;
+    }
+
+    public void updateSettings(OssSettings newSettings) {
+        this.settings = newSettings == null ? new OssSettings() : newSettings;
+        // 配置变了缓存就清空
+        cache.clear();
+    }
+
+    public OssSettings settings() {
+        return settings;
+    }
+
+    /**
+     * 在 FX 线程同步给指定节点拍快照。返回 PNG 内存数据。
+     */
+    public static byte[] snapshotPng(Node node) throws IOException {
+        SnapshotParameters params = new SnapshotParameters();
+        params.setFill(Color.TRANSPARENT);
+        WritableImage wi = node.snapshot(params, null);
+        return toPng(wi);
+    }
+
+    /**
+     * 把 WritableImage 编码为 PNG bytes(可在任意线程调用)。
+     */
+    public static byte[] toPng(WritableImage wi) throws IOException {
+        if (wi == null) throw new IOException("快照为空");
+        BufferedImage img = SwingFXUtils.fromFXImage(wi, null);
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream(32 * 1024)) {
+            ImageIO.write(img, "PNG", baos);
+            return baos.toByteArray();
+        }
+    }
+
+    /**
+     * 配置可用则上传,否则返回 null。日志通过 {@code logger} 输出。
+     *
+     * @param pngBytes  已经编好码的 PNG bytes
+     * @param sn        设备 SN,用于路径前缀与缓存 key
+     * @param plate     车牌号(不含特殊后缀的明文)
+     * @param logger    日志回调,可为 null
+     * @return 上传结果,未配置或失败返回 null
+     */
+    public OssUploader.UploadResult uploadIfConfigured(byte[] pngBytes,
+                                                       String sn,
+                                                       String plate,
+                                                       Consumer<String> logger) {
+        OssSettings cfg = this.settings;
+        if (!cfg.isUsable()) {
+            return null; // 未配置或配置无效:上层会选择不附带图片
+        }
+        if (pngBytes == null || pngBytes.length == 0) {
+            log(logger, "⚠️ OSS 上传跳过:截图字节为空");
+            return null;
+        }
+        String cacheKey = (sn == null ? "" : sn) + "|" + (plate == null ? "" : plate);
+        CacheEntry cached = cache.get(cacheKey);
+        long now = System.currentTimeMillis();
+        if (cached != null && cached.expiresAt > now) {
+            log(logger, "♻️ OSS 命中缓存:" + cached.result.absoluteUrl());
+            return cached.result;
+        }
+        String key = buildKey(cfg, sn, plate);
+        log(logger, "📤 OSS 上传中 → " + key + " (" + pngBytes.length + " B)");
+        long t0 = System.currentTimeMillis();
+        try {
+            OssUploader.UploadResult res = OssUploader.put(cfg, key, pngBytes, "image/png");
+            cache.put(cacheKey, new CacheEntry(res, now + CACHE_TTL_MS));
+            log(logger, "✅ OSS 上传成功 (" + (System.currentTimeMillis() - t0) + "ms):"
+                    + res.absoluteUrl());
+            return res;
+        } catch (Throwable t) {
+            log(logger, "⚠️ OSS 上传失败:" + t.getMessage() + "(本次不带图片继续发送)");
+            return null;
+        }
+    }
+
+    private static String buildKey(OssSettings cfg, String sn, String plate) {
+        String prefix = cfg.getPathPrefix();
+        String day = LocalDate.now().format(DATE_DIR);
+        String plateSafe = sanitizeFileName(plate);
+        String snSafe    = sanitizeFileName(sn);
+        long ts = System.currentTimeMillis();
+        StringBuilder sb = new StringBuilder();
+        if (prefix != null && !prefix.isEmpty()) sb.append(prefix).append('/');
+        sb.append(day).append('/');
+        if (!snSafe.isEmpty()) sb.append(snSafe).append('/');
+        sb.append(plateSafe.isEmpty() ? "unknown" : plateSafe).append('-').append(ts).append(".png");
+        return sb.toString();
+    }
+
+    /** 中文 / 空白 / 特殊字符替换成 _,避免出现在 OSS key 里带来歧义 */
+    private static String sanitizeFileName(String raw) {
+        if (raw == null) return "";
+        StringBuilder out = new StringBuilder(raw.length());
+        for (int i = 0; i < raw.length(); i++) {
+            char c = raw.charAt(i);
+            if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
+                    || (c >= '0' && c <= '9')
+                    || c == '-' || c == '_') {
+                out.append(c);
+            } else {
+                out.append('_');
+            }
+        }
+        return out.toString();
+    }
+
+    private static void log(Consumer<String> logger, String msg) {
+        if (logger != null) {
+            try { logger.accept(msg); } catch (Throwable ignored) { }
+        }
+    }
+
+    private record CacheEntry(OssUploader.UploadResult result, long expiresAt) { }
+}

+ 143 - 0
src/main/java/com/fujica/parkingtool/ui/ConsoleView.java

@@ -0,0 +1,143 @@
+package com.fujica.parkingtool.ui;
+
+import javafx.application.Platform;
+import javafx.scene.control.TextArea;
+import javafx.scene.layout.StackPane;
+
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 控制台输出区域:黑底浅字、滚动条贴边,提供 line/raw 两类输出,线程安全。
+ *
+ * <p>额外提供轻量级搜索能力:基于 {@link TextArea#selectRange(int, int)} 高亮当前匹配,
+ * 配合 {@link #countMatches(String)} 与 {@link #currentMatchIndex(String)} 计算 "x / N"。</p>
+ */
+public class ConsoleView extends StackPane {
+
+    private static final DateTimeFormatter HMS = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
+
+    private final TextArea area = new TextArea();
+
+    /** 上次成功匹配的关键字,用于判断 findNext 是否要从选区末尾继续向后查找 */
+    private String lastQuery = "";
+
+    public ConsoleView() {
+        getStyleClass().add("console");
+        area.getStyleClass().add("console-area");
+        area.setEditable(false);
+        area.setWrapText(false);
+        area.setPrefRowCount(10);
+        getChildren().add(area);
+    }
+
+    /** 写入一行带时间戳的日志。 */
+    public void log(String message) {
+        appendOnFx("[" + LocalTime.now().format(HMS) + "] " + message + "\n");
+    }
+
+    /** 直接写入原始内容(不补换行)。 */
+    public void raw(String text) {
+        appendOnFx(text);
+    }
+
+    /** 清空。 */
+    public void clear() {
+        lastQuery = "";
+        if (Platform.isFxApplicationThread()) {
+            area.clear();
+        } else {
+            Platform.runLater(area::clear);
+        }
+    }
+
+    /* ============ 搜索 ============ */
+
+    /** 取消选区,重置搜索游标。 */
+    public void clearSelection() {
+        lastQuery = "";
+        area.deselect();
+    }
+
+    /** 整段文本里 query 出现的总次数(不区分大小写)。 */
+    public int countMatches(String query) {
+        if (query == null || query.isEmpty()) return 0;
+        String txt = area.getText().toLowerCase();
+        String q = query.toLowerCase();
+        int n = 0, i = 0;
+        while ((i = txt.indexOf(q, i)) >= 0) {
+            n++;
+            i += q.length();
+        }
+        return n;
+    }
+
+    /** 当前选中的匹配在所有匹配里的 1-based 序号;未匹配返回 0。 */
+    public int currentMatchIndex(String query) {
+        if (query == null || query.isEmpty()) return 0;
+        String txt = area.getText().toLowerCase();
+        String q = query.toLowerCase();
+        int sel = area.getSelection().getStart();
+        if (sel < 0) return 0;
+        int n = 0, i = 0;
+        while ((i = txt.indexOf(q, i)) >= 0) {
+            n++;
+            if (i == sel) return n;
+            i += q.length();
+        }
+        return 0;
+    }
+
+    /** 查找下一个匹配并选中,自动回绕到文档开头。 */
+    public boolean findNext(String query) {
+        return find(query, true);
+    }
+
+    /** 查找上一个匹配并选中,自动回绕到文档末尾。 */
+    public boolean findPrev(String query) {
+        return find(query, false);
+    }
+
+    private boolean find(String query, boolean forward) {
+        if (query == null || query.isEmpty()) {
+            clearSelection();
+            return false;
+        }
+        String text = area.getText();
+        String txt = text.toLowerCase();
+        String q   = query.toLowerCase();
+
+        int from;
+        if (forward) {
+            from = query.equals(lastQuery) ? area.getSelection().getEnd() : 0;
+            if (from < 0) from = 0;
+            int idx = txt.indexOf(q, from);
+            if (idx < 0) idx = txt.indexOf(q, 0); // wrap
+            if (idx < 0) {
+                clearSelection();
+                return false;
+            }
+            area.selectRange(idx, idx + q.length());
+        } else {
+            from = query.equals(lastQuery) ? area.getSelection().getStart() - 1 : text.length();
+            int idx = (from < 0) ? -1 : txt.lastIndexOf(q, from);
+            if (idx < 0) idx = txt.lastIndexOf(q);
+            if (idx < 0) {
+                clearSelection();
+                return false;
+            }
+            area.selectRange(idx, idx + q.length());
+        }
+        lastQuery = query;
+        // TextArea 的 selectRange 会移动 caret 并自动让 caret 处于可视范围
+        return true;
+    }
+
+    private void appendOnFx(String text) {
+        if (Platform.isFxApplicationThread()) {
+            area.appendText(text);
+        } else {
+            Platform.runLater(() -> area.appendText(text));
+        }
+    }
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1101 - 0
src/main/java/com/fujica/parkingtool/ui/MainView.java


+ 274 - 0
src/main/java/com/fujica/parkingtool/ui/MqttSettingsDialog.java

@@ -0,0 +1,274 @@
+package com.fujica.parkingtool.ui;
+
+import com.fujica.parkingtool.mqtt.ChannelKey;
+import com.fujica.parkingtool.mqtt.MqttSettings;
+import com.fujica.parkingtool.oss.OssSettings;
+import javafx.geometry.Insets;
+import javafx.scene.Node;
+import javafx.scene.control.ButtonBar;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.Label;
+import javafx.scene.control.PasswordField;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.Spinner;
+import javafx.scene.control.SpinnerValueFactory;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * 设置对话框:MQTT 双 Tab(入口/出口) + 阿里云 OSS Tab。
+ *
+ * <p>结果以 {@link Result} 形式返回:mqtt 配置按通道拆分,oss 单独一份。</p>
+ */
+public class MqttSettingsDialog extends Dialog<MqttSettingsDialog.Result> {
+
+    /**
+     * Dialog 返回结果。
+     *
+     * @param mqtt    每个通道的最新配置
+     * @param oss     OSS 最新配置
+     * @param restart 是否同时执行"重启通道"(断开 + 重连,并恢复心跳)
+     */
+    public record Result(Map<ChannelKey, MqttSettings> mqtt, OssSettings oss, boolean restart) { }
+
+    // 自定义按钮(取消 / 保存 / 保存并重启)
+    private static final ButtonType BTN_CANCEL =
+            new ButtonType("取消", ButtonBar.ButtonData.CANCEL_CLOSE);
+    private static final ButtonType BTN_SAVE =
+            new ButtonType("保存", ButtonBar.ButtonData.OK_DONE);
+    private static final ButtonType BTN_SAVE_RESTART =
+            new ButtonType("保存并重启", ButtonBar.ButtonData.APPLY);
+
+    private final Map<ChannelKey, Form> forms = new EnumMap<>(ChannelKey.class);
+    private final OssForm ossForm;
+    private final OssSettings initialOss;
+
+    public MqttSettingsDialog(Map<ChannelKey, MqttSettings> initialMqtt,
+                              OssSettings initialOss) {
+        this.initialOss = initialOss == null ? new OssSettings() : initialOss;
+
+        setTitle("连接设置");
+        setHeaderText("MQTT 连接、设备身份 与 阿里云 OSS 上传配置");
+        getDialogPane().getStyleClass().add("mqtt-settings");
+
+        TabPane tabs = new TabPane();
+        tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
+
+        for (ChannelKey key : ChannelKey.values()) {
+            Form form = new Form(initialMqtt.get(key));
+            forms.put(key, form);
+
+            ScrollPane sp = new ScrollPane(form.root);
+            sp.setFitToWidth(true);
+            sp.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
+            sp.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
+            sp.getStyleClass().add("settings-scroll");
+
+            Tab tab = new Tab(key.displayName + " · " + key.key);
+            tab.setContent(sp);
+            tabs.getTabs().add(tab);
+        }
+
+        // OSS Tab
+        ossForm = new OssForm(this.initialOss);
+        ScrollPane ossScroll = new ScrollPane(ossForm.root);
+        ossScroll.setFitToWidth(true);
+        ossScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
+        ossScroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
+        ossScroll.getStyleClass().add("settings-scroll");
+        Tab ossTab = new Tab("OSS · 图片上传");
+        ossTab.setContent(ossScroll);
+        tabs.getTabs().add(ossTab);
+
+        getDialogPane().setContent(tabs);
+        getDialogPane().getButtonTypes().setAll(BTN_CANCEL, BTN_SAVE, BTN_SAVE_RESTART);
+        getDialogPane().setPrefSize(640, 720);
+
+        // 让"保存并重启"显示为重点按钮
+        Node saveAndRestartNode = getDialogPane().lookupButton(BTN_SAVE_RESTART);
+        if (saveAndRestartNode != null) {
+            saveAndRestartNode.getStyleClass().add("btn-primary");
+        }
+
+        setResultConverter(btn -> {
+            if (btn == null || btn == BTN_CANCEL) return null;
+            boolean restart = (btn == BTN_SAVE_RESTART);
+            Map<ChannelKey, MqttSettings> result = new EnumMap<>(ChannelKey.class);
+            for (Map.Entry<ChannelKey, Form> e : forms.entrySet()) {
+                result.put(e.getKey(), e.getValue().toSettings(initialMqtt.get(e.getKey())));
+            }
+            OssSettings oss = ossForm.toSettings(this.initialOss);
+            return new Result(result, oss, restart);
+        });
+    }
+
+    /* ------------- 单通道表单 ------------- */
+
+    private static class Form {
+        final VBox root = new VBox();
+        final TextField hostField;
+        final Spinner<Integer> portSpinner;
+        final TextField usernameField;
+        final PasswordField passwordField;
+        final TextField clientIdField;
+        final TextField snField;
+        final Spinner<Integer> bvSpinner;
+        final TextField deviceIpField;
+        final TextField deviceNameField;
+        final Spinner<Integer> chSpinner;
+        final Spinner<Integer> keepAliveSpinner;
+        final Spinner<Integer> qosSpinner;
+
+        Form(MqttSettings s) {
+            hostField        = new TextField(s.getHost());
+            portSpinner      = intSpinner(1, 65535, s.getPort());
+            usernameField    = new TextField(s.getUsername());
+            passwordField    = new PasswordField();
+            passwordField.setText(s.getPassword());
+            clientIdField    = new TextField(s.getClientId());
+            snField          = new TextField(s.getSn());
+            bvSpinner        = intSpinner(0, 999999, s.getBoardVersion());
+            deviceIpField    = new TextField(s.getDeviceIp());
+            deviceNameField  = new TextField(s.getDeviceName());
+            chSpinner        = intSpinner(0, 64, s.getChannel());
+            keepAliveSpinner = intSpinner(10, 3600, s.getKeepAliveSec());
+            qosSpinner       = intSpinner(0, 2, s.getQos());
+
+            GridPane g = new GridPane();
+            g.setHgap(10);
+            g.setVgap(8);
+            g.setPadding(new Insets(16, 18, 16, 18));
+
+            int row = 0;
+            g.add(section("Broker"), 0, row++, 2, 1);
+            addRow(g, row++, "Host",      hostField);
+            addRow(g, row++, "Port",      portSpinner);
+            addRow(g, row++, "Username",  usernameField);
+            addRow(g, row++, "Password",  passwordField);
+            addRow(g, row++, "Client Id", clientIdField);
+            addRow(g, row++, "MQTT KeepAlive (s)", keepAliveSpinner);
+            addRow(g, row++, "QoS",       qosSpinner);
+
+            g.add(section("Device Identity"), 0, row++, 2, 1);
+            addRow(g, row++, "SN",          snField);
+            addRow(g, row++, "Board Ver",   bvSpinner);
+            addRow(g, row++, "Device IP",   deviceIpField);
+            addRow(g, row++, "Device Name", deviceNameField);
+            addRow(g, row,   "Channel",     chSpinner);
+
+            root.getChildren().add(g);
+        }
+
+        MqttSettings toSettings(MqttSettings initial) {
+            MqttSettings s = initial.copy();
+            s.setHost(hostField.getText().trim());
+            s.setPort(portSpinner.getValue());
+            s.setUsername(usernameField.getText().trim());
+            s.setPassword(passwordField.getText());
+            s.setClientId(clientIdField.getText().trim());
+            s.setSn(snField.getText().trim());
+            s.setBoardVersion(bvSpinner.getValue());
+            s.setDeviceIp(deviceIpField.getText().trim());
+            s.setDeviceName(deviceNameField.getText().trim());
+            s.setChannel(chSpinner.getValue());
+            s.setKeepAliveSec(keepAliveSpinner.getValue());
+            s.setQos(qosSpinner.getValue());
+            return s;
+        }
+    }
+
+    private static Label section(String title) {
+        Label l = new Label(title);
+        l.getStyleClass().add("section-title");
+        GridPane.setMargin(l, new Insets(8, 0, 4, 0));
+        return l;
+    }
+
+    private static void addRow(GridPane grid, int row, String label, Node field) {
+        Label l = new Label(label);
+        l.getStyleClass().add("field-label");
+        grid.add(l, 0, row);
+        grid.add(field, 1, row);
+        GridPane.setHgrow(field, Priority.ALWAYS);
+        if (field instanceof Region r) {
+            r.setMaxWidth(Double.MAX_VALUE);
+            r.setPrefWidth(280);
+        }
+    }
+
+    private static Spinner<Integer> intSpinner(int min, int max, int initial) {
+        Spinner<Integer> sp = new Spinner<>();
+        sp.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(min, max, initial));
+        sp.setEditable(true);
+        sp.setPrefWidth(120);
+        return sp;
+    }
+
+    /* ------------- OSS 表单 ------------- */
+
+    private static class OssForm {
+        final VBox root = new VBox();
+        final TextField addressField;
+        final TextField accessKeyField;
+        final PasswordField secretKeyField;
+        final TextField bucketField;
+        final TextField pathPrefixField;
+
+        OssForm(OssSettings s) {
+            addressField    = new TextField(s.getAddress());
+            addressField.setPromptText("如:oss-cn-hangzhou.aliyuncs.com");
+            accessKeyField  = new TextField(s.getAccessKey());
+            accessKeyField.setPromptText("AccessKey ID");
+            secretKeyField  = new PasswordField();
+            secretKeyField.setText(s.getSecretKey());
+            secretKeyField.setPromptText("AccessKey Secret");
+            bucketField     = new TextField(s.getBucket());
+            bucketField.setPromptText("Bucket 名称");
+            pathPrefixField = new TextField(s.getPathPrefix());
+            pathPrefixField.setPromptText("路径前缀(可选)");
+
+            GridPane g = new GridPane();
+            g.setHgap(10);
+            g.setVgap(8);
+            g.setPadding(new Insets(16, 18, 16, 18));
+
+            int row = 0;
+            g.add(section("阿里云 OSS"), 0, row++, 2, 1);
+            addRow(g, row++, "Address",     addressField);
+            addRow(g, row++, "AccessKey",   accessKeyField);
+            addRow(g, row++, "SecretKey",   secretKeyField);
+            addRow(g, row++, "Bucket",      bucketField);
+            addRow(g, row++, "Path Prefix", pathPrefixField);
+
+            Label hint = new Label(
+                    "· 全部 4 项必填后才会上传车牌截图,否则照旧发送(不带图片)。\n" +
+                    "· Address 不需要加 http(s):// 前缀,也不要包含 bucket 子域名。\n" +
+                    "· 上传路径形如:{prefix}/{yyyyMMdd}/{sn}/{plate}-{ts}.png");
+            hint.setWrapText(true);
+            hint.getStyleClass().add("settings-hint");
+            GridPane.setMargin(hint, new Insets(8, 0, 0, 0));
+            g.add(hint, 0, row, 2, 1);
+
+            root.getChildren().add(g);
+        }
+
+        OssSettings toSettings(OssSettings initial) {
+            OssSettings s = initial.copy();
+            s.setAddress(addressField.getText());
+            s.setAccessKey(accessKeyField.getText());
+            s.setSecretKey(secretKeyField.getText());
+            s.setBucket(bucketField.getText());
+            s.setPathPrefix(pathPrefixField.getText());
+            return s;
+        }
+    }
+}

+ 90 - 0
src/main/java/com/fujica/parkingtool/ui/PlateColor.java

@@ -0,0 +1,90 @@
+package com.fujica.parkingtool.ui;
+
+/**
+ * 车牌配色枚举。
+ *
+ * <p>严格对齐 devicedriveservice 的 {@code PlateResult.colorType} 枚举(见
+ * {@code IVS_RESULT_SUBSCRIPTION_GUIDE.md} §6.2),只有 5 个合法值:</p>
+ * <ul>
+ *   <li>1 - 蓝牌(普通小型车)</li>
+ *   <li>2 - 黄牌(大型车 / 教练车 / 摩托车)</li>
+ *   <li>3 - 白牌(警车 / 武警 / 军队 等)</li>
+ *   <li>4 - 黑牌(使领馆)</li>
+ *   <li>5 - 绿牌(新能源,含渐变)</li>
+ * </ul>
+ *
+ * <p>每个值携带:mqtt 协议码、用于 JavaFX 渲染的背景/字体/边框颜色,
+ * 以及"是否新能源"标志(影响布局:新能源车牌位数为 8 位且无中间圆点)。</p>
+ */
+public enum PlateColor {
+
+    BLUE(
+            "蓝牌",
+            1,
+            "linear-gradient(to bottom right, #1664d6 0%, #0a3a8e 100%)",
+            "#ffffff",
+            "#ffffff",
+            false),
+
+    YELLOW(
+            "黄牌",
+            2,
+            "linear-gradient(to bottom right, #ffd23f 0%, #f4b400 100%)",
+            "#111111",
+            "#111111",
+            false),
+
+    /** 警牌:白底 + 红色字符("警"等专用字符红色),边框也是红色 */
+    WHITE(
+            "警牌",
+            3,
+            "#ffffff",
+            "#e11d48",
+            "#111111",
+            false),
+
+    BLACK(
+            "黑牌",
+            4,
+            "linear-gradient(to bottom right, #2a2a2a 0%, #050505 100%)",
+            "#ffffff",
+            "#ffffff",
+            false),
+
+    /** 新能源车牌:白绿渐变,8 位字符,无中央圆点(替换为充电插头图标) */
+    GREEN(
+            "新能源",
+            5,
+            "linear-gradient(to bottom, #ffffff 0%, #e8f9ed 45%, #15b043 100%)",
+            "#111111",
+            "#111111",
+            true);
+
+    /** 显示标签 */
+    public final String label;
+    /** 对接 mqtt 服务的 PlateResult.colorType */
+    public final int colorCode;
+    /** 背景 CSS 表达式(渐变或纯色) */
+    public final String bg;
+    /** 边框颜色 */
+    public final String borderColor;
+    /** 字符颜色 */
+    public final String textColor;
+    /** 是否为新能源车牌 */
+    public final boolean nev;
+
+    PlateColor(String label, int colorCode, String bg,
+               String borderColor, String textColor, boolean nev) {
+        this.label = label;
+        this.colorCode = colorCode;
+        this.bg = bg;
+        this.borderColor = borderColor;
+        this.textColor = textColor;
+        this.nev = nev;
+    }
+
+    /** 普通车牌总位数(含省份),新能源 8 位、其它 7 位。 */
+    public int maxPlateLength() {
+        return nev ? 8 : 7;
+    }
+}

+ 277 - 0
src/main/java/com/fujica/parkingtool/ui/PlateGenerator.java

@@ -0,0 +1,277 @@
+package com.fujica.parkingtool.ui;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 车牌字符过滤、随机生成、省份识别工具。
+ *
+ * <p>过滤规则严格复刻 <code>mobile_plate_generator.html</code> 中的 <code>formatInput()</code>:</p>
+ * <ul>
+ *   <li>字符自动 <b>大写</b></li>
+ *   <li>去除标准车牌禁用的字母 <b>I</b> 和 <b>O</b>(避免与 1/0 混淆)</li>
+ *   <li>仅保留大陆 31 个省份 + 港 / 澳 / 使 + 警 / 学 / 挂 / 领 + A-Z + 0-9</li>
+ *   <li>「无牌车」是特殊关键字,原样保留</li>
+ * </ul>
+ *
+ * <p>另外针对中国车牌的真实规则补充:</p>
+ * <ul>
+ *   <li>普通车牌:省份(1) + 城市字母(1) + 5 位字符序列(其中字母 ≤ 2 个)</li>
+ *   <li>新能源车牌:省份(1) + 城市字母(1) + 能源标识(1, <b>D</b>/<b>F</b>) + 5 位字符序列(共 8 位)</li>
+ * </ul>
+ */
+public final class PlateGenerator {
+
+    /** 大陆 31 省份简称(可作为车牌首字) */
+    public static final String PROVINCES =
+            "京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云藏陕甘青宁新";
+
+    /** 港 / 澳 / 使 也可能作为车牌首字 */
+    public static final String EXTRA_PROVINCES = "港澳使";
+
+    /** 标准车牌字母池:A-Z 去掉 I、O */
+    public static final String LETTER_POOL = "ABCDEFGHJKLMNPQRSTUVWXYZ";
+
+    /** 标准车牌数字池 */
+    public static final String DIGIT_POOL = "0123456789";
+
+    /** 字母+数字(用于序号位) */
+    public static final String CHAR_POOL = LETTER_POOL + DIGIT_POOL;
+
+    /** 允许出现在字符位置的特殊后缀字符(警 / 学 / 挂 / 领) */
+    public static final String SUFFIX_CHARS = "警学挂领";
+
+    /** 新能源车牌"能源标识"位(位于城市码之后),仅 D / F */
+    public static final String NEV_ENERGY_LETTERS = "DF";
+
+    /** 序号 5 位中字母数量上限(防止字母与数字混淆) */
+    private static final int MAX_LETTERS_IN_SEQ = 2;
+
+    /** 用于判断是否为"专用红字"(警/学/使/领,白底警牌时显示红色) */
+    private static final String SPECIAL_RED_CHARS = "警学使领";
+
+    private static final String NO_PLATE_KEYWORD = "无牌车";
+
+    /** 整张允许的字符表,用于 {@link #sanitize(String)} 做白名单过滤 */
+    private static final String ALL_ALLOWED =
+            PROVINCES + EXTRA_PROVINCES + SUFFIX_CHARS + CHAR_POOL;
+
+    private PlateGenerator() {
+    }
+
+    /* =========================================================
+     *  Sanitize / Clamp(输入实时净化)
+     * =========================================================*/
+
+    /**
+     * 输入原始净化:仅做"大写 + 去 IO + 白名单过滤",<b>不</b>裁剪长度、<b>不</b>补省份。
+     * 这正是 HTML 中 <code>formatInput()</code> 的行为。
+     */
+    public static String sanitize(String text) {
+        if (text == null) return "";
+        if (NO_PLATE_KEYWORD.equals(text.trim())) return NO_PLATE_KEYWORD;
+        String upper = text.toUpperCase();
+        StringBuilder out = new StringBuilder(upper.length());
+        for (int i = 0; i < upper.length(); i++) {
+            char c = upper.charAt(i);
+            if (c == 'I' || c == 'O') continue;
+            if (ALL_ALLOWED.indexOf(c) >= 0) {
+                out.append(c);
+            }
+        }
+        return out.toString();
+    }
+
+    /**
+     * 取首字符判断是否为省份。返回 0 表示不是省份字符。
+     */
+    public static char detectProvince(String sanitized) {
+        if (sanitized == null || sanitized.isEmpty()) return 0;
+        char c = sanitized.charAt(0);
+        if (PROVINCES.indexOf(c) >= 0 || EXTRA_PROVINCES.indexOf(c) >= 0) return c;
+        return 0;
+    }
+
+    /**
+     * 按当前车牌颜色裁剪长度。<b>不</b>强制补省份:
+     * <ul>
+     *   <li>输入含省份:保留省份 + 正文,正文裁剪到 (maxLen-1) 位</li>
+     *   <li>输入不含省份:返回纯正文,最长裁剪到 (maxLen-1) 位</li>
+     *   <li>"无牌车" 原样返回</li>
+     * </ul>
+     */
+    public static String clamp(String sanitized, PlateColor color) {
+        if (NO_PLATE_KEYWORD.equals(sanitized)) return NO_PLATE_KEYWORD;
+        if (sanitized == null) return "";
+        int maxBody = color.maxPlateLength() - 1;
+        char prov = detectProvince(sanitized);
+        if (prov != 0) {
+            String body = sanitized.substring(1);
+            if (body.length() > maxBody) body = body.substring(0, maxBody);
+            return prov + body;
+        }
+        String body = sanitized;
+        if (body.length() > maxBody) body = body.substring(0, maxBody);
+        return body;
+    }
+
+    /**
+     * 返回"用于渲染的完整车牌"(保证含省份):若 clamped 已包含省份则原样返回,
+     * 否则在前面补 fallbackProvince。
+     */
+    public static String withFallbackProvince(String clamped, char fallbackProvince) {
+        if (NO_PLATE_KEYWORD.equals(clamped)) return NO_PLATE_KEYWORD;
+        if (clamped == null || clamped.isEmpty()) return String.valueOf(fallbackProvince);
+        if (detectProvince(clamped) != 0) return clamped;
+        return fallbackProvince + clamped;
+    }
+
+    /* =========================================================
+     *  Random(按真实车牌规则)
+     * =========================================================*/
+
+    /**
+     * 随机生成一个完整车牌号(含省份)。严格遵循中国车牌实际规则:
+     * <ul>
+     *   <li>省份:大陆 31 省随机一个</li>
+     *   <li>第 2 位:城市字母(A-Z 去 IO)</li>
+     *   <li>新能源额外第 3 位:D 或 F</li>
+     *   <li>剩余 5 位序号:字母 + 数字混合,字母数量不超过 {@value #MAX_LETTERS_IN_SEQ} 个</li>
+     * </ul>
+     */
+    public static String random(PlateColor color) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(PROVINCES.charAt(rand(PROVINCES.length())));
+        sb.append(LETTER_POOL.charAt(rand(LETTER_POOL.length())));
+        if (color.nev) {
+            sb.append(NEV_ENERGY_LETTERS.charAt(rand(NEV_ENERGY_LETTERS.length())));
+        }
+        appendSequence(sb, color.maxPlateLength() - sb.length());
+        return sb.toString();
+    }
+
+    /**
+     * 批量场景:在固定前缀基础上随机补足完整车牌。
+     * 前缀会先经 {@link #sanitize(String)} 处理。如果前缀缺位(如只给 "粤"),
+     * 自动补:城市字母 → 新能源标识 → 序号。
+     *
+     * @param prefix 用户给定前缀(如 "粤B")
+     * @param color  当前车牌颜色(决定 7/8 位)
+     */
+    public static String randomWithPrefix(String prefix, PlateColor color) {
+        String p = sanitize(prefix == null ? "" : prefix);
+        int maxLen = color.maxPlateLength();
+        if (p.length() > maxLen - 1) p = p.substring(0, maxLen - 1);
+
+        StringBuilder sb = new StringBuilder(p);
+
+        // 1) 省份
+        if (sb.length() == 0 || detectProvince(sb.toString()) == 0) {
+            // 前缀没有省份:在最前补一个随机省份(保持其它前缀字符不变)
+            String rest = sb.toString();
+            sb.setLength(0);
+            sb.append(PROVINCES.charAt(rand(PROVINCES.length())));
+            sb.append(rest);
+            // 重新裁剪以保证不超长
+            if (sb.length() > maxLen) sb.setLength(maxLen);
+        }
+        // 2) 城市字母(位置 = index 1)
+        if (sb.length() < 2) {
+            sb.append(LETTER_POOL.charAt(rand(LETTER_POOL.length())));
+        }
+        // 3) 新能源能源标识(位置 = index 2)
+        if (color.nev && sb.length() < 3) {
+            sb.append(NEV_ENERGY_LETTERS.charAt(rand(NEV_ENERGY_LETTERS.length())));
+        }
+        // 4) 序号补到 maxLen
+        appendSequence(sb, maxLen - sb.length());
+        return sb.toString();
+    }
+
+    /**
+     * 给 sb 追加 n 位序号字符(字母 + 数字混合),字母数量不超过
+     * {@value #MAX_LETTERS_IN_SEQ} 个。n &lt;= 0 时不做事。
+     */
+    private static void appendSequence(StringBuilder sb, int n) {
+        if (n <= 0) return;
+        int letters = 0;
+        for (int i = 0; i < n; i++) {
+            boolean wantLetter = letters < MAX_LETTERS_IN_SEQ && rand(3) == 0;
+            if (wantLetter) {
+                sb.append(LETTER_POOL.charAt(rand(LETTER_POOL.length())));
+                letters++;
+            } else {
+                sb.append(DIGIT_POOL.charAt(rand(DIGIT_POOL.length())));
+            }
+        }
+    }
+
+    private static int rand(int bound) {
+        return ThreadLocalRandom.current().nextInt(bound);
+    }
+
+    /* =========================================================
+     *  Validation(供发送前 / UI 提示用)
+     * =========================================================*/
+
+    /**
+     * 简单校验:plate 是否符合"中国车牌实际规则"。
+     * 不通过时返回详细原因,否则返回 null。
+     */
+    public static String validate(String plate, PlateColor color) {
+        if (plate == null) return "车牌为空";
+        if (isNoPlate(plate)) return null;
+        if (plate.length() != color.maxPlateLength()) {
+            return "长度应为 " + color.maxPlateLength() + " 位";
+        }
+        if (detectProvince(plate) == 0) {
+            return "首位应为省份字符";
+        }
+        char city = plate.charAt(1);
+        if (LETTER_POOL.indexOf(city) < 0) {
+            return "第 2 位应为字母(不含 I/O)";
+        }
+        int seqStart;
+        if (color.nev) {
+            char ev = plate.charAt(2);
+            if (NEV_ENERGY_LETTERS.indexOf(ev) < 0) {
+                return "新能源第 3 位应为 D 或 F";
+            }
+            seqStart = 3;
+        } else {
+            seqStart = 2;
+        }
+        int letters = 0;
+        for (int i = seqStart; i < plate.length(); i++) {
+            char c = plate.charAt(i);
+            if (LETTER_POOL.indexOf(c) >= 0) letters++;
+            else if (DIGIT_POOL.indexOf(c) >= 0) { /* ok */ }
+            else if (SUFFIX_CHARS.indexOf(c) >= 0 && i == plate.length() - 1) {
+                // 警/学/挂/领 可作为最后一位
+            } else {
+                return "第 " + (i + 1) + " 位字符不合法";
+            }
+        }
+        if (letters > MAX_LETTERS_IN_SEQ) {
+            return "序号位字母最多 " + MAX_LETTERS_IN_SEQ + " 个";
+        }
+        return null;
+    }
+
+    /* =========================================================
+     *  Helpers
+     * =========================================================*/
+
+    /** 是否是无牌车特殊关键字 */
+    public static boolean isNoPlate(String text) {
+        return text != null && NO_PLATE_KEYWORD.equals(text.trim());
+    }
+
+    public static String noPlateKeyword() {
+        return NO_PLATE_KEYWORD;
+    }
+
+    /** 是否是警/学/使/领等专用红字(仅白底警牌时显示红色) */
+    public static boolean isSpecialRedChar(char c) {
+        return SPECIAL_RED_CHARS.indexOf(c) >= 0;
+    }
+}

+ 202 - 0
src/main/java/com/fujica/parkingtool/ui/PlatePreview.java

@@ -0,0 +1,202 @@
+package com.fujica.parkingtool.ui;
+
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Label;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.shape.Circle;
+
+/**
+ * 车牌可视化预览。视觉风格参考 <code>mobile_plate_generator.html</code> 中的
+ * 物理车牌渲染(双层边框 + 四角螺丝 + 圆点/插头分隔 + 警牌红字),
+ * 但只保留稳定可控的 2D 表现,不做 3D 倾斜、滑块、放大等装饰逻辑。
+ *
+ * <p>布局:
+ * <pre>
+ *   StackPane (this, 背景渐变)
+ *     ├── 4 × 螺丝 (StackPane.alignment 摆 4 个角)
+ *     ├── 内层细边框 Region
+ *     └── 外层 1 行字符 HBox:省份  城市码  ·  X X X X X
+ * </pre>
+ */
+public class PlatePreview extends StackPane {
+
+    private static final double PLATE_HEIGHT = 80;
+    /** 普通车牌宽高比 440:140,新能源 480:140 */
+    private static final double RATIO_NORMAL = 440.0 / 140.0;
+    private static final double RATIO_NEV    = 480.0 / 140.0;
+
+    /** 螺丝半径 */
+    private static final double SCREW_RADIUS = 3.5;
+
+    private final HBox content = new HBox();
+    private final Region innerBorder = new Region();
+    private final Circle screwTL = new Circle(SCREW_RADIUS);
+    private final Circle screwTR = new Circle(SCREW_RADIUS);
+    private final Circle screwBL = new Circle(SCREW_RADIUS);
+    private final Circle screwBR = new Circle(SCREW_RADIUS);
+
+    private PlateColor color = PlateColor.BLUE;
+    private String fullText = "粤B29082";
+
+    public PlatePreview() {
+        getStyleClass().add("plate");
+        setAlignment(Pos.CENTER);
+        setMinHeight(PLATE_HEIGHT);
+        setPrefHeight(PLATE_HEIGHT);
+        setMaxHeight(PLATE_HEIGHT);
+        applyRatio();
+
+        innerBorder.getStyleClass().add("plate-inner-border");
+        innerBorder.setMouseTransparent(true);
+
+        for (Circle c : new Circle[]{screwTL, screwTR, screwBL, screwBR}) {
+            c.getStyleClass().add("plate-screw");
+        }
+        StackPane.setAlignment(screwTL, Pos.TOP_LEFT);
+        StackPane.setAlignment(screwTR, Pos.TOP_RIGHT);
+        StackPane.setAlignment(screwBL, Pos.BOTTOM_LEFT);
+        StackPane.setAlignment(screwBR, Pos.BOTTOM_RIGHT);
+        Insets screwMargin = new Insets(6, 6, 6, 6);
+        StackPane.setMargin(screwTL, screwMargin);
+        StackPane.setMargin(screwTR, screwMargin);
+        StackPane.setMargin(screwBL, screwMargin);
+        StackPane.setMargin(screwBR, screwMargin);
+
+        content.setAlignment(Pos.CENTER);
+        content.setSpacing(4);
+        content.setMouseTransparent(true);
+        StackPane.setMargin(content, new Insets(0, 14, 0, 14));
+
+        getChildren().addAll(innerBorder, screwTL, screwTR, screwBL, screwBR, content);
+
+        apply();
+    }
+
+    /** 设置车牌号文本(已 format 过的完整字符串,含省份;或者 "无牌车") */
+    public void setPlate(String value) {
+        this.fullText = (value == null || value.isBlank()) ? PlateGenerator.noPlateKeyword() : value;
+        renderContent();
+    }
+
+    /** 切换车牌颜色(同步切换布局:新能源为 8 位无圆点+插头图标) */
+    public void setColor(PlateColor c) {
+        this.color = c == null ? PlateColor.BLUE : c;
+        applyRatio();
+        apply();
+    }
+
+    /* =========================================================
+     *  Render
+     * =========================================================*/
+
+    private void applyRatio() {
+        double w = PLATE_HEIGHT * (color != null && color.nev ? RATIO_NEV : RATIO_NORMAL);
+        setMinWidth(w);
+        setPrefWidth(w);
+        setMaxWidth(w);
+    }
+
+    private void apply() {
+        // 背景 + 边框颜色经由 inline-style 注入,使每种颜色独立可调
+        setStyle(
+                "-fx-background-color: " + color.bg + ";" +
+                "-fx-border-color: " + color.borderColor + ";"
+        );
+        innerBorder.setStyle("-fx-border-color: " + color.borderColor + ";");
+        renderContent();
+    }
+
+    private void renderContent() {
+        content.getChildren().clear();
+
+        if (PlateGenerator.isNoPlate(fullText)) {
+            Label l = new Label("无 牌 车");
+            l.getStyleClass().add("plate-text");
+            l.setStyle("-fx-text-fill: " + color.textColor + ";");
+            content.getChildren().add(l);
+            return;
+        }
+
+        // 提取省份 + 后续
+        char province;
+        String body;
+        if (fullText.length() > 0) {
+            char first = fullText.charAt(0);
+            if (PlateGenerator.PROVINCES.indexOf(first) >= 0
+                    || PlateGenerator.EXTRA_PROVINCES.indexOf(first) >= 0) {
+                province = first;
+                body = fullText.substring(1);
+            } else {
+                province = '粤';
+                body = fullText;
+            }
+        } else {
+            province = '粤';
+            body = "";
+        }
+
+        // 城市码(首位字母)+ 剩余
+        char city = body.isEmpty() ? 'B' : body.charAt(0);
+        String rest = body.isEmpty() ? "" : body.substring(1);
+
+        // 1) 省份
+        Label provLabel = new Label(String.valueOf(province));
+        provLabel.getStyleClass().addAll("plate-text", "plate-text-province");
+        provLabel.setStyle("-fx-text-fill: " + color.textColor + ";");
+        content.getChildren().add(provLabel);
+
+        // 2) 城市码
+        Label cityLabel = new Label(String.valueOf(city));
+        cityLabel.getStyleClass().add("plate-text");
+        cityLabel.setStyle("-fx-text-fill: " + color.textColor + ";");
+        content.getChildren().add(cityLabel);
+
+        // 3) 分隔符
+        if (color.nev) {
+            content.getChildren().add(createNevPlug());
+        } else {
+            content.getChildren().add(createDot());
+        }
+
+        // 4) 剩余字符
+        int needTotal = color.maxPlateLength() - 2; // 已含省份+城市码
+        StringBuilder pad = new StringBuilder(rest);
+        while (pad.length() < needTotal) pad.append('8');
+        if (pad.length() > needTotal) pad.setLength(needTotal);
+
+        for (int i = 0; i < pad.length(); i++) {
+            char c = pad.charAt(i);
+            Label l = new Label(String.valueOf(c));
+            l.getStyleClass().add("plate-text");
+            boolean redChar = color == PlateColor.WHITE && PlateGenerator.isSpecialRedChar(c);
+            l.setStyle("-fx-text-fill: " + (redChar ? "#e11d48" : color.textColor) + ";");
+            content.getChildren().add(l);
+        }
+    }
+
+    /** 中央分隔圆点(蓝/黄/白/黑) */
+    private Region createDot() {
+        Region dot = new Region();
+        dot.getStyleClass().add("plate-dot");
+        dot.setMinSize(5, 5);
+        dot.setPrefSize(5, 5);
+        dot.setMaxSize(5, 5);
+        dot.setStyle("-fx-background-color: " + color.textColor + ";");
+        HBox.setMargin(dot, new Insets(0, 4, 0, 4));
+        return dot;
+    }
+
+    /** 新能源中央的"充电插头"小图标(用纯 CSS region 拼一个简化版) */
+    private Region createNevPlug() {
+        Region plug = new Region();
+        plug.getStyleClass().add("plate-nev-plug");
+        plug.setMinSize(16, 16);
+        plug.setPrefSize(16, 16);
+        plug.setMaxSize(16, 16);
+        HBox.setMargin(plug, new Insets(0, 4, 0, 4));
+        return plug;
+    }
+}

+ 79 - 0
src/main/java/com/fujica/parkingtool/ui/Telemetry.java

@@ -0,0 +1,79 @@
+package com.fujica.parkingtool.ui;
+
+import com.fujica.parkingtool.mqtt.ChannelKey;
+import javafx.application.Platform;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 运行期发送指标:
+ * <ul>
+ *   <li>按消息类型累计发送条数(ivs_result / gpio_in / barr_gate_status / keep_alive)</li>
+ *   <li>滚动保留最近 500 条发送记录(首插);超过即丢弃尾部</li>
+ *   <li>每次进程启动从 0 开始,不持久化</li>
+ * </ul>
+ * 写入方法 {@link #onSent(ChannelKey, String, String)} 可在任意线程被调用,
+ * 内部统一切到 FX 线程更新可观察属性,方便 UI 直接 bind。
+ */
+public class Telemetry {
+
+    private static final int MAX_ENTRIES = 500;
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss");
+
+    /** 想要展示在面板里的消息类型(含显示顺序) */
+    public static final String[] TRACKED_TYPES = {
+            "ivs_result", "gpio_in", "barr_gate_status", "keep_alive"
+    };
+
+    private final Map<String, IntegerProperty> counters = new LinkedHashMap<>();
+    private final ObservableList<Entry> entries =
+            FXCollections.observableArrayList();
+
+    public Telemetry() {
+        for (String t : TRACKED_TYPES) {
+            counters.put(t, new SimpleIntegerProperty(0));
+        }
+    }
+
+    /** 由 DeviceChannel 在任意线程调用 */
+    public void onSent(ChannelKey key, String name, String summary, String json) {
+        if (name == null) return;
+        Platform.runLater(() -> {
+            IntegerProperty c = counters.get(name);
+            if (c != null) c.set(c.get() + 1);
+
+            Entry e = new Entry(LocalTime.now(), key, name, summary, json);
+            entries.add(0, e);
+            while (entries.size() > MAX_ENTRIES) {
+                entries.remove(entries.size() - 1);
+            }
+        });
+    }
+
+    public IntegerProperty counterProperty(String name) {
+        return counters.computeIfAbsent(name, k -> new SimpleIntegerProperty(0));
+    }
+
+    public ObservableList<Entry> entries() {
+        return entries;
+    }
+
+    /** 单条记录。 */
+    public record Entry(LocalTime time, ChannelKey channel, String name,
+                        String summary, String json) {
+        public String formattedTime() { return TIME_FMT.format(time); }
+        public String channelTag() {
+            return channel == null ? "?" : channel.displayName;
+        }
+        public String detail() {
+            return summary == null ? "" : summary;
+        }
+    }
+}

+ 160 - 0
src/main/java/com/fujica/parkingtool/ui/TelemetryPanel.java

@@ -0,0 +1,160 @@
+package com.fujica.parkingtool.ui;
+
+import javafx.geometry.Insets;
+import javafx.geometry.Orientation;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+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;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+
+import javafx.scene.control.Button;
+
+/**
+ * 右下角"运行 · Telemetry"面板。
+ *
+ * <pre>
+ *   最近发送                            (可滚动 ListView,最多 500 条)
+ *   ─────────────────────────────────
+ *   报文 · Payload          [复制 JSON]
+ *   {                                  (TextArea,readonly,等宽字体)
+ *     ...
+ *   }
+ * </pre>
+ *
+ * 点击列表里任意一项,下方实时显示该报文的 pretty JSON。
+ */
+public class TelemetryPanel extends VBox {
+
+    private final TextArea detail = new TextArea();
+
+    public TelemetryPanel(Telemetry telemetry) {
+        setSpacing(0);
+        getStyleClass().add("telemetry-panel");
+
+        // ----- 上半:最近发送列表 -----
+        Label listTitle = new Label("最近发送");
+        listTitle.getStyleClass().add("subsection-title");
+
+        ListView<Telemetry.Entry> list = new ListView<>(telemetry.entries());
+        list.getStyleClass().add("telemetry-list");
+        list.setCellFactory(v -> new EntryCell());
+        list.setPlaceholder(placeholderLabel("等待发送…"));
+        VBox.setVgrow(list, Priority.ALWAYS);
+
+        VBox topBox = new VBox(6, listTitle, list);
+        VBox.setVgrow(list, Priority.ALWAYS);
+        topBox.setPadding(new Insets(0, 0, 6, 0));
+
+        // ----- 下半: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();
+            if (json == null || json.isEmpty()) return;
+            ClipboardContent c = new ClipboardContent();
+            c.putString(json);
+            Clipboard.getSystemClipboard().setContent(c);
+        });
+
+        Region sp = new Region();
+        HBox.setHgrow(sp, Priority.ALWAYS);
+        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");
+        VBox.setVgrow(detail, Priority.ALWAYS);
+
+        VBox bottomBox = new VBox(6, detailHead, detail);
+        bottomBox.setPadding(new Insets(6, 0, 0, 0));
+
+        list.getSelectionModel().selectedItemProperty().addListener((obs, oldV, newV) -> {
+            if (newV == null) { detail.clear(); return; }
+            detail.setText(newV.json() == null ? "" : newV.json());
+            detail.positionCaret(0);
+        });
+
+        // 列表更新自动跟随首条(如果用户没选)
+        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);
+            }
+        });
+
+        // ----- 上下分栏:可拖动 -----
+        SplitPane split = new SplitPane(topBox, bottomBox);
+        split.setOrientation(Orientation.VERTICAL);
+        split.setDividerPositions(0.5);
+        split.getStyleClass().add("telemetry-split");
+        VBox.setVgrow(split, Priority.ALWAYS);
+
+        getChildren().add(split);
+    }
+
+    private static Label placeholderLabel(String text) {
+        Label l = new Label(text);
+        l.getStyleClass().add("telemetry-placeholder");
+        return l;
+    }
+
+    /** 单条记录单元格。 */
+    private static final class EntryCell extends ListCell<Telemetry.Entry> {
+        private final Label timeLabel = new Label();
+        private final Label channelLabel = new Label();
+        private final Label nameLabel = new Label();
+        private final Label detailLabel = new Label();
+        private final HBox row;
+
+        EntryCell() {
+            timeLabel.getStyleClass().add("telemetry-entry-time");
+            channelLabel.getStyleClass().add("telemetry-entry-channel");
+            nameLabel.getStyleClass().add("telemetry-entry-name");
+            detailLabel.getStyleClass().add("telemetry-entry-detail");
+            detailLabel.setMaxWidth(Double.MAX_VALUE);
+            HBox.setHgrow(detailLabel, Priority.ALWAYS);
+
+            row = new HBox(8, timeLabel, channelLabel, nameLabel, detailLabel);
+            row.setAlignment(Pos.CENTER_LEFT);
+            row.getStyleClass().add("telemetry-entry-row");
+            setPadding(new Insets(0));
+        }
+
+        @Override
+        protected void updateItem(Telemetry.Entry item, boolean empty) {
+            super.updateItem(item, empty);
+            if (empty || item == null) {
+                setGraphic(null);
+                setText(null);
+                return;
+            }
+            timeLabel.setText(item.formattedTime());
+            channelLabel.setText(item.channelTag());
+            channelLabel.getStyleClass().removeAll("entry-chip-entry", "entry-chip-exit");
+            if (item.channel() != null) {
+                channelLabel.getStyleClass().add(
+                        item.channel().name().equals("ENTRY")
+                                ? "entry-chip-entry" : "entry-chip-exit");
+            }
+            nameLabel.setText(item.name());
+            detailLabel.setText(item.detail());
+            setGraphic(row);
+            setText(null);
+        }
+    }
+}

+ 105 - 0
src/main/java/com/fujica/parkingtool/ui/ToggleSwitch.java

@@ -0,0 +1,105 @@
+package com.fujica.parkingtool.ui;
+
+import javafx.animation.Interpolator;
+import javafx.animation.TranslateTransition;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.css.PseudoClass;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Label;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.util.Duration;
+
+/**
+ * 左右滑动样式的开关控件(iOS / Material 风)。
+ *
+ * <p>由 HBox 组成:右侧标签 + 左侧 track + thumb。
+ * 选中状态切换时 thumb 横向滑动并应用 :on 伪类,由 CSS 控制颜色与高光。</p>
+ */
+public class ToggleSwitch extends HBox {
+
+    private static final double TRACK_W = 40;
+    private static final double TRACK_H = 22;
+    private static final double THUMB   = 16;
+    private static final double PADDING = 3;
+
+    private static final PseudoClass ON = PseudoClass.getPseudoClass("on");
+
+    private final StackPane track = new StackPane();
+    private final Region    thumb = new Region();
+    private final Label     text  = new Label();
+
+    private final BooleanProperty selected = new SimpleBooleanProperty(false);
+
+    public ToggleSwitch() {
+        this("");
+    }
+
+    public ToggleSwitch(String label) {
+        getStyleClass().add("toggle-switch-row");
+        setAlignment(Pos.CENTER_LEFT);
+        setSpacing(8);
+
+        track.getStyleClass().add("toggle-switch");
+        track.setMinSize(TRACK_W, TRACK_H);
+        track.setPrefSize(TRACK_W, TRACK_H);
+        track.setMaxSize(TRACK_W, TRACK_H);
+
+        thumb.getStyleClass().add("toggle-thumb");
+        thumb.setMinSize(THUMB, THUMB);
+        thumb.setPrefSize(THUMB, THUMB);
+        thumb.setMaxSize(THUMB, THUMB);
+        StackPane.setAlignment(thumb, Pos.CENTER_LEFT);
+        StackPane.setMargin(thumb, new Insets(0, 0, 0, PADDING));
+        track.getChildren().add(thumb);
+
+        text.setText(label);
+        text.getStyleClass().add("toggle-switch-label");
+
+        getChildren().addAll(track, text);
+
+        track.setOnMouseClicked(e -> selected.set(!selected.get()));
+        text.setOnMouseClicked(e -> selected.set(!selected.get()));
+
+        selected.addListener((o, oldV, newV) -> applyState(newV, true));
+        applyState(false, false);
+    }
+
+    /**
+     * 切换 thumb 位置与样式。
+     *
+     * @param on        新状态
+     * @param animate   是否使用过渡动画(首次初始化时关闭)
+     */
+    private void applyState(boolean on, boolean animate) {
+        track.pseudoClassStateChanged(ON, on);
+        double targetX = on ? (TRACK_W - THUMB - PADDING * 2) : 0;
+        if (!animate) {
+            thumb.setTranslateX(targetX);
+            return;
+        }
+        TranslateTransition tt = new TranslateTransition(Duration.millis(140), thumb);
+        tt.setToX(targetX);
+        tt.setInterpolator(Interpolator.EASE_BOTH);
+        tt.play();
+    }
+
+    public BooleanProperty selectedProperty() {
+        return selected;
+    }
+
+    public boolean isSelected() {
+        return selected.get();
+    }
+
+    public void setSelected(boolean v) {
+        selected.set(v);
+    }
+
+    public void setText(String label) {
+        text.setText(label);
+    }
+}

+ 26 - 0
src/main/java/module-info.java

@@ -0,0 +1,26 @@
+module com.fujica.parkingtool {
+    requires javafx.controls;
+    requires javafx.graphics;
+    requires javafx.swing;       // SwingFXUtils(车牌截图 → BufferedImage)
+    requires atlantafx.base;
+
+    requires java.prefs;
+    requires java.desktop;       // ImageIO + BufferedImage
+    requires java.net.http;      // OSS PUT 上传走 java.net.http.HttpClient
+
+    // MQTT 客户端(Paho v3,已带 Automatic-Module-Name)
+    requires org.eclipse.paho.client.mqttv3;
+
+    // Jackson JSON
+    requires com.fasterxml.jackson.databind;
+    requires com.fasterxml.jackson.core;
+    requires com.fasterxml.jackson.annotation;
+
+    exports com.fujica.parkingtool;
+    exports com.fujica.parkingtool.ui;
+    exports com.fujica.parkingtool.mqtt;
+    exports com.fujica.parkingtool.oss;
+
+    // 让 Jackson 可以反射读取我们的 POJO(消息 DTO)
+    opens com.fujica.parkingtool.mqtt to com.fasterxml.jackson.databind;
+}

+ 587 - 0
src/main/resources/com/fujica/parkingtool/styles/app.css

@@ -0,0 +1,587 @@
+/* ============================================================
+ *  Parking Simulator · 现代深色 UI(基于 AtlantaFX PrimerDark)
+ * ============================================================ */
+
+.root {
+    -fx-font-family: "Inter", "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
+    -fx-font-size: 13px;
+
+    -app-bg-0:      #070912;
+    -app-bg-1:      #0d1224;
+    -app-bg-2:      #131a30;
+
+    -app-panel:     rgba(255,255,255,0.035);
+    -app-panel-bd:  rgba(255,255,255,0.07);
+    -app-panel-bd-strong: rgba(255,255,255,0.14);
+
+    -app-text:      #e6eaff;
+    -app-text-dim:  #8b94b9;
+    -app-text-weak: #6b7398;
+
+    -app-accent:      #5b8cff;
+    -app-accent-soft: rgba(91,140,255,0.18);
+    -app-accent-2:    #29e3d7;
+    -app-pink:        #ff5fb0;
+    -app-green:       #2ee07a;
+    -app-red:         #ff5d6c;
+}
+
+/* 整体背景:极简深空,少量光感 */
+.main-view {
+    -fx-background-color:
+        radial-gradient(center 10% 0%, radius 75%, rgba(91,140,255,0.10), transparent 60%),
+        radial-gradient(center 95% 100%, radius 60%, rgba(41,227,215,0.08), transparent 65%),
+        linear-gradient(to bottom right, -app-bg-0, -app-bg-1 60%, -app-bg-2);
+}
+
+/* ============ 顶栏(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;
+    /* HeaderBar 自身会保留系统按钮(macOS 红黄绿)的安全区域,
+       这里只做视觉留白 */
+    -fx-padding: 8 14 8 14;
+    -fx-min-height: 44;
+    -fx-pref-height: 48;
+}
+.header-bar .brand {
+    -fx-font-size: 14px;
+    -fx-font-weight: 800;
+    -fx-text-fill: -app-text;
+    -fx-letter-spacing: 0.04em;
+}
+.header-bar .brand-sub {
+    -fx-text-fill: -app-text-weak;
+    -fx-font-size: 10px;
+    -fx-font-weight: 600;
+    -fx-letter-spacing: 0.04em;
+}
+
+/* 顶栏胶囊按钮(清空 / 设置) */
+.btn-pill.button {
+    -fx-background-color: rgba(255,255,255,0.05);
+    -fx-background-radius: 999;
+    -fx-border-color: -app-panel-bd-strong;
+    -fx-border-radius: 999;
+    -fx-border-width: 1;
+    -fx-text-fill: -app-text;
+    -fx-font-weight: 600;
+    -fx-padding: 6 14;
+}
+.btn-pill.button:hover {
+    -fx-background-color: rgba(91,140,255,0.16);
+    -fx-border-color: -app-accent;
+    -fx-text-fill: #ffffff;
+}
+
+/* ============ 通道连接 Chip(入口/出口) ============ */
+.ch-chip {
+    -fx-background-color: rgba(255,255,255,0.04);
+    -fx-background-radius: 999;
+    -fx-border-radius: 999;
+    -fx-border-width: 1;
+    -fx-border-color: -app-panel-bd-strong;
+}
+.ch-chip-on  { -fx-border-color: rgba(46,224,122,0.55); }
+.ch-chip-off { -fx-border-color: rgba(255,93,108,0.45); }
+.ch-dot {
+    -fx-min-width: 8; -fx-min-height: 8;
+    -fx-max-width: 8; -fx-max-height: 8;
+    -fx-background-radius: 8;
+}
+.ch-dot.on  {
+    -fx-background-color: -app-green;
+    -fx-effect: dropshadow(gaussian, rgba(46,224,122,0.75), 8, 0.6, 0, 0);
+}
+.ch-dot.off {
+    -fx-background-color: -app-red;
+    -fx-effect: dropshadow(gaussian, rgba(255,93,108,0.55), 6, 0.4, 0, 0);
+}
+.ch-name {
+    -fx-text-fill: -app-text;
+    -fx-font-weight: 700;
+    -fx-font-size: 12px;
+    -fx-letter-spacing: 0.06em;
+}
+
+/* ============ 卡片 ============ */
+.card {
+    -fx-background-color: -app-panel;
+    -fx-background-radius: 14;
+    -fx-border-color: -app-panel-bd;
+    -fx-border-radius: 14;
+    -fx-border-width: 1;
+}
+
+.section-title {
+    -fx-text-fill: -app-text;
+    -fx-font-size: 11px;
+    -fx-font-weight: 800;
+    -fx-letter-spacing: 0.14em;
+    -fx-padding: 0 0 2 0;
+}
+
+.subsection-title {
+    -fx-text-fill: -app-text-dim;
+    -fx-font-size: 10px;
+    -fx-font-weight: 800;
+    -fx-letter-spacing: 0.14em;
+    -fx-padding: 4 0 0 0;
+}
+
+.divider {
+    -fx-pref-height: 1;
+    -fx-min-height: 1;
+    -fx-background-color: rgba(255,255,255,0.06);
+}
+
+.field-label {
+    -fx-text-fill: -app-text-dim;
+    -fx-font-size: 12px;
+    -fx-font-weight: 600;
+}
+
+.sn-text {
+    -fx-text-fill: -app-text-weak;
+    -fx-font-size: 11px;
+    -fx-font-family: "JetBrains Mono", "Menlo", monospace;
+}
+
+/* ============ 输入框 ============ */
+.text-field, .spinner .text-field, .date-picker .text-field, .password-field {
+    -fx-background-color: rgba(255,255,255,0.035);
+    -fx-background-radius: 8;
+    -fx-border-color: rgba(255,255,255,0.10);
+    -fx-border-radius: 8;
+    -fx-text-fill: -app-text;
+    -fx-prompt-text-fill: -app-text-weak;
+    -fx-padding: 6 10 6 10;
+}
+.text-field:focused, .spinner:focused .text-field,
+.date-picker:focused .text-field, .password-field:focused {
+    -fx-border-color: -app-accent;
+    -fx-effect: dropshadow(gaussian, rgba(91,140,255,0.30), 8, 0.3, 0, 0);
+}
+
+.plate-input {
+    -fx-font-family: "JetBrains Mono", "Consolas", monospace;
+    -fx-font-weight: 700;
+    -fx-font-size: 14px;
+}
+
+/* Spinner */
+.dark-spinner .increment-arrow-button,
+.dark-spinner .decrement-arrow-button {
+    -fx-background-color: rgba(255,255,255,0.04);
+    -fx-background-radius: 0;
+}
+.dark-spinner .increment-arrow-button:hover,
+.dark-spinner .decrement-arrow-button:hover {
+    -fx-background-color: rgba(91,140,255,0.20);
+}
+.dark-spinner .increment-arrow,
+.dark-spinner .decrement-arrow {
+    -fx-background-color: -app-text-dim;
+}
+
+.time-sep {
+    -fx-text-fill: -app-text-dim;
+    -fx-font-weight: 700;
+    -fx-padding: 0 2;
+}
+
+/* ============ Console ============ */
+.console {
+    -fx-background-color: #050811;
+    -fx-background-radius: 10;
+    -fx-border-color: rgba(255,255,255,0.06);
+    -fx-border-radius: 10;
+    -fx-border-width: 1;
+    -fx-min-height: 160;
+}
+.console-area, .console-area .content {
+    -fx-background-color: transparent;
+}
+.console-area {
+    -fx-text-fill: #c9d2f0;
+    -fx-font-family: "JetBrains Mono", "Menlo", "Consolas", monospace;
+    -fx-font-size: 12px;
+    -fx-padding: 8 6 8 8;
+    /* 搜索匹配高亮:TextArea selection 颜色 */
+    -fx-highlight-fill: rgba(91,140,255,0.45);
+    -fx-highlight-text-fill: #ffffff;
+}
+
+/* 日志卡片右上角搜索框:紧凑、低调 */
+.console-search.text-field {
+    -fx-background-color: rgba(255,255,255,0.04);
+    -fx-background-radius: 999;
+    -fx-border-color: rgba(255,255,255,0.10);
+    -fx-border-radius: 999;
+    -fx-border-width: 1;
+    -fx-padding: 3 10 3 12;
+    -fx-font-size: 11px;
+    -fx-pref-height: 26;
+}
+.console-search.text-field:focused {
+    -fx-border-color: -app-accent;
+    -fx-effect: dropshadow(gaussian, rgba(91,140,255,0.30), 6, 0.3, 0, 0);
+}
+.console-search-counter {
+    -fx-text-fill: -app-text-weak;
+    -fx-font-family: "JetBrains Mono", "Menlo", monospace;
+    -fx-font-size: 11px;
+    -fx-padding: 0 4;
+    -fx-min-width: 28;
+}
+
+/* ============ RadioButton / CheckBox ============ */
+.radio-button .radio,
+.check-box .box {
+    -fx-background-color: rgba(255,255,255,0.04);
+    -fx-border-color: rgba(255,255,255,0.20);
+    -fx-border-width: 1.2;
+}
+.radio-button:selected .radio { -fx-border-color: -app-accent; }
+.radio-button:selected .radio .dot {
+    -fx-background-color: -app-accent;
+    -fx-background-insets: 0;
+}
+.check-box:selected .box {
+    -fx-background-color: -app-accent;
+    -fx-border-color: transparent;
+}
+.check-box:selected .mark { -fx-background-color: white; }
+.radio-button, .check-box { -fx-text-fill: -app-text; }
+
+.color-swatch {
+    -fx-min-width: 14; -fx-min-height: 14;
+    -fx-max-width: 14; -fx-max-height: 14;
+    -fx-background-radius: 3;
+    -fx-border-color: rgba(255,255,255,0.20);
+    -fx-border-radius: 3;
+}
+
+/* ============ Toggle Switch(左右滑动开关) ============ */
+.toggle-switch-row { -fx-cursor: hand; }
+.toggle-switch-row .toggle-switch-label {
+    -fx-text-fill: -app-text-dim;
+    -fx-font-size: 12px;
+    -fx-font-weight: 700;
+}
+.toggle-switch {
+    -fx-background-color: rgba(255,255,255,0.10);
+    -fx-background-radius: 22;
+    -fx-border-color: rgba(255,255,255,0.14);
+    -fx-border-radius: 22;
+    -fx-border-width: 1;
+    -fx-cursor: hand;
+}
+.toggle-switch:on {
+    -fx-background-color: linear-gradient(to right, #6c95ff, #4a73f0);
+    -fx-border-color: -app-accent;
+    -fx-effect: dropshadow(gaussian, rgba(91,140,255,0.45), 8, 0.25, 0, 0);
+}
+.toggle-thumb {
+    -fx-background-color: white;
+    -fx-background-radius: 16;
+    -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.45), 4, 0.2, 0, 1);
+}
+
+/* ============ 尾字快捷按钮(警/学/挂/港/澳/领) ============ */
+.suffix-chip {
+    -fx-background-color: rgba(255,255,255,0.05);
+    -fx-background-radius: 999;
+    -fx-border-color: rgba(255,255,255,0.14);
+    -fx-border-radius: 999;
+    -fx-border-width: 1;
+    -fx-text-fill: -app-text;
+    -fx-font-weight: 800;
+    -fx-font-size: 12px;
+    -fx-padding: 4 12;
+    -fx-cursor: hand;
+}
+.suffix-chip:hover {
+    -fx-background-color: rgba(91,140,255,0.18);
+    -fx-border-color: -app-accent;
+    -fx-text-fill: #ffffff;
+}
+.suffix-chip:armed {
+    -fx-background-color: rgba(91,140,255,0.28);
+}
+
+/* ============ Plate Preview ============ */
+/* 模拟物理车牌:外层圆角 + 双层边框 + 四角螺丝 + 字符浮雕阴影 */
+.plate {
+    -fx-background-radius: 10;
+    -fx-border-radius: 10;
+    -fx-border-width: 2;
+    /* 边框颜色由 PlatePreview 在代码里按 PlateColor 动态注入 */
+    -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.55), 20, 0.0, 0, 5);
+}
+/* 第二层内边框(贴外框 4px 内缩) */
+.plate-inner-border {
+    -fx-background-color: transparent;
+    -fx-border-radius: 6;
+    -fx-border-width: 1;
+    -fx-background-insets: 4;
+    -fx-border-insets: 4;
+}
+/* 四角螺丝(金属高光小圆) */
+.plate-screw {
+    -fx-fill: radial-gradient(focus-angle 315deg, focus-distance 25%, center 50% 50%,
+                              radius 60%,
+                              #f5f5f5 0%, #c8c8c8 45%, #6e6e6e 90%, #444 100%);
+    -fx-stroke: rgba(0,0,0,0.45);
+    -fx-stroke-width: 0.5;
+}
+.plate-text {
+    -fx-font-family: "JetBrains Mono", "Consolas", monospace;
+    -fx-font-size: 30px;
+    -fx-font-weight: 800;
+    -fx-letter-spacing: 0.06em;
+    -fx-effect: dropshadow(one-pass-box, rgba(0,0,0,0.55), 1.5, 0.0, 1, 1);
+}
+/* 省份汉字单独字体 */
+.plate-text-province {
+    -fx-font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
+    -fx-font-size: 28px;
+}
+/* 中央分隔圆点 */
+.plate-dot {
+    -fx-background-radius: 999;
+    -fx-effect: dropshadow(one-pass-box, rgba(0,0,0,0.6), 1, 0.0, 0.5, 0.5);
+}
+/* 新能源充电插头小图标(简化版) */
+.plate-nev-plug {
+    -fx-background-color:
+        /* 插头主体 */
+        radial-gradient(center 50% 60%, radius 38%, #19af46 0%, #19af46 60%, transparent 61%),
+        /* 充电线 */
+        linear-gradient(from 60% 75% to 60% 100%, #19af46, #19af46);
+    -fx-background-radius: 3;
+}
+
+/* ============ Segmented Toggle(通道切换) ============ */
+.segmented {
+    -fx-background-color: rgba(255,255,255,0.04);
+    -fx-background-radius: 10;
+    -fx-border-color: -app-panel-bd-strong;
+    -fx-border-radius: 10;
+    -fx-padding: 3;
+}
+.segmented .segment.toggle-button {
+    -fx-background-color: transparent;
+    -fx-background-radius: 8;
+    -fx-text-fill: -app-text-dim;
+    -fx-font-weight: 700;
+    -fx-padding: 8 16;
+    -fx-border-width: 0;
+}
+.segmented .segment.toggle-button:hover {
+    -fx-text-fill: -app-text;
+}
+.segmented .segment.toggle-button:selected {
+    -fx-background-color: linear-gradient(to bottom, #5b8cff, #4a73f0);
+    -fx-text-fill: white;
+    -fx-effect: dropshadow(gaussian, rgba(91,140,255,0.40), 10, 0.2, 0, 0);
+}
+
+/* ============ 按钮 ============ */
+
+/* 主按钮(强调色) */
+.btn-primary.button {
+    -fx-background-color: linear-gradient(to bottom, #6c95ff, #4a73f0);
+    -fx-background-radius: 8;
+    -fx-text-fill: white;
+    -fx-font-weight: 700;
+    -fx-padding: 7 16;
+    -fx-border-width: 0;
+}
+.btn-primary.button:hover {
+    -fx-background-color: linear-gradient(to bottom, #7da3ff, #5b8cff);
+}
+.btn-primary.danger.button, .btn-primary.button.danger {
+    -fx-background-color: linear-gradient(to bottom, #ff7585, #ef4a5d);
+}
+.btn-primary.button.danger:hover {
+    -fx-background-color: linear-gradient(to bottom, #ff8a98, #ff5d6c);
+}
+
+/* 三个动作大按钮 */
+.btn-neon {
+    -fx-background-color: rgba(255,255,255,0.04);
+    -fx-background-radius: 10;
+    -fx-border-color: -app-panel-bd-strong;
+    -fx-border-radius: 10;
+    -fx-border-width: 1;
+    -fx-text-fill: -app-text;
+    -fx-font-weight: 700;
+    -fx-padding: 8 12;
+}
+.btn-neon:hover {
+    -fx-background-color: rgba(91,140,255,0.14);
+    -fx-border-color: -app-accent;
+    -fx-text-fill: #ffffff;
+}
+.btn-neon-accent {
+    -fx-background-color: linear-gradient(to right, #5b8cff, #8c5bff);
+    -fx-border-color: transparent;
+    -fx-text-fill: white;
+}
+.btn-neon-accent:hover {
+    -fx-background-color: linear-gradient(to right, #6e9dff, #9d6fff);
+}
+
+/* ============ Dialog & Tabs ============ */
+.dialog-pane {
+    -fx-background-color: -app-bg-1;
+}
+
+.settings-scroll, .settings-scroll > .viewport {
+    -fx-background-color: transparent;
+}
+.settings-scroll > .scroll-bar:vertical {
+    -fx-background-color: transparent;
+}
+.dialog-pane > .header-panel {
+    -fx-background-color: rgba(91,140,255,0.10);
+}
+.dialog-pane > .header-panel .label {
+    -fx-text-fill: -app-text;
+}
+.dialog-pane > .content { -fx-text-fill: -app-text; }
+
+.tab-pane .tab {
+    -fx-background-color: rgba(255,255,255,0.03);
+    -fx-padding: 6 18;
+    -fx-background-radius: 8 8 0 0;
+}
+.tab-pane .tab:selected {
+    -fx-background-color: rgba(91,140,255,0.20);
+}
+.tab-pane .tab-header-area .tab-header-background {
+    -fx-background-color: transparent;
+}
+.tab .tab-label { -fx-text-fill: -app-text; -fx-font-weight: 600; }
+
+/* DatePicker 弹窗 */
+.date-picker-popup { -fx-background-color: -app-bg-2; -fx-border-color: rgba(255,255,255,0.10); }
+
+/* 滚动条精简 */
+.scroll-bar:horizontal, .scroll-bar:vertical { -fx-background-color: transparent; }
+.scroll-bar .thumb { -fx-background-color: rgba(255,255,255,0.18); -fx-background-radius: 6; }
+.scroll-bar .thumb:hover { -fx-background-color: rgba(91,140,255,0.45); }
+.scroll-bar .track { -fx-background-color: transparent; }
+.scroll-bar .increment-button, .scroll-bar .decrement-button { -fx-pref-width: 0; -fx-pref-height: 0; }
+.scroll-bar .increment-arrow, .scroll-bar .decrement-arrow { -fx-shape: ""; -fx-padding: 0; }
+
+/* ============ 设置面板:OSS 提示文案 ============ */
+.settings-hint {
+    -fx-text-fill: -app-text-weak;
+    -fx-font-size: 11px;
+    -fx-line-spacing: 2;
+    -fx-padding: 6 0 0 0;
+}
+
+/* ============ Telemetry 卡片 ============ */
+.telemetry-panel {
+    -fx-padding: 4 0 0 0;
+}
+
+/* 上下分栏:去掉默认底色 / 边框,分割条做成极细一条 */
+.telemetry-split {
+    -fx-background-color: transparent;
+    -fx-padding: 0;
+}
+.telemetry-split > .split-pane-divider {
+    -fx-padding: 0 0 0 0;
+    -fx-background-color: rgba(255,255,255,0.06);
+    -fx-pref-height: 1;
+}
+
+/* JSON 详情 TextArea */
+.telemetry-detail {
+    -fx-control-inner-background: rgba(0,0,0,0.18);
+    -fx-text-fill: -app-text;
+    -fx-background-color: transparent;
+    -fx-background-radius: 8;
+    -fx-border-color: rgba(255,255,255,0.08);
+    -fx-border-radius: 8;
+    -fx-font-family: "JetBrains Mono", "SF Mono", "Menlo", monospace;
+    -fx-font-size: 11.5px;
+}
+.telemetry-detail .content {
+    -fx-background-color: transparent;
+}
+.telemetry-detail .text {
+    -fx-fill: -app-text;
+}
+.telemetry-detail:focused {
+    -fx-border-color: rgba(91,140,255,0.45);
+}
+
+/* 最近发送列表 */
+.telemetry-list {
+    -fx-background-color: transparent;
+    -fx-control-inner-background: transparent;
+    -fx-control-inner-background-alt: transparent;
+    -fx-padding: 0;
+    -fx-border-color: transparent;
+    -fx-background-insets: 0;
+}
+.telemetry-list .list-cell {
+    -fx-background-color: transparent;
+    -fx-text-fill: -app-text;
+    -fx-padding: 2 6 2 6;
+}
+.telemetry-list .list-cell:filled:hover {
+    -fx-background-color: rgba(91,140,255,0.07);
+    -fx-background-radius: 6;
+}
+.telemetry-list .list-cell:filled:selected {
+    -fx-background-color: rgba(91,140,255,0.15);
+    -fx-background-radius: 6;
+}
+.telemetry-list .scroll-bar:vertical { -fx-pref-width: 6; }
+
+.telemetry-entry-row {
+    -fx-padding: 0;
+}
+.telemetry-entry-time {
+    -fx-text-fill: -app-text-weak;
+    -fx-font-family: "JetBrains Mono", "SF Mono", "Menlo", monospace;
+    -fx-font-size: 11px;
+}
+.telemetry-entry-channel {
+    -fx-padding: 1 6 1 6;
+    -fx-background-radius: 4;
+    -fx-font-size: 10.5px;
+    -fx-font-weight: 700;
+}
+.telemetry-entry-channel.entry-chip-entry {
+    -fx-background-color: rgba(46,224,122,0.16);
+    -fx-text-fill: #6affa8;
+}
+.telemetry-entry-channel.entry-chip-exit {
+    -fx-background-color: rgba(255,95,176,0.16);
+    -fx-text-fill: #ff8ac6;
+}
+.telemetry-entry-name {
+    -fx-text-fill: -app-text-dim;
+    -fx-font-family: "JetBrains Mono", "SF Mono", "Menlo", monospace;
+    -fx-font-size: 11px;
+}
+.telemetry-entry-detail {
+    -fx-text-fill: -app-text;
+    -fx-font-size: 11.5px;
+    -fx-font-weight: 600;
+}
+.telemetry-placeholder {
+    -fx-text-fill: -app-text-weak;
+    -fx-font-size: 11.5px;
+    -fx-font-style: italic;
+}

BIN
src/main/resources/icon.png