Ver Fonte

新增日志记录功能

17491 há 5 horas atrás
pai
commit
9d9916af2f

+ 4 - 0
.codex/skills/java-client-file-logging/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "Java Client File Logging"
+  short_description: "Add durable file logs to Java desktop clients"
+  default_prompt: "Use $java-client-file-logging to add client-side file logging to this Java desktop application."

+ 88 - 0
.codex/skills/java-client-file-logging/assets/AppLog.java

@@ -0,0 +1,88 @@
+package com.example.client.util;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Lightweight local logging for a standalone Java desktop client.
+ * Change APP_NAME and package before use.
+ */
+public final class AppLog {
+    private static final String APP_NAME = "client";
+    private static final Object LOCK = new Object();
+    private static final SimpleDateFormat TIME = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+    private static PrintStream out;
+    private static volatile String exitReason;
+    private static volatile String uncaughtThread;
+    private static volatile Throwable uncaughtException;
+
+    private AppLog() { }
+
+    public static void init() {
+        synchronized (LOCK) {
+            if (out != null) return;
+            try {
+                File code = new File(AppLog.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+                File base = code.isFile() ? code.getParentFile() : new File(System.getProperty("user.dir"));
+                File dir = new File(base, "logs");
+                if (!dir.exists() && !dir.mkdirs()) return;
+
+                String day = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+                out = new PrintStream(new FileOutputStream(new File(dir, APP_NAME + "-" + day + ".log"), true), true, "UTF-8");
+                Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+                    public void uncaughtException(Thread thread, Throwable exception) {
+                        exitReason = "UNCAUGHT_EXCEPTION";
+                        uncaughtThread = thread.getName();
+                        uncaughtException = exception;
+                    }
+                });
+                Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
+                    public void run() {
+                        if ("USER_CLOSE".equals(exitReason)) {
+                            log("INFO", "Client exited, reason=user-close", null);
+                        } else if ("UNCAUGHT_EXCEPTION".equals(exitReason)) {
+                            log("ERROR", "Client exited, reason=uncaught-exception, thread=" + clean(uncaughtThread), uncaughtException);
+                        }
+                    }
+                }, APP_NAME + "-log-shutdown"));
+            } catch (Exception ignored) {
+                // Logging is best-effort and must not stop client startup.
+            }
+        }
+    }
+
+    public static void login(String userId, String method, boolean success) {
+        log(success ? "INFO" : "WARN", "Login " + (success ? "succeeded" : "failed")
+                + ", method=" + clean(method) + ", user=" + clean(userId), null);
+    }
+
+    public static void scan(String type, String code) {
+        log("INFO", "Scan, type=" + clean(type) + ", code=" + clean(code), null);
+    }
+
+    public static void work(String action, String code, boolean success) {
+        log(success ? "INFO" : "WARN", "Operation " + (success ? "succeeded" : "failed")
+                + ", action=" + clean(action) + ", code=" + clean(code), null);
+    }
+
+    public static void markUserClose() {
+        exitReason = "USER_CLOSE";
+    }
+
+    private static String clean(String value) {
+        return value == null ? "" : value.replace('\r', ' ').replace('\n', ' ');
+    }
+
+    private static void log(String level, String message, Throwable exception) {
+        synchronized (LOCK) {
+            if (out == null) return;
+            out.println("[" + TIME.format(new Date()) + "][" + level + "]["
+                    + Thread.currentThread().getName() + "] " + message);
+            if (exception != null) exception.printStackTrace(out);
+            out.flush();
+        }
+    }
+}

+ 2 - 1
.gitignore

@@ -3,5 +3,6 @@
 classes
 bin
 out
+release
 *.db
-*.iml
+*.iml

+ 17 - 0
README-release.md

@@ -0,0 +1,17 @@
+# Windows release
+
+This client uses JavaFX (`JFXPanel`). Java 11 and later do not include JavaFX, so double-clicking the JAR with the system Java runtime can fail with `NoClassDefFoundError: javafx/embed/swing/JFXPanel`.
+
+After building `MesOP040.jar` in IDEA, generate the portable release:
+
+```powershell
+.\build-release.ps1
+```
+
+The generated `release\MesOP040` directory contains a Java 8 runtime with JavaFX. Distribute the entire directory and start the application with `start.bat`. Do not start `MesOP040.jar` directly on the target machine.
+
+If Java 8 is installed in a different location on the build machine:
+
+```powershell
+.\build-release.ps1 -Java8Home "C:\path\to\jdk8"
+```

+ 43 - 0
build-release.ps1

@@ -0,0 +1,43 @@
+param(
+    [string]$Java8Home = "D:\java\jdk\jdk8"
+)
+
+$ErrorActionPreference = "Stop"
+
+$projectRoot = $PSScriptRoot
+$sourceJar = Join-Path $projectRoot "classes\artifacts\MesOP040_jar\MesOP040.jar"
+$javaExe = Join-Path $Java8Home "bin\java.exe"
+$jreSource = Join-Path $Java8Home "jre"
+$jfxRuntime = Join-Path $jreSource "lib\ext\jfxrt.jar"
+$releaseRoot = Join-Path $projectRoot "release\MesOP040"
+
+if (-not (Test-Path -LiteralPath $sourceJar)) {
+    throw "未找到 JAR:$sourceJar。请先在 IDEA 执行 Build Artifacts -> MesOP040:jar -> Build。"
+}
+
+if (-not (Test-Path -LiteralPath $javaExe) -or -not (Test-Path -LiteralPath $jfxRuntime)) {
+    throw "Java8Home 必须指向包含 JavaFX 的 JDK 8。当前路径:$Java8Home"
+}
+
+if (Test-Path -LiteralPath $releaseRoot) {
+    Remove-Item -LiteralPath $releaseRoot -Recurse -Force
+}
+
+New-Item -ItemType Directory -Path $releaseRoot | Out-Null
+Copy-Item -LiteralPath $sourceJar -Destination (Join-Path $releaseRoot "MesOP040.jar")
+Copy-Item -LiteralPath $jreSource -Destination (Join-Path $releaseRoot "runtime") -Recurse
+
+$launcher = @'
+@echo off
+setlocal
+cd /d "%~dp0"
+if not exist "runtime\bin\javaw.exe" (
+  echo Missing bundled Java runtime.
+  pause
+  exit /b 1
+)
+start "MES OP040" /b "runtime\bin\javaw.exe" -jar "MesOP040.jar"
+'@
+Set-Content -LiteralPath (Join-Path $releaseRoot "start.bat") -Value $launcher -Encoding ASCII
+
+Write-Host "Release created: $releaseRoot"

+ 20 - 0
logs/mes-client-2026-07-14.log

@@ -0,0 +1,20 @@
+[2026-07-14 11:00:05.358][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 11:00:08.532][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:12:04.449][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 11:12:07.558][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:14:43.316][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 11:14:46.333][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:17:14.427][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 11:17:19.655][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:24:42.216][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:53:01.453][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 11:53:05.101][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:58:33.074][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 11:58:36.111][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 11:59:13.718][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 14:14:53.173][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 14:15:05.282][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 14:17:20.789][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 14:17:28.207][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭
+[2026-07-14 14:19:48.487][INFO][AWT-EventQueue-0] 登录成功,方式=权限校验,用户=system
+[2026-07-14 14:19:56.347][INFO][mes-log-shutdown] 客户端退出,原因=用户手动关闭

+ 11 - 0
src/com/mes/laser/LaserFlowManager.java

@@ -2,6 +2,7 @@ package com.mes.laser;
 
 import com.alibaba.fastjson2.JSONObject;
 import com.mes.ui.DataUtil;
+import com.mes.util.AppLog;
 
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -108,6 +109,7 @@ public class LaserFlowManager {
         executor.submit(() -> {
             JSONObject codeData = LaserClient.getCode(laserServerUrl, getCodePath, lineSn, oprno, laserTimeout);
             if (codeData == null) {
+                AppLog.work("获取镭雕码", "", false);
                 uiCallback.onStatus("获取镭雕码失败,请重试", true);
                 uiCallback.onLockButton(false);
                 return;
@@ -115,12 +117,14 @@ public class LaserFlowManager {
             String codeId = codeData.getString("codeId");
             String code = codeData.getString("code");
             if (codeId == null || code == null) {
+                AppLog.work("获取镭雕码", "", false);
                 uiCallback.onStatus("获取镭雕码返回数据不完整", true);
                 uiCallback.onLockButton(false);
                 return;
             }
 
             // 回显钢印码到扫码框
+            AppLog.work("获取镭雕码", code, true);
             uiCallback.onCodeReceived(code);
             uiCallback.onStatus("已获取镭雕码,正在发送开始信号...", false);
 
@@ -134,12 +138,14 @@ public class LaserFlowManager {
             }
 
             if (!startOk) {
+                AppLog.work("启动镭雕", code, false);
                 uiCallback.onStatus("发送开始镭雕信号失败,请人工确认设备状态后重试", true);
                 uiCallback.onLockButton(false);
                 return;
             }
 
             // 进入等待完成状态
+            AppLog.work("启动镭雕", code, true);
             currentCodeId = codeId;
             currentCode = code;
             waitingComplete = true;
@@ -168,6 +174,7 @@ public class LaserFlowManager {
      * 人工复位:超时或异常时,操作员手动点击解锁按钮
      */
     public void manualReset() {
+        AppLog.work("镭雕人工复位", currentCode, true);
         currentCodeId = null;
         currentCode = null;
         waitingComplete = false;
@@ -261,17 +268,20 @@ public class LaserFlowManager {
         currentCode = null;
 
         if (!"OK".equalsIgnoreCase(result)) {
+            AppLog.work("镭雕完成", steelSn, false);
             uiCallback.onStatus("镭雕失败:" + (errorMsg == null ? "" : errorMsg), true);
             uiCallback.onLockButton(false);
             return;
         }
 
         uiCallback.onStatus("镭雕完成,正在生成客户编码...", false);
+        AppLog.work("镭雕完成", steelSn, true);
 
         executor.submit(() -> {
             JSONObject genResp = DataUtil.genCustomerSn(prodCode, steelSn);
             if (genResp == null || genResp.get("result") == null
                     || !"true".equalsIgnoreCase(genResp.get("result").toString())) {
+                AppLog.work("生成客户编码", steelSn, false);
                 String msg = genResp != null && genResp.get("message") != null
                         ? genResp.get("message").toString() : "生成客户编码失败";
                 uiCallback.onStatus(msg, true);
@@ -280,6 +290,7 @@ public class LaserFlowManager {
             }
 
             String customerSn = genResp.get("data") == null ? "" : genResp.get("data").toString();
+            AppLog.work("生成客户编码", customerSn, true);
             // 覆盖回显为客户编码,随后按此编码提交建档
             uiCallback.onCodeReceived(customerSn);
 

+ 12 - 0
src/com/mes/ui/LoginFarme.java

@@ -5,6 +5,7 @@ import com.mes.component.MesRadio;
 import com.mes.component.MesWebView;
 import com.mes.util.Base64Utils;
 import com.mes.util.HttpUtils;
+import com.mes.util.AppLog;
 
 import javax.swing.*;
 import java.awt.*;
@@ -32,6 +33,7 @@ public class LoginFarme extends JFrame {
             @Override
             public void windowClosing(WindowEvent e) {
                 if (MesClient.confirmClose(LoginFarme.this)) {
+                    AppLog.markUserClose();
                     System.exit(0);
                 }
             }
@@ -104,6 +106,7 @@ public class LoginFarme extends JFrame {
         String user_str = userNameTxt.getText().toString();
         String password_str = userPasswordTxt.getText().toString();
         if(user_str.equalsIgnoreCase("")||password_str.equalsIgnoreCase("")) {
+            AppLog.login(user_str, "用户名密码", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"用户名或密码不能为空","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }
@@ -113,6 +116,7 @@ public class LoginFarme extends JFrame {
         String url = "http://"+MesClient.mes_server_ip+":8980/js/a/login?__ajax=json&username="+username+"&password="+password+"&validCode=&__sid=";
         String loginResult = HttpUtils.sendRequest(url);
         if(loginResult.equalsIgnoreCase("false")) {
+            AppLog.login(user_str, "用户名密码", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录异常,请检查网络或联系网络管理员","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }else {
@@ -121,6 +125,7 @@ public class LoginFarme extends JFrame {
                 //检查用户权限是否可登录界面
                 checkUserAuthority(retObj);
             }else {
+                AppLog.login(user_str, "用户名密码", false);
                 //ret = "msg save error";
                 //ret = false;
                 JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录失败,用户名或密码错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
@@ -135,10 +140,12 @@ public class LoginFarme extends JFrame {
         String scanContent = JOptionPane.showInputDialog(null, "请扫码工牌二维码");
         System.out.println("scanContent="+scanContent);
         if(scanContent!=null&&!scanContent.equalsIgnoreCase("")) {
+            AppLog.scan("工牌", scanContent);
             String url = "http://"+MesClient.mes_server_ip+":8980/js/a/mes/mesLogin/login?__login=true&__ajax=json&username="+scanContent;
             String loginResult = HttpUtils.sendRequest(url);
             System.out.println("loginResult="+loginResult);
             if(loginResult.equalsIgnoreCase("false")) {
+                AppLog.login(scanContent, "扫码", false);
                 JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录异常,请检查网络或联系网络管理员","提示窗口", JOptionPane.INFORMATION_MESSAGE);
                 return;
             }else {
@@ -147,11 +154,13 @@ public class LoginFarme extends JFrame {
                     //检查用户权限是否可登录界面
                     checkUserAuthority(retObj);
                 }else {
+                    AppLog.login(scanContent, "扫码", false);
                     JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录失败,用户名或密码错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
                     return;
                 }
             }
         }else {
+            AppLog.login("", "扫码", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"扫码内容错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }
@@ -175,10 +184,12 @@ public class LoginFarme extends JFrame {
                 JSONObject authObjTmp = JSONObject.parseObject(authorityObj.get("data").toString());
                 MesClient.mes_auth = Integer.parseInt(authObjTmp.getString("auth").toString());
                 if(MesClient.mes_auth==0) {
+                    AppLog.login(user_id, "权限校验", false);
                     //无权限登录
                     JOptionPane.showMessageDialog(MesClient.mesClientFrame,"您无权登录该工位","提示窗口", JOptionPane.INFORMATION_MESSAGE);
                     return;
                 }else if(MesClient.mes_auth==1||MesClient.mes_auth==2) {
+                    AppLog.login(user_id, "权限校验", true);
                     // 获取等于所处时间-当前小时
                     LocalDateTime now = LocalDateTime.now();
                     MesClient.userLoginHours = now.getHour();
@@ -221,6 +232,7 @@ public class LoginFarme extends JFrame {
             }
 
         }else {
+            AppLog.login(user_id, "权限校验", false);
             JOptionPane.showMessageDialog(MesClient.mesClientFrame,"登录失败,用户名或密码错误","提示窗口", JOptionPane.INFORMATION_MESSAGE);
             return;
         }

+ 10 - 1
src/com/mes/ui/MesClient.java

@@ -9,6 +9,7 @@ import com.mes.laser.LaserFlowManager;
 import com.mes.netty.NettyClient;
 import com.mes.util.DateLocalUtils;
 import com.mes.util.JdbcUtils;
+import com.mes.util.AppLog;
 import javafx.embed.swing.JFXPanel;
 
 import javax.swing.*;
@@ -118,6 +119,7 @@ public class MesClient extends JFrame {
     private static final ExecutorService laserActionExecutor = Executors.newSingleThreadExecutor();
 
     public static void main(String[] args) {
+        AppLog.init();
         if (LockUtil.getInstance().isAppActive() == true){
 //            JOptionPane.showMessageDialog(null, "已有一个程序在运行,程序退出");
             return;
@@ -153,7 +155,7 @@ public class MesClient extends JFrame {
                     initLaserFlow();
 
                 }catch (Exception e){
-                    e.printStackTrace();
+                    throw new RuntimeException("客户端初始化失败", e);
                 }
             }
         });
@@ -458,6 +460,7 @@ public class MesClient extends JFrame {
         //弹窗扫工件码
         String scanBarcode = JOptionPane.showInputDialog(null, scanBarcodeTitle);
         if(scanBarcode!=null&&!scanBarcode.equalsIgnoreCase("")) {
+            AppLog.scan(scan_type == 1 ? "工件码" : "物料批次码", scanBarcode);
             //获取用户
             getUser();
             //获取扫码内容36位
@@ -492,6 +495,7 @@ public class MesClient extends JFrame {
                 if(!sn.isEmpty()){
                     String qret = "OK";
                     Boolean sendret = DataUtil.sendCreate(nettyClient,sn,qret,user20);
+                    AppLog.work("工件建档提交", sn, Boolean.TRUE.equals(sendret));
                     if(!sendret){
                         MesClient.setMenuStatus("消息发送失败,请重试",1);
                         return;
@@ -529,6 +533,7 @@ public class MesClient extends JFrame {
         getUser();
         String snPad = getBarcode(sn);
         Boolean sendret = DataUtil.sendCreate(nettyClient, snPad, qret, user20);
+        AppLog.work("镭雕结果提交(" + qret + ")", snPad, Boolean.TRUE.equals(sendret));
         if(!sendret){
             setMenuStatus("结果提交MES失败,请重试",1);
             return false;
@@ -601,6 +606,7 @@ public class MesClient extends JFrame {
             @Override
             public void windowClosing(WindowEvent e) {
                 if (confirmClose(MesClient.this)) {
+                    AppLog.markUserClose();
                     System.exit(0);
                 }
             }
@@ -1284,12 +1290,15 @@ public class MesClient extends JFrame {
         String scanBarcodeTitle = "请扫物料:"+bindMaterialResp.getMaterialTitle();
         String scanBarcode = JOptionPane.showInputDialog(null, scanBarcodeTitle);
         if(scanBarcode!=null&&!scanBarcode.equalsIgnoreCase("")) {
+            AppLog.scan("物料批次码", scanBarcode);
 
             JSONObject retObj = DataUtil.saveBindMaterail(scanBarcode,bindMaterialResp.getCraft(),bindMaterialResp.getMaterialId(),bindMaterialResp.getType());
             if(retObj.get("result")!=null&&retObj.get("result").toString().equalsIgnoreCase("true")) {
+                AppLog.work("物料绑定", scanBarcode, true);
                 MesClient.setMenuStatus("扫物料:"+bindMaterialResp.getMaterialTitle()+"成功",0);
                 updateMaterailData();
             }else{
+                AppLog.work("物料绑定", scanBarcode, false);
                 if(retObj.get("result")==null){
                     MesClient.setMenuStatus("请求失败,请重试",-1);
                 }else{

+ 101 - 0
src/com/mes/ui/RoundedComponentStyle.java

@@ -0,0 +1,101 @@
+package com.mes.ui;
+
+import javax.swing.AbstractButton;
+import javax.swing.BorderFactory;
+import javax.swing.JComponent;
+import javax.swing.JTextField;
+import javax.swing.border.AbstractBorder;
+import javax.swing.plaf.basic.BasicButtonUI;
+import javax.swing.plaf.basic.BasicTextFieldUI;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.Insets;
+import java.awt.RenderingHints;
+
+/** Applies a restrained, consistent rounded treatment to client controls. */
+public final class RoundedComponentStyle {
+    private static final int ARC = 8;
+    private static final Color BORDER_COLOR = new Color(170, 170, 170);
+
+    private RoundedComponentStyle() {
+    }
+
+    public static void apply(Component component) {
+        if (component instanceof AbstractButton) {
+            styleButton((AbstractButton) component);
+        } else if (component instanceof JTextField) {
+            styleTextField((JTextField) component);
+        }
+
+        if (component instanceof Container) {
+            for (Component child : ((Container) component).getComponents()) {
+                apply(child);
+            }
+        }
+    }
+
+    public static void styleButton(AbstractButton button) {
+        button.setUI(new RoundedButtonUI());
+        button.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
+        button.setOpaque(false);
+    }
+
+    public static void styleTextField(JTextField textField) {
+        textField.setUI(new RoundedTextFieldUI());
+        textField.setBorder(BorderFactory.createCompoundBorder(
+                new RoundedBorder(BORDER_COLOR, ARC),
+                BorderFactory.createEmptyBorder(2, 7, 2, 7)));
+        textField.setOpaque(false);
+    }
+
+    private static class RoundedButtonUI extends BasicButtonUI {
+        @Override
+        public void update(Graphics graphics, JComponent component) {
+            Graphics2D g2 = (Graphics2D) graphics.create();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setColor(component.isEnabled() ? component.getBackground() : new Color(230, 230, 230));
+            g2.fillRoundRect(0, 0, component.getWidth() - 1, component.getHeight() - 1, ARC, ARC);
+            g2.dispose();
+            paint(graphics, component);
+        }
+    }
+
+    private static class RoundedTextFieldUI extends BasicTextFieldUI {
+        @Override
+        public void update(Graphics graphics, JComponent component) {
+            Graphics2D g2 = (Graphics2D) graphics.create();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setColor(component.getBackground());
+            g2.fillRoundRect(0, 0, component.getWidth() - 1, component.getHeight() - 1, ARC, ARC);
+            g2.dispose();
+            paint(graphics, component);
+        }
+    }
+
+    private static class RoundedBorder extends AbstractBorder {
+        private final Color color;
+        private final int arc;
+
+        RoundedBorder(Color color, int arc) {
+            this.color = color;
+            this.arc = arc;
+        }
+
+        @Override
+        public void paintBorder(Component component, Graphics graphics, int x, int y, int width, int height) {
+            Graphics2D g2 = (Graphics2D) graphics.create();
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g2.setColor(color);
+            g2.drawRoundRect(x, y, width - 1, height - 1, arc, arc);
+            g2.dispose();
+        }
+
+        @Override
+        public Insets getBorderInsets(Component component) {
+            return new Insets(1, 1, 1, 1);
+        }
+    }
+}

+ 77 - 0
src/com/mes/util/AppLog.java

@@ -0,0 +1,77 @@
+package com.mes.util;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+public final class AppLog {
+    private static final Object LOCK = new Object();
+    private static PrintStream out;
+    private static volatile String exitReason;
+    private static volatile String uncaughtThread;
+    private static volatile Throwable uncaughtException;
+    private static final SimpleDateFormat TIME = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+
+    private AppLog() { }
+
+    public static void init() {
+        synchronized (LOCK) {
+            if (out != null) return;
+            try {
+                File code = new File(AppLog.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+                File base = code.isFile() ? code.getParentFile() : new File(System.getProperty("user.dir"));
+                File dir = new File(base, "logs");
+                if (!dir.exists()) dir.mkdirs();
+                String day = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+                out = new PrintStream(new FileOutputStream(new File(dir, "mes-client-" + day + ".log"), true), true, "UTF-8");
+                Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+                    public void uncaughtException(Thread t, Throwable e) {
+                        exitReason = "UNCAUGHT_EXCEPTION";
+                        uncaughtThread = t.getName();
+                        uncaughtException = e;
+                    }
+                });
+                Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
+                    public void run() {
+                        if ("USER_CLOSE".equals(exitReason)) {
+                            log("INFO", "客户端退出,原因=用户手动关闭", null);
+                        } else if ("UNCAUGHT_EXCEPTION".equals(exitReason)) {
+                            log("ERROR", "客户端退出,原因=未处理异常,线程=" + clean(uncaughtThread), uncaughtException);
+                        }
+                    }
+                }, "mes-log-shutdown"));
+            } catch (Exception e) { e.printStackTrace(); }
+        }
+    }
+
+    public static void login(String userId, String method, boolean success) {
+        log(success ? "INFO" : "WARN", "登录" + (success ? "成功" : "失败")
+                + ",方式=" + clean(method) + ",用户=" + clean(userId), null);
+    }
+
+    public static void scan(String type, String code) {
+        log("INFO", "扫码,类型=" + clean(type) + ",编码=" + clean(code), null);
+    }
+
+    public static void work(String action, String code, boolean success) {
+        log(success ? "INFO" : "WARN", "业务" + (success ? "成功" : "失败")
+                + ",操作=" + clean(action) + ",编码=" + clean(code), null);
+    }
+
+    public static void markUserClose() { exitReason = "USER_CLOSE"; }
+
+    private static String clean(String value) {
+        return value == null ? "" : value.replace('\r', ' ').replace('\n', ' ');
+    }
+
+    private static void log(String level, String message, Throwable e) {
+        synchronized (LOCK) {
+            if (out == null) return;
+            out.println("[" + TIME.format(new Date()) + "][" + level + "][" + Thread.currentThread().getName() + "] " + message);
+            if (e != null) e.printStackTrace(out);
+            out.flush();
+        }
+    }
+}