jingbo 5 hari lalu
induk
melakukan
eca9ec203c

+ 67 - 0
docs/client-update/README.md

@@ -0,0 +1,67 @@
+# MES 客户端自动升级约定
+
+现场客户端启动时请求本约定下的静态资源,对比本地版本后下载 zip 并自动替换,无需手工覆盖 EXE。
+
+## 服务端路径(相对 `http://{mes.server_ip}:8980`)
+
+| 资源 | 路径 | 说明 |
+|------|------|------|
+| 版本清单 | `/client-update/version.json` | 必填 |
+| 安装包 | `/client-update/MesClient-{version}.zip` | 与 `packageUrl` 一致 |
+
+将上述文件放到 Jeesite/Tomcat 可访问的静态目录(例如 `webapp/client-update/`)。
+
+## version.json 字段
+
+```json
+{
+  "version": "1.2.0",
+  "force": false,
+  "packageUrl": "http://192.168.9.180:8980/client-update/MesClient-1.2.0.zip",
+  "sha256": "",
+  "notes": "修复说明(可选)"
+}
+```
+
+- `version`:服务端最新版本,三段数字 `主.次.修订`
+- `force`:`true` 时不可跳过,必须升级后才能继续使用
+- `packageUrl`:zip 完整下载地址(可与服务器 IP 一致)
+- `sha256`:可选,zip 文件 SHA-256 十六进制;为空则不校验
+- `notes`:更新说明,弹窗展示
+
+检查失败(网络/404)不阻断启动。
+
+## zip 包内容
+
+与现场安装目录结构一致:
+
+```text
+MesClient.exe          # 可选,启动壳未变可省略
+MesClient.jar          # 业务更新时必含
+MesClient.l4j.ini      # 可选
+app.version            # 必含,一行纯文本版本号,如 1.2.0
+```
+
+推荐日常使用 **JAR 与 EXE 分离** 打包,多数发版只需更新 JAR + `app.version`。
+
+## 本地版本读取顺序
+
+1. 安装目录旁 `app.version`
+2. 若无,则读 JAR 内 `config/config.properties` 的 `mes.client.version`
+
+## 客户端配置
+
+```properties
+mes.client.version=1.0.0
+mes.update.enabled=true
+mes.update.path=/client-update/version.json
+```
+
+`mes.update.enabled=false` 可紧急关闭自动升级。
+
+## 发版步骤摘要
+
+1. 升高 `mes.client.version` 与旁路 `app.version`
+2. 导出 JAR,用 jar2exe **分离模式** 生成 EXE(壳未变可复用旧 EXE)
+3. 用 `tools/client-release/package-update.bat` 打 zip
+4. 上传 zip 与更新后的 `version.json` 到服务器 `/client-update/`

+ 7 - 0
docs/client-update/version.json.example

@@ -0,0 +1,7 @@
+{
+  "version": "1.0.1",
+  "force": false,
+  "packageUrl": "http://127.0.0.1:8980/client-update/MesClient-1.0.1.zip",
+  "sha256": "",
+  "notes": "示例:修复登录乱码"
+}

+ 1 - 1
src/com/mes/ui/LoginFarme.java

@@ -22,7 +22,7 @@ public class LoginFarme extends JFrame {
     static JButton scanLoginButton = new JButton("扫  码  登  录");
 
     public LoginFarme(){
-        setTitle("MES系统客户端:"+MesClient.mes_gw+" - "+MesClient.mes_gw_des);
+        setTitle(MesClient.buildWindowTitle());
 
         ImageIcon bg = new ImageIcon(MesClient.class.getResource("/background.png"));
         setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

+ 28 - 2
src/com/mes/ui/MesClient.java

@@ -4,6 +4,9 @@ import com.alibaba.fastjson2.JSONObject;
 import com.mes.component.MesRadio;
 import com.mes.component.MesWebView;
 import com.mes.device.DeviceStateReporter;
+import com.mes.update.ClientUpdater;
+import com.mes.update.UpdateInstaller;
+import com.mes.update.VersionUtil;
 import com.mes.util.DateLocalUtils;
 import com.mes.util.HttpUtils;
 import com.mes.util.JdbcUtils;
@@ -37,6 +40,9 @@ public class MesClient extends JFrame {
     public static int mes_heart_icon_cycle = 1;
     public static String mes_line_sn = ""; // 产线编号
     public static int mes_device_heartbeat_minutes = 5; // 设备状态上报间隔(分钟)
+    public static String mes_client_version = "1.0.0"; // 客户端版本号
+    public static boolean mes_update_enabled = true; // 是否启用自动升级
+    public static String mes_update_path = "/client-update/version.json"; // 升级版本清单路径
 
     //session
     public static String sessionid = "";
@@ -92,6 +98,14 @@ public class MesClient extends JFrame {
                         //读文件配置
                         readProperty();
 
+                        // 启动前检查自动升级(失败不阻断;安排升级后退出)
+                        boolean continueStartup = ClientUpdater.checkAndMaybeUpdate(
+                                mes_server_ip, mes_client_version, mes_update_enabled, mes_update_path);
+                        if (!continueStartup) {
+                            System.exit(0);
+                            return;
+                        }
+
                         // 显示界面
                         mesClientFrame = new MesClient();
                         mesClientFrame.setVisible(false);
@@ -130,7 +144,19 @@ public class MesClient extends JFrame {
         if (mes_device_heartbeat_minutes <= 0) {
             mes_device_heartbeat_minutes = 5;
         }
-        log.info(mes_gw + ";" + mes_gw_des + ";" + mes_server_ip + ";");
+        String cfgVersion = pro.getProperty("mes.client.version", "1.0.0");
+        mes_client_version = VersionUtil.resolveLocalVersion(UpdateInstaller.resolveInstallDir(), cfgVersion);
+        mes_update_enabled = !"false".equalsIgnoreCase(
+                pro.getProperty("mes.update.enabled", "true").trim());
+        String updatePath = pro.getProperty("mes.update.path", "/client-update/version.json");
+        mes_update_path = (updatePath == null || updatePath.trim().isEmpty())
+                ? "/client-update/version.json" : updatePath.trim();
+        log.info(mes_gw + ";" + mes_gw_des + ";" + mes_server_ip + ";version=" + mes_client_version + ";");
+    }
+
+    /** 窗口标题后缀:工位 + 版本号 */
+    public static String buildWindowTitle() {
+        return "MES系统客户端:" + mes_gw + " - " + mes_gw_des + " v" + mes_client_version;
     }
 
     //启动心跳包程序(界面图标闪烁)
@@ -361,7 +387,7 @@ public class MesClient extends JFrame {
 
     public MesClient() {
         setIconImage(Toolkit.getDefaultToolkit().getImage(MesClient.class.getResource("/bg/logo.png")));
-        setTitle("MES系统客户端:"+mes_gw + "- " + mes_gw_des);
+        setTitle(buildWindowTitle());
 //        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
         setBounds(0, 0, 1024, 768);
 

+ 244 - 0
src/com/mes/update/ClientUpdater.java

@@ -0,0 +1,244 @@
+package com.mes.update;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+import javax.swing.SwingUtilities;
+import javax.swing.WindowConstants;
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Frame;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 启动时自动升级编排:检查 → 弹窗 → 下载 → 替换脚本 → 退出。
+ */
+public final class ClientUpdater {
+
+    private static final Logger log = LoggerFactory.getLogger(ClientUpdater.class);
+
+    private ClientUpdater() {
+    }
+
+    /**
+     * @return true 表示继续正常启动;false 表示应退出进程(已安排升级或强制更新失败)
+     */
+    public static boolean checkAndMaybeUpdate(String serverIp,
+                                              String localVersionFromConfig,
+                                              boolean enabled,
+                                              String updatePath) {
+        if (!enabled) {
+            log.info("自动升级已关闭");
+            return true;
+        }
+        File installDir = UpdateInstaller.resolveInstallDir();
+        String localVersion = VersionUtil.resolveLocalVersion(installDir, localVersionFromConfig);
+        log.info("本地版本={},安装目录={}", localVersion, installDir.getAbsolutePath());
+
+        UpdateInfo info;
+        try {
+            info = new UpdateChecker().fetch(serverIp, updatePath);
+        } catch (Exception e) {
+            log.warn("升级检查失败,继续启动:{}", e.getMessage());
+            return true;
+        }
+        if (info == null) {
+            return true;
+        }
+        if (!VersionUtil.isNewer(info.getVersion(), localVersion)) {
+            log.info("已是最新版本:local={} remote={}", localVersion, info.getVersion());
+            return true;
+        }
+
+        log.info("发现新版本:{} -> {},force={}", localVersion, info.getVersion(), info.isForce());
+        final boolean[] proceed = new boolean[]{false};
+        try {
+            runOnEdtWait(() -> proceed[0] = askUser(null, localVersion, info));
+        } catch (Exception e) {
+            log.warn("升级确认弹窗失败:{}", e.getMessage());
+            return true;
+        }
+        if (!proceed[0]) {
+            if (info.isForce()) {
+                try {
+                    runOnEdtWait(() -> JOptionPane.showMessageDialog(null,
+                            "当前版本必须升级后才能使用。",
+                            "强制更新",
+                            JOptionPane.WARNING_MESSAGE));
+                } catch (Exception ignored) {
+                }
+                return false;
+            }
+            return true;
+        }
+
+        boolean applied = downloadAndApply(installDir, info);
+        if (!applied) {
+            try {
+                runOnEdtWait(() -> {
+                    if (info.isForce()) {
+                        JOptionPane.showMessageDialog(null,
+                                "强制更新失败,程序将退出。请检查网络或联系管理员。",
+                                "更新失败",
+                                JOptionPane.ERROR_MESSAGE);
+                    } else {
+                        JOptionPane.showMessageDialog(null,
+                                "更新失败,将继续使用当前版本。",
+                                "更新失败",
+                                JOptionPane.WARNING_MESSAGE);
+                    }
+                });
+            } catch (Exception ignored) {
+            }
+            return !info.isForce();
+        }
+        return false;
+    }
+
+    /** 已在 EDT 时直接执行,避免 invokeAndWait 死锁 */
+    private static void runOnEdtWait(Runnable action) throws Exception {
+        if (SwingUtilities.isEventDispatchThread()) {
+            action.run();
+        } else {
+            SwingUtilities.invokeAndWait(action);
+        }
+    }
+
+    private static boolean askUser(Component parent, String localVersion, UpdateInfo info) {
+        String notes = info.getNotes() == null ? "" : info.getNotes().trim();
+        StringBuilder msg = new StringBuilder();
+        msg.append("发现新版本 ").append(info.getVersion())
+                .append("(当前 ").append(localVersion).append(")\n\n");
+        if (!notes.isEmpty()) {
+            msg.append(notes).append("\n\n");
+        }
+        if (info.isForce()) {
+            msg.append("本次为强制更新,必须升级后才能继续使用。");
+            JOptionPane.showMessageDialog(parent, msg.toString(), "强制更新", JOptionPane.INFORMATION_MESSAGE);
+            return true;
+        }
+        msg.append("是否立即更新?");
+        int choice = JOptionPane.showConfirmDialog(parent, msg.toString(), "发现新版本",
+                JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
+        return choice == JOptionPane.YES_OPTION;
+    }
+
+    private static boolean downloadAndApply(File installDir, UpdateInfo info) {
+        File tempRoot = new File(System.getProperty("java.io.tmpdir"),
+                "mesclient-update-" + System.currentTimeMillis());
+        File zipFile = new File(tempRoot, "package.zip");
+        File extractDir = new File(tempRoot, "payload");
+        if (!tempRoot.mkdirs()) {
+            log.warn("无法创建临时目录:{}", tempRoot.getAbsolutePath());
+            return false;
+        }
+
+        JProgressBar bar = new JProgressBar(0, 100);
+        bar.setStringPainted(true);
+        bar.setPreferredSize(new Dimension(360, 24));
+        JLabel label = new JLabel("正在下载更新包...");
+        JPanel panel = new JPanel(new BorderLayout(8, 8));
+        panel.add(label, BorderLayout.NORTH);
+        panel.add(bar, BorderLayout.CENTER);
+        JDialog dialog = new JDialog((Frame) null, "正在更新", true);
+        dialog.setContentPane(panel);
+        dialog.pack();
+        dialog.setLocationRelativeTo(null);
+        dialog.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+
+        AtomicBoolean ok = new AtomicBoolean(false);
+        Thread worker = new Thread(() -> {
+            try {
+                UpdateDownloader downloader = new UpdateDownloader();
+                File downloaded = downloader.download(info.getPackageUrl(), zipFile, (downloadedBytes, total) -> {
+                    final int percent;
+                    if (total > 0) {
+                        percent = (int) Math.min(100, (downloadedBytes * 100) / total);
+                    } else {
+                        percent = -1;
+                    }
+                    SwingUtilities.invokeLater(() -> {
+                        if (percent < 0) {
+                            bar.setIndeterminate(true);
+                            bar.setString(formatSize(downloadedBytes));
+                        } else {
+                            bar.setIndeterminate(false);
+                            bar.setValue(percent);
+                            bar.setString(percent + "%");
+                        }
+                    });
+                });
+                if (downloaded == null) {
+                    return;
+                }
+                if (!downloader.verifySha256(downloaded, info.getSha256())) {
+                    SwingUtilities.invokeLater(() -> label.setText("校验失败"));
+                    return;
+                }
+                SwingUtilities.invokeLater(() -> {
+                    label.setText("正在解压...");
+                    bar.setIndeterminate(true);
+                    bar.setString("解压中");
+                });
+                UpdateInstaller installer = new UpdateInstaller();
+                File payload = installer.unzip(downloaded, extractDir);
+                File versionFile = new File(payload, "app.version");
+                if (!versionFile.isFile()) {
+                    try (OutputStreamWriter w = new OutputStreamWriter(
+                            new FileOutputStream(versionFile), StandardCharsets.UTF_8)) {
+                        w.write(info.getVersion());
+                    }
+                }
+                String exeName = UpdateInstaller.resolveExeName(installDir);
+                File[] payloadFiles = payload.listFiles();
+                if (payloadFiles != null) {
+                    for (File f : payloadFiles) {
+                        if (f.isFile() && f.getName().toLowerCase().endsWith(".exe")) {
+                            exeName = f.getName();
+                            break;
+                        }
+                    }
+                }
+                SwingUtilities.invokeLater(() -> label.setText("准备替换并重启..."));
+                installer.launchReplaceScript(installDir, payload, exeName);
+                ok.set(true);
+            } catch (Exception e) {
+                log.warn("执行升级失败:{}", e.getMessage(), e);
+            } finally {
+                SwingUtilities.invokeLater(dialog::dispose);
+            }
+        }, "mes-client-updater");
+        worker.setDaemon(true);
+        worker.start();
+        dialog.setVisible(true);
+        try {
+            worker.join(5 * 60 * 1000L);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        if (!ok.get()) {
+            UpdateInstaller.deleteRecursively(tempRoot);
+        }
+        return ok.get();
+    }
+
+    private static String formatSize(long bytes) {
+        if (bytes < 1024) {
+            return bytes + " B";
+        }
+        if (bytes < 1024 * 1024) {
+            return String.format("%.1f KB", bytes / 1024.0);
+        }
+        return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
+    }
+}

+ 99 - 0
src/com/mes/update/UpdateChecker.java

@@ -0,0 +1,99 @@
+package com.mes.update;
+
+import com.alibaba.fastjson2.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 从服务端拉取 version.json。
+ */
+public class UpdateChecker {
+
+    private static final Logger log = LoggerFactory.getLogger(UpdateChecker.class);
+
+    /**
+     * @param serverIp  MES 服务器 IP
+     * @param updatePath 如 /client-update/version.json
+     * @return 解析成功返回 UpdateInfo,失败返回 null(不阻断启动)
+     */
+    public UpdateInfo fetch(String serverIp, String updatePath) {
+        if (serverIp == null || serverIp.trim().isEmpty()) {
+            log.warn("升级检查跳过:服务器 IP 为空");
+            return null;
+        }
+        String path = updatePath == null || updatePath.trim().isEmpty()
+                ? "/client-update/version.json"
+                : updatePath.trim();
+        if (!path.startsWith("/")) {
+            path = "/" + path;
+        }
+        String url = "http://" + serverIp.trim() + ":8980" + path;
+        HttpURLConnection connection = null;
+        try {
+            connection = (HttpURLConnection) new URL(url).openConnection();
+            connection.setRequestMethod("GET");
+            connection.setConnectTimeout(8000);
+            connection.setReadTimeout(15000);
+            connection.setUseCaches(false);
+            int code = connection.getResponseCode();
+            if (code != HttpURLConnection.HTTP_OK) {
+                log.warn("升级检查失败:HTTP {},url={}", code, url);
+                return null;
+            }
+            StringBuilder sb = new StringBuilder();
+            try (InputStream in = connection.getInputStream();
+                 BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    sb.append(line);
+                }
+            }
+            return parse(sb.toString());
+        } catch (Exception e) {
+            log.warn("升级检查异常:{},url={}", e.getMessage(), url);
+            return null;
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    UpdateInfo parse(String json) {
+        if (json == null || json.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            JSONObject obj = JSONObject.parseObject(json);
+            if (obj == null) {
+                return null;
+            }
+            String version = obj.getString("version");
+            String packageUrl = obj.getString("packageUrl");
+            if (version == null || version.trim().isEmpty()
+                    || packageUrl == null || packageUrl.trim().isEmpty()) {
+                log.warn("升级检查失败:version.json 缺少 version 或 packageUrl");
+                return null;
+            }
+            UpdateInfo info = new UpdateInfo();
+            info.setVersion(version.trim());
+            info.setPackageUrl(packageUrl.trim());
+            info.setForce(obj.getBooleanValue("force"));
+            String sha = obj.getString("sha256");
+            info.setSha256(sha == null ? "" : sha.trim());
+            String notes = obj.getString("notes");
+            info.setNotes(notes == null ? "" : notes.trim());
+            return info;
+        } catch (Exception e) {
+            log.warn("升级检查失败:version.json 解析异常 {}", e.getMessage());
+            return null;
+        }
+    }
+}

+ 118 - 0
src/com/mes/update/UpdateDownloader.java

@@ -0,0 +1,118 @@
+package com.mes.update;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.security.MessageDigest;
+
+/**
+ * 下载升级包到临时目录,可选 SHA-256 校验。
+ */
+public class UpdateDownloader {
+
+    private static final Logger log = LoggerFactory.getLogger(UpdateDownloader.class);
+
+    public interface ProgressCallback {
+        void onProgress(long downloaded, long total);
+    }
+
+    /**
+     * 下载 zip 到指定文件。
+     *
+     * @return 成功返回目标文件,失败返回 null
+     */
+    public File download(String packageUrl, File targetFile, ProgressCallback callback) {
+        if (packageUrl == null || packageUrl.trim().isEmpty() || targetFile == null) {
+            return null;
+        }
+        HttpURLConnection connection = null;
+        try {
+            File parent = targetFile.getParentFile();
+            if (parent != null && !parent.exists() && !parent.mkdirs()) {
+                log.warn("无法创建下载目录:{}", parent.getAbsolutePath());
+                return null;
+            }
+            connection = (HttpURLConnection) new URL(packageUrl.trim()).openConnection();
+            connection.setRequestMethod("GET");
+            connection.setConnectTimeout(15000);
+            connection.setReadTimeout(120000);
+            connection.setUseCaches(false);
+            int code = connection.getResponseCode();
+            if (code != HttpURLConnection.HTTP_OK) {
+                log.warn("下载升级包失败:HTTP {},url={}", code, packageUrl);
+                return null;
+            }
+            long total = connection.getContentLengthLong();
+            long downloaded = 0;
+            byte[] buffer = new byte[8192];
+            try (InputStream in = connection.getInputStream();
+                 FileOutputStream out = new FileOutputStream(targetFile)) {
+                int n;
+                while ((n = in.read(buffer)) >= 0) {
+                    out.write(buffer, 0, n);
+                    downloaded += n;
+                    if (callback != null) {
+                        callback.onProgress(downloaded, total);
+                    }
+                }
+            }
+            return targetFile;
+        } catch (Exception e) {
+            log.warn("下载升级包异常:{}", e.getMessage());
+            if (targetFile.exists()) {
+                // noinspection ResultOfMethodCallIgnored
+                targetFile.delete();
+            }
+            return null;
+        } finally {
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    /**
+     * 校验文件 SHA-256。expected 为空则视为通过。
+     */
+    public boolean verifySha256(File file, String expected) {
+        if (expected == null || expected.trim().isEmpty()) {
+            return true;
+        }
+        if (file == null || !file.isFile()) {
+            return false;
+        }
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] buffer = new byte[8192];
+            try (FileInputStream in = new FileInputStream(file)) {
+                int n;
+                while ((n = in.read(buffer)) >= 0) {
+                    digest.update(buffer, 0, n);
+                }
+            }
+            String actual = toHex(digest.digest());
+            boolean ok = actual.equalsIgnoreCase(expected.trim());
+            if (!ok) {
+                log.warn("升级包校验失败:期望 {},实际 {}", expected, actual);
+            }
+            return ok;
+        } catch (Exception e) {
+            log.warn("升级包校验异常:{}", e.getMessage());
+            return false;
+        }
+    }
+
+    private static String toHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder(bytes.length * 2);
+        for (byte b : bytes) {
+            sb.append(String.format("%02x", b));
+        }
+        return sb.toString();
+    }
+}

+ 53 - 0
src/com/mes/update/UpdateInfo.java

@@ -0,0 +1,53 @@
+package com.mes.update;
+
+/**
+ * 服务端 version.json 解析结果。
+ */
+public class UpdateInfo {
+
+    private String version;
+    private boolean force;
+    private String packageUrl;
+    private String sha256;
+    private String notes;
+
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    public boolean isForce() {
+        return force;
+    }
+
+    public void setForce(boolean force) {
+        this.force = force;
+    }
+
+    public String getPackageUrl() {
+        return packageUrl;
+    }
+
+    public void setPackageUrl(String packageUrl) {
+        this.packageUrl = packageUrl;
+    }
+
+    public String getSha256() {
+        return sha256;
+    }
+
+    public void setSha256(String sha256) {
+        this.sha256 = sha256;
+    }
+
+    public String getNotes() {
+        return notes;
+    }
+
+    public void setNotes(String notes) {
+        this.notes = notes;
+    }
+}

+ 274 - 0
src/com/mes/update/UpdateInstaller.java

@@ -0,0 +1,274 @@
+package com.mes.update;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.lang.management.ManagementFactory;
+import java.nio.charset.Charset;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipInputStream;
+
+/**
+ * 解压升级包并生成 Windows 替换脚本(进程退出后覆盖文件并重启)。
+ */
+public class UpdateInstaller {
+
+    private static final Logger log = LoggerFactory.getLogger(UpdateInstaller.class);
+
+    /**
+     * 解压 zip 到目标目录(已存在则清空重建)。
+     */
+    public File unzip(File zipFile, File extractDir) throws Exception {
+        if (extractDir.exists()) {
+            deleteRecursively(extractDir);
+        }
+        if (!extractDir.mkdirs()) {
+            throw new IllegalStateException("无法创建解压目录: " + extractDir.getAbsolutePath());
+        }
+        try {
+            unzipWithZipFile(zipFile, extractDir);
+        } catch (Exception encodingIssue) {
+            log.warn("ZipFile 解压失败,回退 ZipInputStream:{}", encodingIssue.getMessage());
+            if (extractDir.exists()) {
+                deleteRecursively(extractDir);
+            }
+            if (!extractDir.mkdirs()) {
+                throw new IllegalStateException("无法创建解压目录: " + extractDir.getAbsolutePath());
+            }
+            unzipFallback(zipFile, extractDir);
+        }
+        return flattenIfSingleRoot(extractDir);
+    }
+
+    private void unzipWithZipFile(File zipFile, File extractDir) throws Exception {
+        try (ZipFile zip = new ZipFile(zipFile, Charset.forName("UTF-8"))) {
+            Enumeration<? extends ZipEntry> entries = zip.entries();
+            byte[] buffer = new byte[8192];
+            while (entries.hasMoreElements()) {
+                ZipEntry entry = entries.nextElement();
+                writeZipEntry(extractDir, entry, zip.getInputStream(entry), buffer);
+            }
+        }
+    }
+
+    private void unzipFallback(File zipFile, File extractDir) throws Exception {
+        byte[] buffer = new byte[8192];
+        try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
+            ZipEntry entry;
+            while ((entry = zis.getNextEntry()) != null) {
+                writeZipEntry(extractDir, entry, zis, buffer);
+                zis.closeEntry();
+            }
+        }
+    }
+
+    private void writeZipEntry(File extractDir, ZipEntry entry, java.io.InputStream in, byte[] buffer) throws Exception {
+        File outFile = new File(extractDir, entry.getName());
+        if (!isSafeExtractPath(extractDir, outFile)) {
+            throw new IllegalStateException("非法压缩条目: " + entry.getName());
+        }
+        if (entry.isDirectory()) {
+            if (!outFile.exists() && !outFile.mkdirs()) {
+                throw new IllegalStateException("无法创建目录: " + outFile.getAbsolutePath());
+            }
+            return;
+        }
+        File parent = outFile.getParentFile();
+        if (parent != null && !parent.exists() && !parent.mkdirs()) {
+            throw new IllegalStateException("无法创建目录: " + parent.getAbsolutePath());
+        }
+        try (FileOutputStream out = new FileOutputStream(outFile);
+             BufferedOutputStream bos = new BufferedOutputStream(out)) {
+            int n;
+            while ((n = in.read(buffer)) >= 0) {
+                bos.write(buffer, 0, n);
+            }
+        }
+    }
+
+    private File flattenIfSingleRoot(File extractDir) throws Exception {
+        File[] children = extractDir.listFiles();
+        if (children == null || children.length != 1 || !children[0].isDirectory()) {
+            return extractDir;
+        }
+        File nested = children[0];
+        if (!hasAppPayload(nested)) {
+            return extractDir;
+        }
+        File flat = new File(extractDir.getParentFile(), extractDir.getName() + "-flat");
+        if (flat.exists()) {
+            deleteRecursively(flat);
+        }
+        if (!nested.renameTo(flat)) {
+            copyDirectory(nested, flat);
+            deleteRecursively(extractDir);
+            return flat;
+        }
+        deleteRecursively(extractDir);
+        return flat;
+    }
+
+    private boolean hasAppPayload(File dir) {
+        File[] files = dir.listFiles();
+        if (files == null) {
+            return false;
+        }
+        for (File f : files) {
+            String name = f.getName().toLowerCase();
+            if (name.endsWith(".jar") || name.endsWith(".exe") || name.equals("app.version")) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 生成并启动 update.bat:等待当前 PID 退出后覆盖安装目录并启动 EXE。
+     */
+    public void launchReplaceScript(File installDir, File payloadDir, String exeName) throws Exception {
+        if (installDir == null || !installDir.isDirectory()) {
+            throw new IllegalStateException("安装目录无效");
+        }
+        if (payloadDir == null || !payloadDir.isDirectory()) {
+            throw new IllegalStateException("升级包内容目录无效");
+        }
+        String exe = (exeName == null || exeName.trim().isEmpty()) ? resolveExeName(installDir) : exeName.trim();
+        long pid = currentPid();
+        File script = new File(System.getProperty("java.io.tmpdir"), "mesclient-update-" + pid + ".bat");
+        writeBat(script, pid, installDir, payloadDir, exe);
+
+        ProcessBuilder pb = new ProcessBuilder(
+                "cmd.exe", "/c", "start", "\"mesclient-update\"", "/min", script.getAbsolutePath());
+        pb.directory(installDir);
+        pb.start();
+        log.info("已启动升级脚本:{}", script.getAbsolutePath());
+    }
+
+    private void writeBat(File script, long pid, File installDir, File payloadDir, String exeName) throws Exception {
+        Charset charset = Charset.defaultCharset();
+        try (PrintWriter out = new PrintWriter(new OutputStreamWriter(new FileOutputStream(script), charset))) {
+            out.println("@echo off");
+            out.println("setlocal EnableExtensions");
+            out.println("set \"PID=" + pid + "\"");
+            out.println("set \"INSTALL_DIR=" + installDir.getAbsolutePath() + "\"");
+            out.println("set \"SOURCE_DIR=" + payloadDir.getAbsolutePath() + "\"");
+            out.println("set \"EXE_NAME=" + exeName + "\"");
+            out.println("echo [mes-update] waiting process %PID% ...");
+            out.println(":wait");
+            out.println("tasklist /FI \"PID eq %PID%\" 2>NUL | findstr /I /C:\" %PID% \" >NUL");
+            out.println("if not errorlevel 1 (");
+            out.println("  timeout /t 1 /nobreak >NUL");
+            out.println("  goto wait");
+            out.println(")");
+            out.println("echo [mes-update] copying files ...");
+            out.println("xcopy /E /Y /I /Q \"%SOURCE_DIR%\\*\" \"%INSTALL_DIR%\\\" >NUL");
+            out.println("if errorlevel 1 (");
+            out.println("  echo [mes-update] copy failed");
+            out.println("  pause");
+            out.println("  exit /b 1");
+            out.println(")");
+            out.println("echo [mes-update] starting %EXE_NAME%");
+            out.println("start \"\" \"%INSTALL_DIR%\\%EXE_NAME%\"");
+            out.println("rmdir /S /Q \"%SOURCE_DIR%\" >NUL 2>&1");
+            out.println("del \"%~f0\" >NUL 2>&1");
+            out.println("endlocal");
+            out.println("exit /b 0");
+        }
+    }
+
+    public static long currentPid() {
+        try {
+            String name = ManagementFactory.getRuntimeMXBean().getName();
+            int at = name.indexOf('@');
+            if (at > 0) {
+                return Long.parseLong(name.substring(0, at));
+            }
+        } catch (Exception ignored) {
+        }
+        return 0L;
+    }
+
+    public static String resolveExeName(File installDir) {
+        File preferred = new File(installDir, "MesClient.exe");
+        if (preferred.isFile()) {
+            return preferred.getName();
+        }
+        File[] files = installDir.listFiles();
+        if (files != null) {
+            for (File f : files) {
+                if (f.isFile() && f.getName().toLowerCase().endsWith(".exe")) {
+                    return f.getName();
+                }
+            }
+        }
+        return "MesClient.exe";
+    }
+
+    public static File resolveInstallDir() {
+        try {
+            java.net.URL loc = UpdateInstaller.class.getProtectionDomain().getCodeSource().getLocation();
+            File code = new File(loc.toURI());
+            if (code.isFile()) {
+                return code.getParentFile();
+            }
+            return new File(System.getProperty("user.dir"));
+        } catch (Exception e) {
+            return new File(System.getProperty("user.dir"));
+        }
+    }
+
+    private static boolean isSafeExtractPath(File baseDir, File target) throws Exception {
+        String base = baseDir.getCanonicalPath();
+        String path = target.getCanonicalPath();
+        return path.startsWith(base + File.separator) || path.equals(base);
+    }
+
+    private static void copyDirectory(File src, File dest) throws Exception {
+        if (!dest.exists() && !dest.mkdirs()) {
+            throw new IllegalStateException("无法创建目录: " + dest.getAbsolutePath());
+        }
+        File[] files = src.listFiles();
+        if (files == null) {
+            return;
+        }
+        byte[] buffer = new byte[8192];
+        for (File file : files) {
+            File target = new File(dest, file.getName());
+            if (file.isDirectory()) {
+                copyDirectory(file, target);
+            } else {
+                try (FileInputStream in = new FileInputStream(file);
+                     FileOutputStream out = new FileOutputStream(target)) {
+                    int n;
+                    while ((n = in.read(buffer)) >= 0) {
+                        out.write(buffer, 0, n);
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deleteRecursively(File file) {
+        if (file == null || !file.exists()) {
+            return;
+        }
+        if (file.isDirectory()) {
+            File[] children = file.listFiles();
+            if (children != null) {
+                for (File child : children) {
+                    deleteRecursively(child);
+                }
+            }
+        }
+        // noinspection ResultOfMethodCallIgnored
+        file.delete();
+    }
+}

+ 104 - 0
src/com/mes/update/VersionUtil.java

@@ -0,0 +1,104 @@
+package com.mes.update;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 本地/远程版本读写与三段数字比较。
+ */
+public final class VersionUtil {
+
+    private VersionUtil() {
+    }
+
+    /**
+     * 读取本地版本:优先安装目录旁 app.version,否则使用配置中的默认版本。
+     */
+    public static String resolveLocalVersion(File installDir, String configVersion) {
+        if (installDir != null) {
+            File versionFile = new File(installDir, "app.version");
+            String fromFile = readVersionFile(versionFile);
+            if (fromFile != null && !fromFile.isEmpty()) {
+                return fromFile;
+            }
+        }
+        if (configVersion != null && !configVersion.trim().isEmpty()) {
+            return configVersion.trim();
+        }
+        return "0.0.0";
+    }
+
+    public static String readVersionFile(File versionFile) {
+        if (versionFile == null || !versionFile.isFile()) {
+            return null;
+        }
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(new FileInputStream(versionFile), StandardCharsets.UTF_8))) {
+            String line = reader.readLine();
+            if (line == null) {
+                return null;
+            }
+            return line.trim();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 比较两个版本号。返回 &gt;0 表示 a 更新,&lt;0 表示 b 更新,0 表示相等。
+     */
+    public static int compare(String a, String b) {
+        int[] pa = parse(a);
+        int[] pb = parse(b);
+        int len = Math.max(pa.length, pb.length);
+        for (int i = 0; i < len; i++) {
+            int va = i < pa.length ? pa[i] : 0;
+            int vb = i < pb.length ? pb[i] : 0;
+            if (va != vb) {
+                return va - vb;
+            }
+        }
+        return 0;
+    }
+
+    public static boolean isNewer(String remote, String local) {
+        return compare(remote, local) > 0;
+    }
+
+    private static int[] parse(String version) {
+        if (version == null || version.trim().isEmpty()) {
+            return new int[]{0, 0, 0};
+        }
+        String cleaned = version.trim();
+        if (cleaned.startsWith("v") || cleaned.startsWith("V")) {
+            cleaned = cleaned.substring(1);
+        }
+        String[] parts = cleaned.split("[^0-9]+");
+        int[] result = new int[Math.max(3, parts.length)];
+        int idx = 0;
+        for (String part : parts) {
+            if (part == null || part.isEmpty()) {
+                continue;
+            }
+            try {
+                result[idx++] = Integer.parseInt(part);
+            } catch (NumberFormatException ignored) {
+                result[idx++] = 0;
+            }
+        }
+        if (idx == 0) {
+            return new int[]{0, 0, 0};
+        }
+        if (idx < 3) {
+            int[] padded = new int[3];
+            System.arraycopy(result, 0, padded, 0, idx);
+            return padded;
+        }
+        int[] exact = new int[idx];
+        System.arraycopy(result, 0, exact, 0, idx);
+        return exact;
+    }
+}

+ 11 - 5
src/resources/config/config.properties

@@ -1,6 +1,12 @@
-mes.gw=OP030A
-mes.server_ip=127.0.0.1
-#mes.server_ip=192.168.9.180
-mes.line_sn=115XT
+mes.gw=OP010A
+#mes.server_ip=127.0.0.1
+mes.server_ip=192.168.9.180
+mes.line_sn=HEVXT
 # 设备状态定时上报间隔(分钟)
-mes.device.heartbeat.minutes=5
+mes.device.heartbeat.minutes=5
+# 客户端版本号(安装目录旁 app.version 优先)
+mes.client.version=1.0.0
+# 启动时自动检查升级
+mes.update.enabled=true
+# 相对 http://{mes.server_ip}:8980 的版本清单路径
+mes.update.path=/client-update/version.json

+ 36 - 0
tools/client-release/README.md

@@ -0,0 +1,36 @@
+# MES 客户端升级包发版
+
+## 推荐打包方式
+
+日常发版使用 jar2exe **「JAR 与 EXE 分离」**:
+
+1. 升高 [`src/resources/config/config.properties`](../../src/resources/config/config.properties) 中 `mes.client.version`
+2. 导出业务 JAR
+3. jar2exe 分离模式生成 `MesClient.exe`(壳未变可跳过,复用旧 EXE)
+4. 执行本目录脚本打升级 zip
+
+```bat
+package-update.bat 1.0.1 C:\path\MesClient.jar C:\path\MesClient.exe
+```
+
+仅更新业务、壳不变时:
+
+```bat
+package-update.bat 1.0.1 C:\path\MesClient.jar
+```
+
+输出:
+
+- `dist/MesClient-{version}.zip`
+- `dist/version.json`(请改 `packageUrl` 后上传)
+
+## 上传
+
+将 zip 与 `version.json` 放到 MES 服务器:
+
+```text
+http://{mes.server_ip}:8980/client-update/version.json
+http://{mes.server_ip}:8980/client-update/MesClient-{version}.zip
+```
+
+详细约定见 [`docs/client-update/README.md`](../../docs/client-update/README.md)。

+ 112 - 0
tools/client-release/package-update.bat

@@ -0,0 +1,112 @@
+@echo off
+chcp 65001 >nul
+setlocal EnableExtensions EnableDelayedExpansion
+
+REM ============================================================
+REM  打包 MES 客户端升级 zip(分离模式:EXE + JAR + app.version)
+REM  用法:
+REM    package-update.bat <版本号> <JAR路径> [EXE路径] [输出目录]
+REM  示例:
+REM    package-update.bat 1.0.1 C:\build\MesClient.jar C:\build\MesClient.exe
+REM ============================================================
+
+set "VERSION=%~1"
+set "JAR_PATH=%~2"
+set "EXE_PATH=%~3"
+set "OUT_DIR=%~4"
+
+if "%VERSION%"=="" (
+  echo 用法: package-update.bat ^<版本号^> ^<JAR路径^> [EXE路径] [输出目录]
+  echo 示例: package-update.bat 1.0.1 .\MesClient.jar .\MesClient.exe
+  exit /b 1
+)
+if "%JAR_PATH%"=="" (
+  echo 错误: 请指定 JAR 路径
+  exit /b 1
+)
+if not exist "%JAR_PATH%" (
+  echo 错误: JAR 不存在: %JAR_PATH%
+  exit /b 1
+)
+
+if "%OUT_DIR%"=="" set "OUT_DIR=%~dp0dist"
+if not exist "%OUT_DIR%" mkdir "%OUT_DIR%"
+
+set "STAGE=%OUT_DIR%\stage-%VERSION%"
+if exist "%STAGE%" rmdir /S /Q "%STAGE%"
+mkdir "%STAGE%"
+
+echo [client-release] 版本: %VERSION%
+echo [client-release] 组装目录: %STAGE%
+
+copy /Y "%JAR_PATH%" "%STAGE%\MesClient.jar" >nul
+if errorlevel 1 (
+  echo 错误: 复制 JAR 失败
+  exit /b 1
+)
+
+REM 写入旁路版本文件(自动升级以此为准)
+> "%STAGE%\app.version" echo %VERSION%
+
+if not "%EXE_PATH%"=="" (
+  if exist "%EXE_PATH%" (
+    copy /Y "%EXE_PATH%" "%STAGE%\MesClient.exe" >nul
+    echo [client-release] 已包含 EXE
+    REM 同目录 ini(若存在)
+    set "INI_PATH=%~dpn3.l4j.ini"
+    if exist "!INI_PATH!" (
+      copy /Y "!INI_PATH!" "%STAGE%\MesClient.l4j.ini" >nul
+      echo [client-release] 已包含 l4j.ini
+    )
+  ) else (
+    echo 警告: EXE 不存在,仅打包 JAR: %EXE_PATH%
+  )
+) else (
+  echo [client-release] 未指定 EXE,仅打包 JAR + app.version(壳未变时可用)
+)
+
+set "ZIP_PATH=%OUT_DIR%\MesClient-%VERSION%.zip"
+if exist "%ZIP_PATH%" del /F /Q "%ZIP_PATH%"
+
+REM 优先使用 tar(Win10+),否则尝试 PowerShell Compress-Archive
+where tar >nul 2>&1
+if not errorlevel 1 (
+  pushd "%STAGE%"
+  tar -a -c -f "%ZIP_PATH%" *
+  set "TAR_ERR=!errorlevel!"
+  popd
+  if not "!TAR_ERR!"=="0" (
+    echo 错误: tar 打包失败
+    exit /b 1
+  )
+) else (
+  powershell -NoProfile -Command "Compress-Archive -Path '%STAGE%\*' -DestinationPath '%ZIP_PATH%' -Force"
+  if errorlevel 1 (
+    echo 错误: Compress-Archive 打包失败
+    exit /b 1
+  )
+)
+
+REM 生成可上传的 version.json 模板
+set "JSON_PATH=%OUT_DIR%\version.json"
+(
+  echo {
+  echo   "version": "%VERSION%",
+  echo   "force": false,
+  echo   "packageUrl": "http://127.0.0.1:8980/client-update/MesClient-%VERSION%.zip",
+  echo   "sha256": "",
+  echo   "notes": "请修改 packageUrl 为实际服务器地址后上传"
+  echo }
+) > "%JSON_PATH%"
+
+echo.
+echo [client-release] 完成
+echo   zip : %ZIP_PATH%
+echo   json: %JSON_PATH%
+echo.
+echo 下一步:
+echo   1. 将 zip 与 version.json 上传到服务器 /client-update/
+echo   2. 修改 version.json 中的 packageUrl 为实际 IP
+echo   3. 日常发版请用 jar2exe「JAR 与 EXE 分离」模式
+echo.
+exit /b 0

TEMPAT SAMPAH
tools/jar2exe/dist/jar2exe-tool.jar


TEMPAT SAMPAH
tools/jar2exe/dist/jar2exe.exe


+ 5 - 0
tools/jar2exe/dist/jar2exe.l4j.ini

@@ -0,0 +1,5 @@
+# Auto generated by jar2exe - do not delete
+# Force JVM encoding so Chinese APIs work without changing business code
+-Dfile.encoding=UTF-8
+-Dsun.jnu.encoding=UTF-8
+-Dclient.encoding.override=UTF-8

+ 5 - 2
tools/jar2exe/dist/launch4j-config.xml

@@ -2,8 +2,8 @@
 <launch4jConfig>
   <dontWrapJar>false</dontWrapJar>
   <headerType>gui</headerType>
-  <jar>C:/work/demo/dayang/mesclient-OKNG/tools/jar2exe/dist/jar2exe-tool.jar</jar>
-  <outfile>C:/work/demo/dayang/mesclient-OKNG/tools/jar2exe/dist/jar2exe.exe</outfile>
+  <jar>c:/work/demo/dayang/mesclient-OKNG/tools/jar2exe/dist/jar2exe-tool.jar</jar>
+  <outfile>c:/work/demo/dayang/mesclient-OKNG/tools/jar2exe/dist/jar2exe.exe</outfile>
   <errTitle>JAR2EXE</errTitle>
   <cmdLine></cmdLine>
   <chdir>.</chdir>
@@ -24,5 +24,8 @@
     <maxVersion></maxVersion>
     <jdkPreference>preferJre</jdkPreference>
     <runtimeBits>64/32</runtimeBits>
+    <opt>-Dfile.encoding=UTF-8</opt>
+    <opt>-Dsun.jnu.encoding=UTF-8</opt>
+    <opt>-Dclient.encoding.override=UTF-8</opt>
   </jre>
 </launch4jConfig>

TEMPAT SAMPAH
tools/jar2exe/dist/release/jar2exe.exe


+ 5 - 0
tools/jar2exe/dist/release/jar2exe.l4j.ini

@@ -0,0 +1,5 @@
+# Auto generated by jar2exe - do not delete
+# Force JVM encoding so Chinese APIs work without changing business code
+-Dfile.encoding=UTF-8
+-Dsun.jnu.encoding=UTF-8
+-Dclient.encoding.override=UTF-8

+ 26 - 2
tools/jar2exe/jar2exe.ps1

@@ -161,7 +161,8 @@ function New-Launch4jConfig {
         [string]$JreMin,
         [string]$Icon,
         [string]$Header,
-        [bool]$SeparateJar
+        [bool]$SeparateJar,
+        [string]$FileEncoding = "GBK"
     )
 
     $jarAbs = $JarFile.Replace("\", "/")
@@ -172,6 +173,15 @@ function New-Launch4jConfig {
         $iconXml = "  <icon>$iconAbs</icon>"
     }
 
+    $encodingOpts = ""
+    if (-not [string]::IsNullOrWhiteSpace($FileEncoding) -and $FileEncoding -ne "system") {
+        $encodingOpts = @"
+    <opt>-Dfile.encoding=$FileEncoding</opt>
+    <opt>-Dsun.jnu.encoding=$FileEncoding</opt>
+    <opt>-Dclient.encoding.override=$FileEncoding</opt>
+"@
+    }
+
     $xml = @"
 <?xml version="1.0" encoding="UTF-8"?>
 <launch4jConfig>
@@ -197,6 +207,7 @@ $iconXml
     <maxVersion></maxVersion>
     <jdkPreference>preferJre</jdkPreference>
     <runtimeBits>64/32</runtimeBits>
+$encodingOpts
   </jre>
 </launch4jConfig>
 "@
@@ -277,7 +288,8 @@ New-Launch4jConfig -ConfigPath $configPath `
     -JreMin $MinJreVersion `
     -Icon $IconPath `
     -Header $HeaderType `
-    -SeparateJar $DontWrapJar
+    -SeparateJar $DontWrapJar `
+    -FileEncoding "GBK"
 
 Write-Step "正在生成 EXE..."
 $process = Start-Process -FilePath $launch4jExe -ArgumentList "`"$configPath`"" -Wait -PassThru -NoNewWindow
@@ -293,11 +305,23 @@ if (-not (Test-Path -LiteralPath $OutputExe)) {
     exit 1
 }
 
+$iniPath = [System.IO.Path]::ChangeExtension($OutputExe, ".l4j.ini")
+@"
+# Auto generated by jar2exe - do not delete
+-Dfile.encoding=GBK
+-Dsun.jnu.encoding=GBK
+-Dclient.encoding.override=GBK
+-Duser.language=zh
+-Duser.country=CN
+"@ | Set-Content -LiteralPath $iniPath -Encoding Ascii
+Write-Ok "编码配置: $iniPath"
+
 Write-Ok "转换完成!"
 Write-Ok "EXE 文件: $OutputExe"
 Write-Host ""
 Write-Host "说明:" -ForegroundColor Yellow
 Write-Host "  - EXE requires JRE $MinJreVersion or higher on target machine"
+Write-Host "  - Keep the .l4j.ini file next to the EXE when distributing"
 Write-Host "  - If using -DontWrapJar, distribute JAR and EXE together"
 Write-Host "  - For bundled JRE, configure bundled JRE path in Launch4j and regenerate"
 

+ 1 - 0
tools/jar2exe/package-release.bat

@@ -33,5 +33,6 @@ echo 目录内容:
 echo   jar2exe.exe      - 双击运行图形工具
 echo   launch4j\        - 转换引擎(需与 exe 同目录)
 echo.
+echo 打包客户端时请勾选「中文登录兼容补丁」,并选择 UTF-8。
 echo 将整个 release 文件夹复制到任意位置即可使用。
 exit /b 0

+ 23 - 0
tools/jar2exe/patch/com/mes/util/Base64Utils.java

@@ -0,0 +1,23 @@
+package com.mes.util;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * 打包注入补丁:与 Jeesite 后端 UTF-8 解码一致。
+ * 后端若收到 GBK 的 Base64,日志会出现类似「��nx」的乱码。
+ */
+public class Base64Utils {
+    public static String getBase64(String str) {
+        if (str == null) {
+            return "";
+        }
+        String encoded = Base64.getEncoder().encodeToString(str.getBytes(StandardCharsets.UTF_8));
+        try {
+            return URLEncoder.encode(encoded, "UTF-8");
+        } catch (Exception e) {
+            return encoded;
+        }
+    }
+}

+ 210 - 0
tools/jar2exe/src/com/jar2exe/ChineseLoginPatcher.java

@@ -0,0 +1,210 @@
+package com.jar2exe;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.zip.ZipEntry;
+
+/**
+ * 打包时注入中文登录兼容补丁:替换 JAR 内 Base64Utils,不改动业务工程源码。
+ */
+public class ChineseLoginPatcher {
+
+    private static final String TARGET_ENTRY = "com/mes/util/Base64Utils.class";
+
+    /**
+     * 根据后端日志确认:Jeesite 按 UTF-8 解码登录名 Base64。
+     * 若客户端用 GBK 做 Base64,服务端会出现类似「��nx」的乱码。
+     * 因此补丁固定 UTF-8,并对结果做 URL 编码(避免 + 在查询参数中变空格)。
+     */
+    private static final String PATCH_SOURCE =
+            "package com.mes.util;\n"
+                    + "\n"
+                    + "import java.net.URLEncoder;\n"
+                    + "import java.nio.charset.StandardCharsets;\n"
+                    + "import java.util.Base64;\n"
+                    + "\n"
+                    + "public class Base64Utils {\n"
+                    + "    public static String getBase64(String str) {\n"
+                    + "        if (str == null) {\n"
+                    + "            return \"\";\n"
+                    + "        }\n"
+                    + "        String encoded = Base64.getEncoder().encodeToString(str.getBytes(StandardCharsets.UTF_8));\n"
+                    + "        try {\n"
+                    + "            return URLEncoder.encode(encoded, \"UTF-8\");\n"
+                    + "        } catch (Exception e) {\n"
+                    + "            return encoded;\n"
+                    + "        }\n"
+                    + "    }\n"
+                    + "}\n";
+
+    private ChineseLoginPatcher() {
+    }
+
+    /**
+     * 生成已注入补丁的临时 JAR。
+     */
+    public static File apply(File sourceJar, File toolRoot, File workDir, Launch4jHelper.LogCallback callback)
+            throws IOException, InterruptedException {
+        if (!sourceJar.exists()) {
+            throw new IOException("JAR 不存在: " + sourceJar.getAbsolutePath());
+        }
+        if (!workDir.exists() && !workDir.mkdirs()) {
+            throw new IOException("无法创建补丁目录: " + workDir.getAbsolutePath());
+        }
+
+        File classFile = compilePatchClass(workDir, callback);
+        File patchedJar = new File(workDir, "patched-input.jar");
+        replaceClassInJar(sourceJar, patchedJar, TARGET_ENTRY, classFile, callback);
+        callback.log("中文登录补丁已注入: " + TARGET_ENTRY);
+        return patchedJar;
+    }
+
+    private static File compilePatchClass(File workDir, Launch4jHelper.LogCallback callback)
+            throws IOException, InterruptedException {
+        File srcDir = new File(workDir, "patch-src/com/mes/util");
+        if (!srcDir.exists() && !srcDir.mkdirs()) {
+            throw new IOException("无法创建目录: " + srcDir.getAbsolutePath());
+        }
+        File patchSrc = new File(srcDir, "Base64Utils.java");
+        try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(patchSrc), StandardCharsets.UTF_8)) {
+            writer.write(PATCH_SOURCE);
+        }
+
+        File classesDir = new File(workDir, "patch-classes");
+        if (!classesDir.exists() && !classesDir.mkdirs()) {
+            throw new IOException("无法创建目录: " + classesDir.getAbsolutePath());
+        }
+
+        callback.log("正在编译中文登录补丁...");
+        ProcessBuilder builder = new ProcessBuilder(
+                "javac",
+                "-encoding", "UTF-8",
+                "-source", "1.8",
+                "-target", "1.8",
+                "-d", classesDir.getAbsolutePath(),
+                patchSrc.getAbsolutePath()
+        );
+        builder.redirectErrorStream(true);
+        Process process = builder.start();
+        String output = readProcessOutput(process.getInputStream());
+        boolean finished = process.waitFor(60, TimeUnit.SECONDS);
+        if (!finished) {
+            process.destroyForcibly();
+            throw new IOException("编译补丁超时");
+        }
+        if (process.exitValue() != 0) {
+            throw new IOException("编译补丁失败(请确认本机已安装 JDK 且 javac 可用):\n" + output);
+        }
+
+        File classFile = new File(classesDir, TARGET_ENTRY.replace('/', File.separatorChar));
+        if (!classFile.exists()) {
+            throw new IOException("补丁 class 未生成: " + classFile.getAbsolutePath());
+        }
+        return classFile;
+    }
+
+    private static void replaceClassInJar(File sourceJar, File targetJar, String entryName,
+                                         File classFile, Launch4jHelper.LogCallback callback) throws IOException {
+        boolean replaced = false;
+        try (JarFile jarFile = new JarFile(sourceJar);
+             JarOutputStream jos = new JarOutputStream(new FileOutputStream(targetJar))) {
+            byte[] buffer = new byte[8192];
+            java.util.Enumeration<JarEntry> entries = jarFile.entries();
+            while (entries.hasMoreElements()) {
+                JarEntry entry = entries.nextElement();
+                String name = entry.getName();
+                if (name.equals(entryName)) {
+                    JarEntry newEntry = new JarEntry(entryName);
+                    jos.putNextEntry(newEntry);
+                    try (InputStream in = new BufferedInputStream(new FileInputStream(classFile))) {
+                        int len;
+                        while ((len = in.read(buffer)) != -1) {
+                            jos.write(buffer, 0, len);
+                        }
+                    }
+                    jos.closeEntry();
+                    replaced = true;
+                    continue;
+                }
+                JarEntry copy = new JarEntry(name);
+                if (entry.getTime() >= 0) {
+                    copy.setTime(entry.getTime());
+                }
+                jos.putNextEntry(copy);
+                if (!entry.isDirectory()) {
+                    try (InputStream in = jarFile.getInputStream(entry)) {
+                        int len;
+                        while ((len = in.read(buffer)) != -1) {
+                            jos.write(buffer, 0, len);
+                        }
+                    }
+                }
+                jos.closeEntry();
+            }
+        }
+
+        if (!replaced) {
+            callback.log("原 JAR 未找到 " + entryName + ",将追加补丁类");
+            File tempJar = new File(targetJar.getParentFile(), "patched-append.jar");
+            copyFile(targetJar, tempJar);
+            try (JarFile jarFile = new JarFile(tempJar);
+                 JarOutputStream jos = new JarOutputStream(new FileOutputStream(targetJar))) {
+                byte[] buffer = new byte[8192];
+                java.util.Enumeration<JarEntry> entries = jarFile.entries();
+                while (entries.hasMoreElements()) {
+                    JarEntry entry = entries.nextElement();
+                    jos.putNextEntry(new ZipEntry(entry.getName()));
+                    if (!entry.isDirectory()) {
+                        try (InputStream in = jarFile.getInputStream(entry)) {
+                            int len;
+                            while ((len = in.read(buffer)) != -1) {
+                                jos.write(buffer, 0, len);
+                            }
+                        }
+                    }
+                    jos.closeEntry();
+                }
+                jos.putNextEntry(new ZipEntry(entryName));
+                try (InputStream in = new BufferedInputStream(new FileInputStream(classFile))) {
+                    int len;
+                    while ((len = in.read(buffer)) != -1) {
+                        jos.write(buffer, 0, len);
+                    }
+                }
+                jos.closeEntry();
+            }
+            tempJar.delete();
+        }
+    }
+
+    private static void copyFile(File source, File target) throws IOException {
+        try (InputStream in = new FileInputStream(source);
+             FileOutputStream out = new FileOutputStream(target)) {
+            byte[] buffer = new byte[8192];
+            int len;
+            while ((len = in.read(buffer)) != -1) {
+                out.write(buffer, 0, len);
+            }
+        }
+    }
+
+    private static String readProcessOutput(InputStream in) throws IOException {
+        StringBuilder sb = new StringBuilder();
+        byte[] buffer = new byte[1024];
+        int len;
+        while ((len = in.read(buffer)) != -1) {
+            sb.append(new String(buffer, 0, len, java.nio.charset.Charset.defaultCharset()));
+        }
+        return sb.toString();
+    }
+}

+ 45 - 0
tools/jar2exe/src/com/jar2exe/Jar2ExeApp.java

@@ -24,6 +24,13 @@ public class Jar2ExeApp extends JFrame implements Launch4jHelper.LogCallback {
     private final JTextField minJreField = new JTextField("1.8.0", 8);
     private final JComboBox<String> headerTypeCombo = new JComboBox<>(new String[]{"GUI 窗口程序", "控制台程序"});
     private final JComboBox<String> wrapModeCombo = new JComboBox<>(new String[]{"JAR 嵌入 EXE(单文件)", "JAR 与 EXE 分离(同目录)"});
+    /** 解决 EXE 启动后接口中文乱码:强制 JVM 编码 */
+    private final JComboBox<String> encodingCombo = new JComboBox<>(new String[]{
+            "UTF-8(Jeesite 后端推荐)",
+            "GBK",
+            "系统默认(不强制)"
+    });
+    private final JCheckBox patchChineseLoginCheck = new JCheckBox("中文登录兼容补丁(不改源码,Base64 固定 UTF-8)", true);
     private final JTextArea logArea = new JTextArea();
     private final JButton convertButton = new JButton("开始打包");
     private final JButton openOutputButton = new JButton("打开输出目录");
@@ -101,6 +108,23 @@ public class Jar2ExeApp extends JFrame implements Launch4jHelper.LogCallback {
         gbc.weightx = 1;
         panel.add(wrapModeCombo, gbc);
 
+        gbc.gridx = 0;
+        gbc.gridy = ++row;
+        gbc.weightx = 0;
+        panel.add(new JLabel("字符编码"), gbc);
+
+        gbc.gridx = 1;
+        gbc.weightx = 1;
+        encodingCombo.setSelectedIndex(0);
+        encodingCombo.setToolTipText("后端日志出现乱码登录名时请用 UTF-8");
+        panel.add(encodingCombo, gbc);
+
+        gbc.gridx = 1;
+        gbc.gridy = ++row;
+        gbc.weightx = 1;
+        patchChineseLoginCheck.setToolTipText("注入 Base64Utils:UTF-8 + URL 编码,避免中文登录名变成后端乱码");
+        panel.add(patchChineseLoginCheck, gbc);
+
         return panel;
     }
 
@@ -310,6 +334,8 @@ public class Jar2ExeApp extends JFrame implements Launch4jHelper.LogCallback {
             options.minJreVersion = minJreField.getText().trim();
             options.headerType = headerTypeCombo.getSelectedIndex() == 0 ? "gui" : "console";
             options.dontWrapJar = wrapModeCombo.getSelectedIndex() == 1;
+            options.fileEncoding = resolveSelectedEncoding();
+            options.patchChineseLogin = patchChineseLoginCheck.isSelected();
 
             String iconPath = iconPathField.getText().trim();
             if (!iconPath.isEmpty()) {
@@ -321,6 +347,11 @@ public class Jar2ExeApp extends JFrame implements Launch4jHelper.LogCallback {
             log("主类: " + mainClass);
             log("程序类型: " + options.headerType);
             log("打包方式: " + (options.dontWrapJar ? "分离" : "嵌入"));
+            log("字符编码: " + (options.fileEncoding == null || "system".equals(options.fileEncoding) ? "系统默认" : options.fileEncoding));
+            log("中文登录补丁: " + (options.patchChineseLogin ? "开启" : "关闭"));
+            if (options.patchChineseLogin) {
+                log("说明: 注入 Base64Utils(UTF-8),避免后端出现 乱码登录名(如 ��nx)");
+            }
 
             launch4jHelper.ensureLaunch4j(this);
             launch4jHelper.convert(options, this);
@@ -355,6 +386,20 @@ public class Jar2ExeApp extends JFrame implements Launch4jHelper.LogCallback {
         }
     }
 
+    /**
+     * 根据界面选项解析要写入 Launch4j 的 JVM 编码。
+     */
+    private String resolveSelectedEncoding() {
+        int index = encodingCombo.getSelectedIndex();
+        if (index == 1) {
+            return "GBK";
+        }
+        if (index == 2) {
+            return "system";
+        }
+        return "UTF-8";
+    }
+
     private void openOutputDirectory() {
         String outputPath = outputExeField.getText().trim();
         if (outputPath.isEmpty()) {

+ 149 - 15
tools/jar2exe/src/com/jar2exe/Launch4jHelper.java

@@ -29,8 +29,10 @@ public class Launch4jHelper {
             "https://downloads.sourceforge.net/project/launch4j/launch4j-3/3.50/launch4j-3.50-win32.zip";
 
     private final File launch4jDir;
+    private final File toolRoot;
 
     public Launch4jHelper(File toolRootDir) {
+        this.toolRoot = toolRootDir;
         this.launch4jDir = new File(toolRootDir, "launch4j");
     }
 
@@ -85,21 +87,50 @@ public class Launch4jHelper {
      */
     public File convert(ConvertOptions options, LogCallback callback) throws IOException, InterruptedException {
         File workspace = null;
+        File patchDir = null;
         ConvertOptions workOptions = options;
-        boolean useWorkspace = needsAsciiWorkspace(options);
-
-        if (useWorkspace) {
-            callback.log("检测到路径包含中文或非 ASCII 字符,已切换到临时目录打包...");
-            workspace = createAsciiWorkspace(options, callback);
-            workOptions = workspaceOptions(workspace, options);
-        } else {
-            File outputParent = options.outputExe.getParentFile();
-            if (outputParent != null && !outputParent.exists() && !outputParent.mkdirs()) {
-                throw new IOException("无法创建输出目录: " + outputParent.getAbsolutePath());
-            }
-        }
 
         try {
+            // 可选:打包时注入中文登录补丁(不改业务工程源码)
+            if (options.patchChineseLogin) {
+                patchDir = new File(System.getProperty("java.io.tmpdir"), "jar2exe-patch-" + System.nanoTime());
+                File patchedJar = ChineseLoginPatcher.apply(options.jarFile, toolRoot, patchDir, callback);
+                workOptions = copyOptions(options);
+                workOptions.jarFile = patchedJar;
+
+                // 分离模式:把补丁 JAR 落到输出目录,避免临时文件删除后 EXE 找不到 JAR
+                if (options.dontWrapJar) {
+                    File outputParent = options.outputExe.getParentFile();
+                    if (outputParent != null && !outputParent.exists() && !outputParent.mkdirs()) {
+                        throw new IOException("无法创建输出目录: " + outputParent.getAbsolutePath());
+                    }
+                    String baseName = options.jarFile.getName();
+                    if (baseName.toLowerCase().endsWith(".jar")) {
+                        baseName = baseName.substring(0, baseName.length() - 4) + "-patched.jar";
+                    } else {
+                        baseName = baseName + "-patched.jar";
+                    }
+                    File retainedJar = new File(outputParent, baseName);
+                    copyFile(patchedJar, retainedJar);
+                    workOptions.jarFile = retainedJar;
+                    callback.log("分离模式补丁 JAR: " + retainedJar.getAbsolutePath());
+                }
+                callback.log("已启用中文登录兼容补丁(Base64 固定 UTF-8,对齐 Jeesite 后端)");
+            }
+
+            boolean useWorkspace = needsAsciiWorkspace(workOptions);
+            if (useWorkspace) {
+                callback.log("检测到路径包含中文或非 ASCII 字符,已切换到临时目录打包...");
+                workspace = createAsciiWorkspace(workOptions, callback);
+                ConvertOptions beforeWorkspace = workOptions;
+                workOptions = workspaceOptions(workspace, beforeWorkspace);
+            } else {
+                File outputParent = workOptions.outputExe.getParentFile();
+                if (outputParent != null && !outputParent.exists() && !outputParent.mkdirs()) {
+                    throw new IOException("无法创建输出目录: " + outputParent.getAbsolutePath());
+                }
+            }
+
             File launch4jExe = ensureLaunch4j(callback);
             File configFile = createConfig(workOptions, workspace != null ? workspace : workOptions.outputExe.getParentFile());
 
@@ -115,7 +146,7 @@ public class Launch4jHelper {
                 throw new IOException("EXE 未生成,请查看上方日志");
             }
 
-            if (useWorkspace) {
+            if (workspace != null) {
                 File outputParent = options.outputExe.getParentFile();
                 if (outputParent != null && !outputParent.exists() && !outputParent.mkdirs()) {
                     throw new IOException("无法创建输出目录: " + outputParent.getAbsolutePath());
@@ -124,21 +155,52 @@ public class Launch4jHelper {
                 callback.log("已复制 EXE 到: " + options.outputExe.getAbsolutePath());
 
                 if (options.dontWrapJar) {
-                    File targetJar = new File(outputParent, options.jarFile.getName());
-                    copyFile(options.jarFile, targetJar);
+                    // 工作区里的 jar 可能是 input.jar,需复制为最终补丁名或原名
+                    File targetJar;
+                    if (options.patchChineseLogin) {
+                        String baseName = options.jarFile.getName();
+                        if (baseName.toLowerCase().endsWith(".jar")) {
+                            baseName = baseName.substring(0, baseName.length() - 4) + "-patched.jar";
+                        } else {
+                            baseName = baseName + "-patched.jar";
+                        }
+                        targetJar = new File(outputParent, baseName);
+                    } else {
+                        targetJar = new File(outputParent, options.jarFile.getName());
+                    }
+                    copyFile(workOptions.jarFile, targetJar);
                     callback.log("已复制 JAR 到: " + targetJar.getAbsolutePath());
                 }
             }
 
+            writeEncodingIni(options.outputExe, options.fileEncoding, callback);
             return options.outputExe;
         } finally {
             if (workspace != null) {
                 deleteDirectory(workspace);
                 callback.log("已清理临时目录");
             }
+            if (patchDir != null) {
+                deleteDirectory(patchDir);
+            }
         }
     }
 
+    private static ConvertOptions copyOptions(ConvertOptions source) {
+        ConvertOptions copy = new ConvertOptions();
+        copy.jarFile = source.jarFile;
+        copy.outputExe = source.outputExe;
+        copy.mainClass = source.mainClass;
+        copy.appTitle = source.appTitle;
+        copy.minJreVersion = source.minJreVersion;
+        copy.iconFile = source.iconFile;
+        copy.headerType = source.headerType;
+        copy.dontWrapJar = source.dontWrapJar;
+        copy.fileEncoding = source.fileEncoding;
+        copy.patchChineseLogin = source.patchChineseLogin;
+        return copy;
+    }
+
     /**
      * 生成 Launch4j 配置文件。
      */
@@ -186,6 +248,18 @@ public class Launch4jHelper {
         xml.append("    <maxVersion></maxVersion>\n");
         xml.append("    <jdkPreference>preferJre</jdkPreference>\n");
         xml.append("    <runtimeBits>64/32</runtimeBits>\n");
+        // 显式指定 JVM 编码,避免 EXE 启动后接口中文乱码(业务 JAR 无需改代码)
+        String encoding = normalizeEncoding(options.fileEncoding);
+        if (encoding != null) {
+            xml.append("    <opt>-Dfile.encoding=").append(escapeXml(encoding)).append("</opt>\n");
+            xml.append("    <opt>-Dsun.jnu.encoding=").append(escapeXml(encoding)).append("</opt>\n");
+            xml.append("    <opt>-Dclient.encoding.override=").append(escapeXml(encoding)).append("</opt>\n");
+            if ("GBK".equalsIgnoreCase(encoding) || "GB2312".equalsIgnoreCase(encoding)
+                    || "GB18030".equalsIgnoreCase(encoding)) {
+                xml.append("    <opt>-Duser.language=zh</opt>\n");
+                xml.append("    <opt>-Duser.country=CN</opt>\n");
+            }
+        }
         xml.append("  </jre>\n");
         xml.append("</launch4jConfig>\n");
 
@@ -227,6 +301,38 @@ public class Launch4jHelper {
         return process.waitFor();
     }
 
+    /**
+     * 在 EXE 同目录生成 Launch4j 运行时 ini,双重保障编码参数生效。
+     */
+    private void writeEncodingIni(File outputExe, String fileEncoding, LogCallback callback) throws IOException {
+        String encoding = normalizeEncoding(fileEncoding);
+        if (encoding == null || outputExe == null) {
+            return;
+        }
+        String exeName = outputExe.getName();
+        int dot = exeName.lastIndexOf('.');
+        String baseName = dot > 0 ? exeName.substring(0, dot) : exeName;
+        File iniFile = new File(outputExe.getParentFile(), baseName + ".l4j.ini");
+
+        StringBuilder content = new StringBuilder();
+        content.append("# Auto generated by jar2exe - do not delete\r\n");
+        content.append("# Force JVM encoding so Chinese APIs work without changing business code\r\n");
+        content.append("-Dfile.encoding=").append(encoding).append("\r\n");
+        content.append("-Dsun.jnu.encoding=").append(encoding).append("\r\n");
+        content.append("-Dclient.encoding.override=").append(encoding).append("\r\n");
+        if ("GBK".equalsIgnoreCase(encoding) || "GB2312".equalsIgnoreCase(encoding)
+                || "GB18030".equalsIgnoreCase(encoding)) {
+            content.append("-Duser.language=zh\r\n");
+            content.append("-Duser.country=CN\r\n");
+        }
+
+        try (FileOutputStream fos = new FileOutputStream(iniFile)) {
+            fos.write(content.toString().getBytes(StandardCharsets.US_ASCII));
+        }
+        callback.log("已生成编码配置: " + iniFile.getAbsolutePath());
+        callback.log("说明: 请将 " + iniFile.getName() + " 与 EXE 放在同一目录一起分发");
+    }
+
     private static boolean needsAsciiWorkspace(ConvertOptions options) {
         if (containsNonAscii(options.jarFile.getAbsolutePath())) {
             return true;
@@ -285,9 +391,33 @@ public class Launch4jHelper {
         work.iconFile = iconCopy;
         work.headerType = options.headerType;
         work.dontWrapJar = options.dontWrapJar;
+        work.fileEncoding = options.fileEncoding;
+        work.patchChineseLogin = options.patchChineseLogin;
         return work;
     }
 
+    /**
+     * 规范化编码名称;空或“系统默认”时返回 null(不写入 JVM 参数)。
+     */
+    private static String normalizeEncoding(String encoding) {
+        if (encoding == null) {
+            return null;
+        }
+        String value = encoding.trim();
+        if (value.isEmpty() || "system".equalsIgnoreCase(value) || "default".equalsIgnoreCase(value)
+                || "系统默认".equals(value)) {
+            return null;
+        }
+        if ("UTF8".equalsIgnoreCase(value) || "utf-8".equalsIgnoreCase(value)) {
+            return "UTF-8";
+        }
+        if ("GB2312".equalsIgnoreCase(value) || "GB18030".equalsIgnoreCase(value)
+                || "gbk".equalsIgnoreCase(value)) {
+            return value.toUpperCase().startsWith("GB") ? value.toUpperCase() : "GBK";
+        }
+        return value;
+    }
+
     private static void copyFile(File source, File target) throws IOException {
         File parent = target.getParentFile();
         if (parent != null && !parent.exists() && !parent.mkdirs()) {
@@ -415,6 +545,10 @@ public class Launch4jHelper {
         public File iconFile;
         public String headerType = "gui";
         public boolean dontWrapJar = false;
+        /** JVM 文件编码。Jeesite 后端请用 UTF-8 */
+        public String fileEncoding = "UTF-8";
+        /** 打包时注入中文登录补丁(Base64 固定 UTF-8),不改业务工程源码 */
+        public boolean patchChineseLogin = true;
     }
 
     /**

+ 3 - 0
tools/jar2exe/src/com/jar2exe/PackSelf.java

@@ -24,6 +24,9 @@ public class PackSelf {
         options.appTitle = "JAR2EXE";
         options.minJreVersion = "1.8.0";
         options.headerType = "gui";
+        // 工具自身界面用 UTF-8 即可;不要给工具 JAR 注入业务补丁
+        options.fileEncoding = "UTF-8";
+        options.patchChineseLogin = false;
 
         System.out.println("[pack] JAR: " + jarFile.getAbsolutePath());
         System.out.println("[pack] EXE: " + outputExe.getAbsolutePath());