wangxichen 2 dní pred
rodič
commit
66d126e6cc

+ 28 - 0
.project

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+	<name>MesOP040</name>
+	<comment></comment>
+	<projects>
+	</projects>
+	<buildSpec>
+		<buildCommand>
+			<name>org.eclipse.jdt.core.javabuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+	</buildSpec>
+	<natures>
+		<nature>org.eclipse.jdt.core.javanature</nature>
+	</natures>
+	<filteredResources>
+		<filter>
+			<id>1755847095655</id>
+			<name></name>
+			<type>30</type>
+			<matcher>
+				<id>org.eclipse.core.resources.regexFilterMatcher</id>
+				<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
+			</matcher>
+		</filter>
+	</filteredResources>
+</projectDescription>

BIN
TSCLIB.dll


+ 72 - 0
config/label-config.json

@@ -0,0 +1,72 @@
+{
+  "labelSize": {
+    "width": 76.2,
+    "height": 38.1,
+    "gap": 3,
+    "totalHeight": 41.1,
+    "unit": "mm"
+  },
+  "elements": [
+    {
+      "id": "qrcode",
+      "type": "qrcode",
+      "label": "二维码",
+      "x": 70,
+      "y": 130,
+      "width": 100,
+      "height": 100,
+      "eccLevel": "L",
+      "cellWidth": "8",
+      "text": "+KB60IS3031T811001201",
+      "dataSource": "sn"
+    },
+    {
+      "id": "product-line1",
+      "type": "text",
+      "label": "产品名",
+      "x": 313,
+      "y": 73,
+      "fontSize": 26,
+      "fontFamily": "SimSun",
+      "fontStyle": 1,
+      "text": "高压电池下壳体总成",
+      "dataSource": "static"
+    },
+    {
+      "id": "product-line2",
+      "type": "text",
+      "label": "产品型号",
+      "x": 344,
+      "y": 105,
+      "fontSize": 22,
+      "fontFamily": "Arial",
+      "fontStyle": 0,
+      "text": "2510094CDE2200",
+      "dataSource": "productModel"
+    },
+    {
+      "id": "date-serial",
+      "type": "text",
+      "label": "日期+流水号",
+      "x": 298,
+      "y": 204,
+      "fontSize": 24,
+      "fontFamily": "Arial",
+      "fontStyle": 0,
+      "text": "2026/08/11 NO:0000001",
+      "dataSource": "dateSerial"
+    },
+    {
+      "id": "batch-number",
+      "type": "text",
+      "label": "批次号",
+      "x": 275,
+      "y": 268,
+      "fontSize": 28,
+      "fontFamily": "Arial",
+      "fontStyle": 1,
+      "text": "+KB60IS3031T811001201",
+      "dataSource": "sn"
+    }
+  ]
+}

+ 5 - 0
config/print.properties

@@ -0,0 +1,5 @@
+# ===== 扫码打标签(TSC 标签机)=====
+# 标签机 Windows 打印机名,需与「设备和打印机」里的名字完全一致(也可在程序里点 [标签机设置] 选择)
+label.printer.name=TSC TTP-244 PRO
+# 客户码校验正则(反斜杠写两个),默认只认 T09(+KB60)/T68(+KB87) 21 位客户码
+#label.sn.pattern=^\\+KB(60|87)[A-Z0-9]{8}[0-9]{8}$

BIN
jacob-1.21-x64.dll


+ 687 - 0
label-editor.html

@@ -0,0 +1,687 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>TSC 标签可视化编辑器</title>
+    <!-- QR Code 生成库 -->
+    <script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+            background: #f5f5f5;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1400px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 8px;
+            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+            overflow: hidden;
+        }
+
+        .header {
+            background: #2c3e50;
+            color: white;
+            padding: 20px 30px;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+
+        .header h1 {
+            font-size: 24px;
+            font-weight: 500;
+        }
+
+        .header-actions {
+            display: flex;
+            gap: 10px;
+        }
+
+        .btn {
+            padding: 10px 20px;
+            border: none;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 14px;
+            transition: all 0.3s;
+        }
+
+        .btn-primary {
+            background: #3498db;
+            color: white;
+        }
+
+        .btn-primary:hover {
+            background: #2980b9;
+        }
+
+        .btn-success {
+            background: #27ae60;
+            color: white;
+        }
+
+        .btn-success:hover {
+            background: #229954;
+        }
+
+        .main-content {
+            display: flex;
+            height: calc(100vh - 140px);
+        }
+
+        .sidebar {
+            width: 300px;
+            border-right: 1px solid #ddd;
+            overflow-y: auto;
+            padding: 20px;
+        }
+
+        .sidebar h3 {
+            margin-bottom: 15px;
+            color: #2c3e50;
+            font-size: 16px;
+        }
+
+        .element-list {
+            list-style: none;
+        }
+
+        .element-item {
+            padding: 12px;
+            margin-bottom: 8px;
+            background: #f8f9fa;
+            border-radius: 4px;
+            cursor: pointer;
+            transition: all 0.2s;
+            border-left: 3px solid #3498db;
+        }
+
+        .element-item:hover {
+            background: #e9ecef;
+        }
+
+        .element-item.selected {
+            background: #d4e6f1;
+            border-left-color: #2980b9;
+        }
+
+        .element-item .label {
+            font-weight: 500;
+            color: #2c3e50;
+            display: block;
+            margin-bottom: 4px;
+        }
+
+        .element-item .type {
+            font-size: 12px;
+            color: #7f8c8d;
+        }
+
+        .canvas-area {
+            flex: 1;
+            padding: 30px;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            background: #ecf0f1;
+        }
+
+        .canvas-wrapper {
+            background: white;
+            box-shadow: 0 4px 15px rgba(0,0,0,0.15);
+            border-radius: 4px;
+            padding: 20px;
+            position: relative;
+        }
+
+        .canvas-container {
+            position: relative;
+            border: 2px solid #bdc3c7;
+            background: white;
+            cursor: crosshair;
+        }
+
+        .canvas-element {
+            position: absolute;
+            cursor: move;
+            border: 2px solid transparent;
+            transition: border-color 0.2s;
+        }
+
+        .canvas-element:hover {
+            border-color: #3498db;
+        }
+
+        .canvas-element.selected {
+            border-color: #e74c3c;
+            box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.3);
+        }
+
+        .canvas-element.text-element {
+            white-space: nowrap;
+            font-family: Arial, sans-serif;
+            padding: 2px 4px;
+        }
+
+        .canvas-element.qrcode-element {
+            background: #ecf0f1;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            font-size: 10px;
+            color: #7f8c8d;
+        }
+
+        .properties-panel {
+            width: 320px;
+            border-left: 1px solid #ddd;
+            overflow-y: auto;
+            padding: 20px;
+        }
+
+        .properties-panel h3 {
+            margin-bottom: 15px;
+            color: #2c3e50;
+            font-size: 16px;
+        }
+
+        .property-group {
+            margin-bottom: 20px;
+        }
+
+        .property-group label {
+            display: block;
+            margin-bottom: 5px;
+            color: #34495e;
+            font-size: 13px;
+            font-weight: 500;
+        }
+
+        .property-group input,
+        .property-group select {
+            width: 100%;
+            padding: 8px;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+            font-size: 13px;
+        }
+
+        .property-group input:focus,
+        .property-group select:focus {
+            outline: none;
+            border-color: #3498db;
+        }
+
+        .info-bar {
+            padding: 10px 20px;
+            background: #34495e;
+            color: white;
+            font-size: 13px;
+            display: flex;
+            justify-content: space-between;
+        }
+
+        .zoom-controls {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            margin-bottom: 15px;
+        }
+
+        .zoom-btn {
+            padding: 5px 12px;
+            background: #7f8c8d;
+            color: white;
+            border: none;
+            border-radius: 3px;
+            cursor: pointer;
+            font-size: 12px;
+        }
+
+        .zoom-btn:hover {
+            background: #6c7a7d;
+        }
+
+        .zoom-level {
+            font-size: 13px;
+            color: #2c3e50;
+            min-width: 60px;
+            text-align: center;
+        }
+
+        .property-row {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 10px;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>TSC 标签可视化编辑器</h1>
+            <div class="header-actions">
+                <button class="btn btn-primary" onclick="loadConfig()">加载配置</button>
+                <button class="btn btn-success" onclick="saveConfig()">保存配置</button>
+            </div>
+        </div>
+
+        <div class="main-content">
+            <!-- 左侧元素列表 -->
+            <div class="sidebar">
+                <h3>📋 标签元素</h3>
+                <ul class="element-list" id="elementList"></ul>
+            </div>
+
+            <!-- 中间画布区域 -->
+            <div class="canvas-area">
+                <div class="zoom-controls">
+                    <button class="zoom-btn" onclick="zoomOut()">缩小 -</button>
+                    <span class="zoom-level" id="zoomLevel">100%</span>
+                    <button class="zoom-btn" onclick="zoomIn()">放大 +</button>
+                    <button class="zoom-btn" onclick="resetZoom()">重置</button>
+                </div>
+                <div class="canvas-wrapper">
+                    <div class="canvas-container" id="canvas"></div>
+                </div>
+            </div>
+
+            <!-- 右侧属性面板 -->
+            <div class="properties-panel">
+                <h3>⚙️ 属性设置</h3>
+                <div id="propertiesContent">
+                    <p style="color: #7f8c8d; font-size: 13px;">请选择一个元素</p>
+                </div>
+            </div>
+        </div>
+
+        <div class="info-bar">
+            <span>标签尺寸: <span id="labelSize">76.2mm × 38.1mm</span></span>
+            <span>选中元素: <span id="selectedElement">无</span></span>
+        </div>
+    </div>
+
+    <script>
+        // 全局变量
+        let config = null;
+        let selectedElementId = null;
+        let zoom = 1.0;
+        const DPI = 203 / 25.4; // 203 DPI,1mm = 8 dots
+        let isDragging = false;
+        let dragStartX = 0;
+        let dragStartY = 0;
+        let dragElement = null;
+
+        // 加载配置
+        async function loadConfig() {
+            try {
+                const response = await fetch('../config/label-config.json');
+                config = await response.json();
+                renderCanvas();
+                renderElementList();
+                showMessage('配置加载成功', 'success');
+            } catch (error) {
+                showMessage('配置加载失败: ' + error.message, 'error');
+            }
+        }
+
+        // 保存配置
+        function saveConfig() {
+            if (!config) {
+                showMessage('没有配置可保存', 'error');
+                return;
+            }
+
+            const json = JSON.stringify(config, null, 2);
+            const blob = new Blob([json], { type: 'application/json' });
+            const url = URL.createObjectURL(blob);
+            const a = document.createElement('a');
+            a.href = url;
+            a.download = 'label-config.json';
+            a.click();
+            URL.revokeObjectURL(url);
+            showMessage('配置已下载', 'success');
+        }
+
+        // 渲染画布
+        function renderCanvas() {
+            if (!config) return;
+
+            const canvas = document.getElementById('canvas');
+            const widthPx = config.labelSize.width * DPI * zoom;
+            const heightPx = config.labelSize.height * DPI * zoom;
+
+            canvas.style.width = widthPx + 'px';
+            canvas.style.height = heightPx + 'px';
+            canvas.innerHTML = '';
+
+            config.elements.forEach(element => {
+                const div = document.createElement('div');
+                div.className = 'canvas-element';
+                div.id = 'canvas-' + element.id;
+                div.dataset.id = element.id;
+
+                const x = element.x * zoom;
+                const y = element.y * zoom;
+
+                div.style.left = x + 'px';
+                div.style.top = y + 'px';
+
+                if (element.type === 'text') {
+                    div.className += ' text-element';
+                    div.textContent = element.text;
+                    div.style.fontSize = (element.fontSize * zoom) + 'px';
+                    div.style.fontFamily = element.fontFamily;
+                    div.style.fontWeight = element.fontStyle === 1 ? 'bold' : 'normal';
+                } else if (element.type === 'qrcode') {
+                    div.className += ' qrcode-element';
+                    div.style.width = (element.width * zoom) + 'px';
+                    div.style.height = (element.height * zoom) + 'px';
+                    div.style.background = 'white';
+                    div.style.padding = '0';
+
+                    // 生成真实二维码
+                    const qrData = element.text || '+KB60IS3031T811001201'; // 默认示例数据
+                    try {
+                        new QRCode(div, {
+                            text: qrData,
+                            width: element.width * zoom,
+                            height: element.height * zoom,
+                            colorDark: '#000000',
+                            colorLight: '#ffffff',
+                            correctLevel: QRCode.CorrectLevel.L
+                        });
+                    } catch (e) {
+                        div.textContent = 'QR';
+                        div.style.background = '#ecf0f1';
+                    }
+                }
+
+                if (element.id === selectedElementId) {
+                    div.className += ' selected';
+                }
+
+                div.addEventListener('click', (e) => {
+                    e.stopPropagation();
+                    selectElement(element.id);
+                });
+
+                div.addEventListener('mousedown', startDrag);
+
+                canvas.appendChild(div);
+            });
+
+            document.getElementById('labelSize').textContent =
+                `${config.labelSize.width}mm × ${config.labelSize.height}mm`;
+        }
+
+        // 拖拽开始
+        function startDrag(e) {
+            isDragging = true;
+            dragElement = e.target;
+            dragStartX = e.clientX;
+            dragStartY = e.clientY;
+
+            const elementId = dragElement.dataset.id;
+            selectElement(elementId);
+
+            document.addEventListener('mousemove', onDrag);
+            document.addEventListener('mouseup', stopDrag);
+
+            e.preventDefault();
+        }
+
+        // 拖拽中
+        function onDrag(e) {
+            if (!isDragging || !dragElement) return;
+
+            const dx = (e.clientX - dragStartX) / zoom;
+            const dy = (e.clientY - dragStartY) / zoom;
+
+            const elementId = dragElement.dataset.id;
+            const element = config.elements.find(el => el.id === elementId);
+
+            if (element) {
+                element.x = Math.max(0, element.x + dx);
+                element.y = Math.max(0, element.y + dy);
+                renderCanvas();
+                updateProperties();
+            }
+
+            dragStartX = e.clientX;
+            dragStartY = e.clientY;
+        }
+
+        // 拖拽结束
+        function stopDrag() {
+            isDragging = false;
+            dragElement = null;
+            document.removeEventListener('mousemove', onDrag);
+            document.removeEventListener('mouseup', stopDrag);
+        }
+
+        // 渲染元素列表
+        function renderElementList() {
+            const list = document.getElementById('elementList');
+            list.innerHTML = '';
+
+            config.elements.forEach(element => {
+                const li = document.createElement('li');
+                li.className = 'element-item';
+                li.dataset.id = element.id;
+                li.innerHTML = `
+                    <span class="label">${element.label}</span>
+                    <span class="type">${element.type === 'text' ? '文本' : '二维码'} - (${Math.round(element.x)}, ${Math.round(element.y)})</span>
+                `;
+                li.addEventListener('click', () => selectElement(element.id));
+                list.appendChild(li);
+            });
+        }
+
+        // 选择元素
+        function selectElement(elementId) {
+            selectedElementId = elementId;
+
+            // 更新列表选中状态
+            document.querySelectorAll('.element-item').forEach(item => {
+                item.classList.toggle('selected', item.dataset.id === elementId);
+            });
+
+            // 更新画布选中状态
+            document.querySelectorAll('.canvas-element').forEach(el => {
+                el.classList.toggle('selected', el.dataset.id === elementId);
+            });
+
+            const element = config.elements.find(el => el.id === elementId);
+            document.getElementById('selectedElement').textContent = element ? element.label : '无';
+
+            updateProperties();
+        }
+
+        // 更新属性面板
+        function updateProperties() {
+            if (!selectedElementId) {
+                document.getElementById('propertiesContent').innerHTML =
+                    '<p style="color: #7f8c8d; font-size: 13px;">请选择一个元素</p>';
+                return;
+            }
+
+            const element = config.elements.find(el => el.id === selectedElementId);
+            if (!element) return;
+
+            let html = '';
+
+            // 位置属性
+            html += `
+                <div class="property-group">
+                    <label>ID</label>
+                    <input type="text" value="${element.id}" disabled>
+                </div>
+                <div class="property-group">
+                    <div class="property-row">
+                        <div>
+                            <label>X 坐标 (dots)</label>
+                            <input type="number" value="${element.x}" onchange="updateElementProperty('x', parseFloat(this.value))">
+                        </div>
+                        <div>
+                            <label>Y 坐标 (dots)</label>
+                            <input type="number" value="${element.y}" onchange="updateElementProperty('y', parseFloat(this.value))">
+                        </div>
+                    </div>
+                </div>
+            `;
+
+            if (element.type === 'text') {
+                html += `
+                    <div class="property-group">
+                        <label>文本内容</label>
+                        <input type="text" value="${element.text}" onchange="updateElementProperty('text', this.value)">
+                    </div>
+                    <div class="property-group">
+                        <label>字体大小 (pt)</label>
+                        <input type="number" value="${element.fontSize}" onchange="updateElementProperty('fontSize', parseInt(this.value))">
+                    </div>
+                    <div class="property-group">
+                        <label>字体</label>
+                        <select value="${element.fontFamily}" onchange="updateElementProperty('fontFamily', this.value)">
+                            <option value="Arial" ${element.fontFamily === 'Arial' ? 'selected' : ''}>Arial</option>
+                            <option value="SimSun" ${element.fontFamily === 'SimSun' ? 'selected' : ''}>SimSun (宋体)</option>
+                            <option value="SimHei" ${element.fontFamily === 'SimHei' ? 'selected' : ''}>SimHei (黑体)</option>
+                        </select>
+                    </div>
+                    <div class="property-group">
+                        <label>字体样式</label>
+                        <select value="${element.fontStyle}" onchange="updateElementProperty('fontStyle', parseInt(this.value))">
+                            <option value="0" ${element.fontStyle === 0 ? 'selected' : ''}>正常</option>
+                            <option value="1" ${element.fontStyle === 1 ? 'selected' : ''}>加粗</option>
+                        </select>
+                    </div>
+                `;
+            } else if (element.type === 'qrcode') {
+                html += `
+                    <div class="property-group">
+                        <div class="property-row">
+                            <div>
+                                <label>宽度 (dots)</label>
+                                <input type="number" value="${element.width}" onchange="updateElementProperty('width', parseInt(this.value))">
+                            </div>
+                            <div>
+                                <label>高度 (dots)</label>
+                                <input type="number" value="${element.height}" onchange="updateElementProperty('height', parseInt(this.value))">
+                            </div>
+                        </div>
+                    </div>
+                    <div class="property-group">
+                        <label>纠错等级</label>
+                        <select value="${element.eccLevel}" onchange="updateElementProperty('eccLevel', this.value)">
+                            <option value="L" ${element.eccLevel === 'L' ? 'selected' : ''}>L (7%)</option>
+                            <option value="M" ${element.eccLevel === 'M' ? 'selected' : ''}>M (15%)</option>
+                            <option value="Q" ${element.eccLevel === 'Q' ? 'selected' : ''}>Q (25%)</option>
+                            <option value="H" ${element.eccLevel === 'H' ? 'selected' : ''}>H (30%)</option>
+                        </select>
+                    </div>
+                    <div class="property-group">
+                        <label>单元宽度</label>
+                        <input type="number" value="${element.cellWidth}" onchange="updateElementProperty('cellWidth', this.value)">
+                    </div>
+                `;
+            }
+
+            document.getElementById('propertiesContent').innerHTML = html;
+        }
+
+        // 更新元素属性
+        function updateElementProperty(property, value) {
+            if (!selectedElementId) return;
+
+            const element = config.elements.find(el => el.id === selectedElementId);
+            if (element) {
+                element[property] = value;
+                renderCanvas();
+                renderElementList();
+            }
+        }
+
+        // 缩放控制
+        function zoomIn() {
+            zoom = Math.min(zoom + 0.1, 2.0);
+            document.getElementById('zoomLevel').textContent = Math.round(zoom * 100) + '%';
+            renderCanvas();
+        }
+
+        function zoomOut() {
+            zoom = Math.max(zoom - 0.1, 0.5);
+            document.getElementById('zoomLevel').textContent = Math.round(zoom * 100) + '%';
+            renderCanvas();
+        }
+
+        function resetZoom() {
+            zoom = 1.0;
+            document.getElementById('zoomLevel').textContent = '100%';
+            renderCanvas();
+        }
+
+        // 显示消息
+        function showMessage(message, type) {
+            const color = type === 'success' ? '#27ae60' : '#e74c3c';
+            const div = document.createElement('div');
+            div.style.cssText = `
+                position: fixed;
+                top: 20px;
+                right: 20px;
+                background: ${color};
+                color: white;
+                padding: 15px 25px;
+                border-radius: 4px;
+                box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+                z-index: 1000;
+                animation: slideIn 0.3s ease-out;
+            `;
+            div.textContent = message;
+            document.body.appendChild(div);
+
+            setTimeout(() => {
+                div.style.animation = 'slideOut 0.3s ease-out';
+                setTimeout(() => div.remove(), 300);
+            }, 2000);
+        }
+
+        // 初始化
+        window.addEventListener('load', loadConfig);
+    </script>
+
+    <style>
+        @keyframes slideIn {
+            from {
+                transform: translateX(400px);
+                opacity: 0;
+            }
+            to {
+                transform: translateX(0);
+                opacity: 1;
+            }
+        }
+
+        @keyframes slideOut {
+            from {
+                transform: translateX(0);
+                opacity: 1;
+            }
+            to {
+                transform: translateX(400px);
+                opacity: 0;
+            }
+        }
+    </style>
+</body>
+</html>

BIN
lib/commons-logging-1.2.jar


BIN
lib/core-3.5.1.jar


BIN
lib/javase-3.5.1.jar


BIN
lib/jna-5.13.0.jar


+ 3 - 0
src/META-INF/MANIFEST.MF

@@ -0,0 +1,3 @@
+Manifest-Version: 1.0
+Main-Class: com.mes.ui.MesClient
+

+ 264 - 0
src/com/mes/label/LabelScanManager.java

@@ -0,0 +1,264 @@
+package com.mes.label;
+
+import com.mes.print.config.LabelConfigLoader;
+import com.mes.print.config.PrinterConfig;
+import com.mes.print.tsc.TSCLIB;
+import com.mes.print.tsc.TscPrinter;
+import com.mes.util.AppLog;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.regex.Pattern;
+
+/**
+ * 扫码打标签(TSC 热敏标签机)
+ *
+ * 不调任何服务端接口,不提交工位结果。
+ *
+ * 流程:扫码 → 规范化 → 校验格式/型号/流水号 → 查本地历史(打过的由 UI 确认补打)
+ *      → 单线程打印 → 成功才写历史
+ *
+ * 标签内容全部由客户码决定(见 TscPrinter),补打沿用首次打印日期,保证同一个码的标签一致。
+ */
+public class LabelScanManager {
+
+    public interface Callback {
+        /** 打印结束(在打印线程回调,UI 自行切 EDT);ok=true 时 msg 可能带警告 */
+        void onLabelDone(String sn, String serialNo, boolean reprint, boolean ok, String msg);
+    }
+
+    /** 已打印记录 */
+    public static class PrintRecord {
+        public final String sn;
+        public final Date firstTime;
+        public volatile int count;
+
+        PrintRecord(String sn, Date firstTime) {
+            this.sn = sn;
+            this.firstTime = firstTime;
+            this.count = 1;
+        }
+
+        public String serialNo() { return TscPrinter.serialNoOf(sn); }
+    }
+
+    private static final String HISTORY_FILE = "data/label-print-history.txt";
+    /** 默认规则:+KB60/+KB87 + 8 位字母数字 + 8 位数字(日 2 + 流水 4 + 01),共 21 位 */
+    private static final String DEFAULT_SN_PATTERN = "^\\+KB(60|87)[A-Z0-9]{8}[0-9]{8}$";
+    private static final String TIME_FMT = "yyyy-MM-dd HH:mm:ss";
+
+    private final Callback callback;
+    private final Pattern snPattern;
+
+    /** sn → 首次打印记录 */
+    private final Map<String, PrintRecord> history = new ConcurrentHashMap<>();
+    /** 排队/打印中的码,防连扫重复 */
+    private final Set<String> inFlight = ConcurrentHashMap.newKeySet();
+    // TSCLIB 的 openport/closeport 是全局状态,必须单线程
+    private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
+        Thread t = new Thread(r, "LabelPrinter");
+        t.setDaemon(true);
+        return t;
+    });
+
+    public LabelScanManager(Callback callback) {
+        this.callback = callback;
+        this.snPattern = compilePattern(cfg("label.sn.pattern", DEFAULT_SN_PATTERN));
+        loadHistory();
+        System.out.println("[标签] 初始化完成: 历史 " + history.size() + " 条, 标签机=" + getLabelPrinterName());
+    }
+
+    // ==== 对外 API ====
+
+    /** 扫码枪原始输入规范化:去掉空白/回车/Tab 等控制字符,转大写(防 CapsLock 反转大小写) */
+    public static String normalize(String raw) {
+        if (raw == null) return "";
+        return raw.replaceAll("[\\s\\p{Cntrl}]", "").toUpperCase(Locale.ROOT);
+    }
+
+    /** 校验扫到的码,通过返回 null,否则返回提示语 */
+    public String validate(String sn) {
+        if (sn == null || sn.isEmpty()) return "扫码内容为空";
+        if (!snPattern.matcher(sn).matches()) {
+            return "条码格式不符: " + sn + "(请扫产品客户码;若出现乱码请切换英文输入法)";
+        }
+        if (TscPrinter.productModelOf(sn) == null) return "无法识别产品型号: " + sn;
+        if (TscPrinter.serialNoOf(sn) == null) return "无法从客户码解析流水号: " + sn;
+        return null;
+    }
+
+    public boolean isPrinting(String sn) {
+        return sn != null && inFlight.contains(sn);
+    }
+
+    /** 查已打印记录,没打过返回 null */
+    public PrintRecord findPrinted(String sn) {
+        return sn == null ? null : history.get(sn);
+    }
+
+    /**
+     * 提交打印(已打过的自动按补打处理:沿用首次打印日期)
+     * @return false=该码已在排队/打印中
+     */
+    public boolean submit(String sn) {
+        if (!inFlight.add(sn)) return false;
+        executor.submit(() -> {
+            try {
+                doPrint(sn);
+            } finally {
+                inFlight.remove(sn);
+            }
+        });
+        return true;
+    }
+
+    /**
+     * 启动自检:Java 位数、标签布局、TSCLIB.dll,通过返回 null,否则返回提示语
+     * 会加载 DLL,不要在 EDT 调
+     */
+    public String selfCheck() {
+        String arch = System.getProperty("os.arch", "");
+        if (!arch.contains("64")) {
+            return "当前是 32 位 Java(" + arch + "),TSCLIB.dll 需要 64 位 Java";
+        }
+        try {
+            LabelConfigLoader.load();
+        } catch (Throwable e) {
+            return "标签布局加载失败: " + describe(e);
+        }
+        try {
+            // 访问 INSTANCE 触发 DLL 加载:程序目录/系统路径优先,找不到就用 jar 内置的
+            TSCLIB.INSTANCE.getClass();
+        } catch (Throwable e) {
+            return describe(e);
+        }
+        return null;
+    }
+
+    public String getLabelPrinterName() {
+        try {
+            return PrinterConfig.getLabelPrinterName();
+        } catch (Throwable e) {
+            return PrinterConfig.DEFAULT_LABEL_PRINTER;
+        }
+    }
+
+    // ==== 内部实现 ====
+
+    private void doPrint(String sn) {
+        PrintRecord old = history.get(sn);
+        boolean reprint = old != null;
+        Date date = reprint ? old.firstTime : new Date();
+        String serialNo = TscPrinter.serialNoOf(sn);
+        String action = reprint ? "补打标签" : "打标签";
+
+        try {
+            new TscPrinter().printLabel(sn, date);
+        } catch (Throwable e) {
+            // Throwable:DLL 缺失/位数不对抛的是 Error,不能漏
+            String msg = describe(e);
+            System.err.println("[标签] " + action + "失败: " + sn + " - " + msg);
+            e.printStackTrace();
+            AppLog.work(action, sn, false);
+            callback.onLabelDone(sn, serialNo, reprint, false, msg);
+            return;
+        }
+
+        // 先更新内存再写文件:写文件失败也能保证本次运行内去重
+        if (reprint) {
+            old.count++;
+        } else {
+            history.put(sn, new PrintRecord(sn, date));
+        }
+        String warn = null;
+        try {
+            appendHistory(new SimpleDateFormat(TIME_FMT).format(new Date()) + "|" + sn + "|" + (reprint ? "R" : "N"));
+        } catch (Exception e) {
+            e.printStackTrace();
+            warn = "标签已打印,但本地记录写入失败: " + e.getMessage();
+        }
+        AppLog.work(action, sn, true);
+        callback.onLabelDone(sn, serialNo, reprint, true, warn);
+    }
+
+    /** 历史行格式:打印时间|SN|N(首次)或R(补打) */
+    private void loadHistory() {
+        File f = new File(HISTORY_FILE);
+        if (!f.exists()) return;
+        try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8))) {
+            String line;
+            while ((line = br.readLine()) != null) {
+                String[] p = line.trim().split("\\|");
+                if (p.length < 3) continue;
+                try {
+                    Date time = new SimpleDateFormat(TIME_FMT).parse(p[0]);
+                    String sn = p[1];
+                    PrintRecord rec = history.get(sn);
+                    if (rec != null) {
+                        rec.count++;
+                    } else {
+                        history.put(sn, new PrintRecord(sn, time));
+                    }
+                } catch (Exception ignored) {
+                    // 坏行跳过
+                }
+            }
+        } catch (Exception e) {
+            System.err.println("[标签] 读取打印历史失败: " + e.getMessage());
+        }
+    }
+
+    private static synchronized void appendHistory(String line) throws IOException {
+        File f = new File(HISTORY_FILE).getAbsoluteFile();
+        File dir = f.getParentFile();
+        if (dir != null && !dir.exists()) dir.mkdirs();
+        try (Writer w = new OutputStreamWriter(new FileOutputStream(f, true), StandardCharsets.UTF_8)) {
+            w.write(line);
+            w.write("\r\n");
+        }
+    }
+
+    private static String describe(Throwable e) {
+        if (e instanceof UnsatisfiedLinkError) {
+            return "加载 TSCLIB.dll 失败(需 64 位 Java): " + e.getMessage();
+        }
+        if (e instanceof NoClassDefFoundError || e instanceof ExceptionInInitializerError) {
+            return "标签打印组件加载失败(检查 jna jar / TSCLIB.dll): " + e;
+        }
+        String m = e.getMessage();
+        return m == null || m.isEmpty() ? e.toString() : m;
+    }
+
+    private static String cfg(String key, String def) {
+        try {
+            String v = PrinterConfig.get(key, def);
+            return v == null || v.trim().isEmpty() ? def : v.trim();
+        } catch (Throwable e) {
+            return def;
+        }
+    }
+
+    private static Pattern compilePattern(String regex) {
+        try {
+            return Pattern.compile(regex);
+        } catch (Exception e) {
+            System.err.println("[标签] label.sn.pattern 非法,使用默认规则: " + e.getMessage());
+            return Pattern.compile(DEFAULT_SN_PATTERN);
+        }
+    }
+}

+ 17 - 0
src/com/mes/print/PrinterException.java

@@ -0,0 +1,17 @@
+package com.mes.print;
+
+/**
+ * 打印统一异常。业务代码可选择性 try-catch,也可让它冒泡。
+ */
+public class PrinterException extends RuntimeException {
+
+	private static final long serialVersionUID = 1L;
+
+	public PrinterException(String msg) {
+		super(msg);
+	}
+
+	public PrinterException(String msg, Throwable cause) {
+		super(msg, cause);
+	}
+}

+ 154 - 0
src/com/mes/print/config/LabelConfigLoader.java

@@ -0,0 +1,154 @@
+package com.mes.print.config;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 标签配置加载器
+ * 从 JSON 加载标签布局配置
+ */
+public class LabelConfigLoader {
+
+    /** 外部布局文件路径(相对工作目录),同时也是 jar 内打包的资源路径 */
+    public static final String DEFAULT_PATH = "config/label-config.json";
+
+    private final JSONObject config;
+
+    /** 从指定文件加载 */
+    public LabelConfigLoader(String configPath) throws IOException {
+        this(parse(Files.readAllBytes(Paths.get(configPath)), configPath));
+    }
+
+    private LabelConfigLoader(JSONObject config) {
+        this.config = config;
+    }
+
+    /**
+     * 默认加载:程序目录下的 config/label-config.json 优先(现场可直接改坐标,不用重新打包),
+     * 没有就用 jar 里打包的那份
+     */
+    public static LabelConfigLoader load() throws IOException {
+        if (new File(DEFAULT_PATH).isFile()) return new LabelConfigLoader(DEFAULT_PATH);
+        try (InputStream in = LabelConfigLoader.class.getClassLoader().getResourceAsStream(DEFAULT_PATH)) {
+            if (in == null) throw new IOException("找不到 " + DEFAULT_PATH + "(程序目录和 jar 内都没有)");
+            return new LabelConfigLoader(parse(readAll(in), "jar 内 " + DEFAULT_PATH));
+        }
+    }
+
+    private static JSONObject parse(byte[] bytes, String source) throws IOException {
+        JSONObject obj;
+        try {
+            obj = JSON.parseObject(new String(bytes, StandardCharsets.UTF_8));
+        } catch (Exception e) {
+            throw new IOException(source + " 格式错误: " + e.getMessage(), e);
+        }
+        if (obj == null || obj.getJSONObject("labelSize") == null || obj.getJSONArray("elements") == null) {
+            throw new IOException(source + " 缺少 labelSize 或 elements");
+        }
+        return obj;
+    }
+
+    // Java 8 没有 InputStream.readAllBytes
+    private static byte[] readAll(InputStream in) throws IOException {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        byte[] buf = new byte[4096];
+        int n;
+        while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
+        return out.toByteArray();
+    }
+
+    /**
+     * 获取标签尺寸配置
+     */
+    public LabelSize getLabelSize() {
+        JSONObject sizeObj = config.getJSONObject("labelSize");
+        LabelSize size = new LabelSize();
+        size.width = sizeObj.getDoubleValue("width");
+        size.height = sizeObj.getDoubleValue("height");
+        size.gap = sizeObj.getDoubleValue("gap");
+        size.totalHeight = sizeObj.getDoubleValue("totalHeight");
+        return size;
+    }
+
+    /**
+     * 获取所有元素配置
+     */
+    public List<LabelElement> getElements() {
+        JSONArray elementsArray = config.getJSONArray("elements");
+        List<LabelElement> elements = new ArrayList<>();
+
+        for (int i = 0; i < elementsArray.size(); i++) {
+            JSONObject obj = elementsArray.getJSONObject(i);
+            LabelElement element = new LabelElement();
+
+            element.id = obj.getString("id");
+            element.type = obj.getString("type");
+            element.label = obj.getString("label");
+            element.x = obj.getIntValue("x");
+            element.y = obj.getIntValue("y");
+            element.dataSource = obj.getString("dataSource");
+
+            if ("text".equals(element.type)) {
+                element.fontSize = obj.getIntValue("fontSize");
+                element.fontFamily = obj.getString("fontFamily");
+                element.fontStyle = obj.getIntValue("fontStyle");
+                element.text = obj.getString("text");
+            } else if ("qrcode".equals(element.type)) {
+                element.width = obj.getIntValue("width");
+                element.height = obj.getIntValue("height");
+                element.eccLevel = obj.getString("eccLevel");
+                element.cellWidth = obj.getString("cellWidth");
+                element.text = obj.getString("text");  // 添加 text 字段加载
+            }
+
+            elements.add(element);
+        }
+
+        return elements;
+    }
+
+    /**
+     * 标签尺寸
+     */
+    public static class LabelSize {
+        public double width;
+        public double height;
+        public double gap;
+        public double totalHeight;
+    }
+
+    /**
+     * 标签元素
+     */
+    public static class LabelElement {
+        public String id;
+        public String type;
+        public String label;
+        public int x;
+        public int y;
+        public String dataSource;
+
+        // 文本元素属性
+        public int fontSize;
+        public String fontFamily;
+        public int fontStyle;
+        public String text;
+
+        // 二维码元素属性
+        public int width;
+        public int height;
+        public String eccLevel;
+        public String cellWidth;
+    }
+}

+ 113 - 0
src/com/mes/print/config/PrinterConfig.java

@@ -0,0 +1,113 @@
+package com.mes.print.config;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+import com.mes.print.PrinterException;
+
+/**
+ * 打印配置加载器(UTF-8)。查找顺序:
+ * 1. 系统属性 -Dprint.config=xxx 指定的路径
+ * 2. 当前工作目录 ./config/print.properties
+ * 3. classpath 上的 print.properties(打进 jar 里的默认值)
+ * 都没有就全用默认值(标签机 TSC TTP-244 PRO)
+ */
+public class PrinterConfig {
+
+	private static final String CONFIG_FILE = "config/print.properties";
+
+	/** 默认标签机名 */
+	public static final String DEFAULT_LABEL_PRINTER = "TSC TTP-244 PRO";
+
+	private static Properties props;
+
+	public static synchronized Properties load() {
+		if (props != null) return props;
+		Properties p = new Properties();
+		// 用 UTF-8 读:打印机名可能带中文(如「副本 1」),按 ISO-8859-1 读会乱码
+		try (InputStream in = openStream()) {
+			// 找不到配置文件就全用默认值,单 jar 放在任何目录都能跑
+			if (in != null) p.load(new InputStreamReader(in, StandardCharsets.UTF_8));
+		} catch (Exception e) {
+			throw new PrinterException("加载打印配置失败: " + e.getMessage(), e);
+		}
+		props = p;
+		return props;
+	}
+
+	private static InputStream openStream() throws Exception {
+		String sysPath = System.getProperty("print.config");
+		if (sysPath != null && new File(sysPath).exists()) {
+			return new FileInputStream(sysPath);
+		}
+		File f = new File(CONFIG_FILE);
+		System.out.println("[PrinterConfig] 查找配置文件: " + f.getAbsolutePath() + " (exists=" + f.exists() + ")");
+		if (f.exists()) return new FileInputStream(f);
+		InputStream in = PrinterConfig.class.getClassLoader().getResourceAsStream("print.properties");
+		if (in != null) {
+			System.out.println("[PrinterConfig] 从 classpath 加载配置");
+			return in;
+		}
+		System.out.println("[PrinterConfig] 未找到配置文件,使用默认值");
+		return null;
+	}
+
+	public static String get(String key) {
+		return load().getProperty(key);
+	}
+
+	public static String get(String key, String defaultValue) {
+		String v = load().getProperty(key);
+		return v == null ? defaultValue : v;
+	}
+
+	/** 标签机 Windows 打印机名:label.printer.name,没配用默认 */
+	public static String getLabelPrinterName() {
+		String name = get("label.printer.name", "").trim();
+		return name.isEmpty() ? DEFAULT_LABEL_PRINTER : name;
+	}
+
+	/** 重置缓存,下次 get 重新加载 */
+	public static synchronized void reset() {
+		props = null;
+	}
+
+	/**
+	 * 写单个配置项到 ./config/print.properties 并立即生效。
+	 * 按行替换,保留文件里的注释和其他键;键不存在就追加到末尾。
+	 */
+	public static synchronized void set(String key, String value) {
+		File f = new File(CONFIG_FILE).getAbsoluteFile();
+		// properties 里反斜杠是转义符,网络打印机名 \\server\TSC 要写成 \\\\server\\TSC
+		String entry = key + "=" + (value == null ? "" : value.trim().replace("\\", "\\\\"));
+		Pattern keyLine = Pattern.compile("^\\s*" + Pattern.quote(key) + "\\s*[=:].*$");
+		try {
+			List<String> lines = f.exists()
+					? new ArrayList<>(Files.readAllLines(f.toPath(), StandardCharsets.UTF_8))
+					: new ArrayList<>();
+			boolean replaced = false;
+			for (int i = 0; i < lines.size(); i++) {
+				if (keyLine.matcher(lines.get(i)).matches()) {
+					lines.set(i, entry);
+					replaced = true;
+					break;
+				}
+			}
+			if (!replaced) lines.add(entry);
+			File dir = f.getParentFile();
+			if (dir != null && !dir.exists()) dir.mkdirs();
+			Files.write(f.toPath(), lines, StandardCharsets.UTF_8);
+		} catch (Exception e) {
+			throw new PrinterException("保存打印配置失败: " + e.getMessage(), e);
+		}
+		reset();
+	}
+}

+ 48 - 0
src/com/mes/print/test/TestBarcode.java

@@ -0,0 +1,48 @@
+package com.mes.print.test;
+
+import com.mes.print.tsc.TSCLIB;
+
+/**
+ * 测试条形码打印
+ */
+public class TestBarcode {
+    public static void main(String[] args) {
+        TSCLIB tsc = TSCLIB.INSTANCE;
+
+        try {
+            // 1. 打开打印机
+            System.out.println("打开打印机...");
+            int result = tsc.openport("TSC TTP-244 PRO");
+            if (result != 1) {
+                System.err.println("打开打印机失败");
+                return;
+            }
+
+            // 2. 设置标签参数
+            System.out.println("设置标签参数...");
+            tsc.setup("76.2", "41.1", "4", "10", "0", "3", "0");
+
+            // 3. 清除缓冲区
+            tsc.clearbuffer();
+
+            // 4. 绘制条形码(CODE128)
+            System.out.println("绘制条形码...");
+            System.out.println("  位置: (50, 100)");
+            System.out.println("  内容: KB60IS3031T81");
+
+            tsc.barcode128("50", "100", "80", "1", "0", "2", "KB60IS3031T81");
+
+            // 5. 打印
+            System.out.println("开始打印...");
+            tsc.printlabel("1", "1");
+
+            // 6. 关闭打印机
+            tsc.closeport();
+
+            System.out.println("完成!");
+
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 50 - 0
src/com/mes/print/test/TestQRCode.java

@@ -0,0 +1,50 @@
+package com.mes.print.test;
+
+import com.mes.print.tsc.TSCLIB;
+
+/**
+ * 测试二维码打印
+ */
+public class TestQRCode {
+    public static void main(String[] args) {
+        TSCLIB tsc = TSCLIB.INSTANCE;
+
+        try {
+            // 1. 打开打印机
+            System.out.println("打开打印机...");
+            int result = tsc.openport("TSC TTP-244 PRO");
+            if (result != 1) {
+                System.err.println("打开打印机失败");
+                return;
+            }
+
+            // 2. 设置标签参数
+            System.out.println("设置标签参数...");
+            tsc.setup("76.2", "41.1", "4", "10", "0", "3", "0");
+
+            // 3. 清除缓冲区
+            tsc.clearbuffer();
+
+            // 4. 绘制二维码(简单测试)
+            System.out.println("绘制二维码...");
+            System.out.println("  位置: (50, 100)");
+            System.out.println("  纠错: L");
+            System.out.println("  单元: 5");
+            System.out.println("  内容: TEST123");
+
+            tsc.qrcode("50", "100", "L", "5", "A", "0", "TEST123");
+
+            // 5. 打印
+            System.out.println("开始打印...");
+            tsc.printlabel("1", "1");
+
+            // 6. 关闭打印机
+            tsc.closeport();
+
+            System.out.println("完成!");
+
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 48 - 0
src/com/mes/print/test/TestQRCodeRaw.java

@@ -0,0 +1,48 @@
+package com.mes.print.test;
+
+import com.mes.print.tsc.TSCLIB;
+
+/**
+ * 测试用原始 TSPL 命令打印二维码
+ */
+public class TestQRCodeRaw {
+    public static void main(String[] args) {
+        TSCLIB tsc = TSCLIB.INSTANCE;
+
+        try {
+            // 1. 打开打印机
+            System.out.println("打开打印机...");
+            int result = tsc.openport("TSC TTP-244 PRO");
+            if (result != 1) {
+                System.err.println("打开打印机失败");
+                return;
+            }
+
+            // 2. 设置标签参数
+            System.out.println("设置标签参数...");
+            tsc.setup("76.2", "41.1", "4", "10", "0", "3", "0");
+
+            // 3. 清除缓冲区
+            tsc.clearbuffer();
+
+            // 4. 用 sendcommand 发送原始 TSPL 二维码命令
+            System.out.println("发送原始 TSPL 二维码命令...");
+            String qrCommand = "QRCODE 50,100,L,5,A,0,\"TEST123\"";
+            System.out.println("  命令: " + qrCommand);
+
+            tsc.sendcommand(qrCommand);
+
+            // 5. 打印
+            System.out.println("开始打印...");
+            tsc.printlabel("1", "1");
+
+            // 6. 关闭打印机
+            tsc.closeport();
+
+            System.out.println("完成!");
+
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 54 - 0
src/com/mes/print/test/TestTscPrint.java

@@ -0,0 +1,54 @@
+package com.mes.print.test;
+
+import com.mes.print.tsc.TscPrinter;
+import java.util.Scanner;
+
+/**
+ * TSC DLL 打印测试(命令行):输入客户码直接打一张标签
+ * NO 流水号和产品型号都由客户码决定
+ */
+public class TestTscPrint {
+
+    public static void main(String[] args) {
+        Scanner scanner;
+        try {
+            System.setOut(new java.io.PrintStream(System.out, true, "GBK"));
+            scanner = new Scanner(System.in, "GBK");
+        } catch (Exception e) {
+            scanner = new Scanner(System.in);
+        }
+
+        try {
+            System.out.println("===== TSC DLL 打印测试 =====");
+            System.out.print("请输入客户码 (如 +KB60IS3031T929000401): ");
+            String sn = scanner.nextLine().trim().toUpperCase();
+            if (sn.isEmpty()) {
+                System.err.println("客户码不能为空!");
+                return;
+            }
+
+            String model = TscPrinter.productModelOf(sn);
+            String serialNo = TscPrinter.serialNoOf(sn);
+            if (model == null || serialNo == null) {
+                System.err.println("客户码无法识别(型号=" + model + ",流水号=" + serialNo + ")");
+                return;
+            }
+            System.out.println("产品: " + TscPrinter.productOf(sn) + "  型号: " + model + "  NO:" + serialNo);
+
+            System.out.print("\n确认打印?(y/n): ");
+            String confirm = scanner.nextLine().trim().toLowerCase();
+            if (!confirm.equals("y") && !confirm.equals("yes")) {
+                System.out.println("已取消打印");
+                return;
+            }
+
+            new TscPrinter().printLabel(sn);
+            System.out.println("✓ 打印完成!");
+        } catch (Exception e) {
+            System.err.println("\n✗ 打印失败: " + e.getMessage());
+            e.printStackTrace();
+        } finally {
+            scanner.close();
+        }
+    }
+}

+ 104 - 0
src/com/mes/print/test/TscPrintUI.java

@@ -0,0 +1,104 @@
+package com.mes.print.test;
+
+import com.mes.print.tsc.TscPrinter;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+
+/**
+ * TSC 标签打印测试界面:输入客户码打一张(不查重、不记历史,正式使用走 MesClient)
+ */
+public class TscPrintUI extends JFrame {
+
+    private JTextField snField;
+    private JTextArea logArea;
+    private JButton printButton;
+
+    public TscPrintUI() {
+        setTitle("TSC 标签打印测试");
+        setSize(520, 400);
+        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+        setLocationRelativeTo(null);
+        initComponents();
+    }
+
+    private void initComponents() {
+        JPanel mainPanel = new JPanel(new BorderLayout(10, 10));
+        mainPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+
+        JPanel inputPanel = new JPanel(new BorderLayout(10, 0));
+        inputPanel.add(new JLabel("客户码:"), BorderLayout.WEST);
+        snField = new JTextField();
+        snField.addActionListener(this::onPrint);
+        inputPanel.add(snField, BorderLayout.CENTER);
+        printButton = new JButton("打印");
+        printButton.addActionListener(this::onPrint);
+        inputPanel.add(printButton, BorderLayout.EAST);
+        mainPanel.add(inputPanel, BorderLayout.NORTH);
+
+        logArea = new JTextArea();
+        logArea.setEditable(false);
+        logArea.setFont(new Font("Monospaced", Font.PLAIN, 12));
+        JScrollPane scrollPane = new JScrollPane(logArea);
+        scrollPane.setBorder(BorderFactory.createTitledBorder("打印日志"));
+        mainPanel.add(scrollPane, BorderLayout.CENTER);
+
+        JLabel infoLabel = new JLabel("+KB60* → T09 2510094CDE2200 / +KB87* → T68 2510094ANB0900,NO 取码中 4 位日流水");
+        infoLabel.setForeground(Color.GRAY);
+        mainPanel.add(infoLabel, BorderLayout.SOUTH);
+
+        setContentPane(mainPanel);
+    }
+
+    private void onPrint(ActionEvent e) {
+        String sn = snField.getText().trim().toUpperCase();
+        if (sn.isEmpty()) {
+            JOptionPane.showMessageDialog(this, "客户码不能为空!", "输入错误", JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+        String model = TscPrinter.productModelOf(sn);
+        String serialNo = TscPrinter.serialNoOf(sn);
+        if (model == null || serialNo == null) {
+            JOptionPane.showMessageDialog(this, "客户码无法识别(型号=" + model + ",流水号=" + serialNo + ")",
+                    "输入错误", JOptionPane.ERROR_MESSAGE);
+            return;
+        }
+
+        printButton.setEnabled(false);
+        log("开始打印: " + sn + "  " + TscPrinter.productOf(sn) + "  " + model + "  NO:" + serialNo);
+
+        new Thread(() -> {
+            try {
+                new TscPrinter().printLabel(sn);
+                SwingUtilities.invokeLater(() -> {
+                    log("✓ 打印成功");
+                    snField.setText("");
+                    printButton.setEnabled(true);
+                });
+            } catch (Throwable ex) {
+                SwingUtilities.invokeLater(() -> {
+                    log("✗ 打印失败: " + ex.getMessage());
+                    printButton.setEnabled(true);
+                    JOptionPane.showMessageDialog(TscPrintUI.this, "打印失败:\n" + ex.getMessage(),
+                            "打印错误", JOptionPane.ERROR_MESSAGE);
+                });
+            }
+        }, "tsc-test-print").start();
+    }
+
+    private void log(String message) {
+        SwingUtilities.invokeLater(() -> {
+            logArea.append(message + "\n");
+            logArea.setCaretPosition(logArea.getDocument().getLength());
+        });
+    }
+
+    public static void main(String[] args) {
+        try {
+            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+        } catch (Exception ignored) {
+        }
+        SwingUtilities.invokeLater(() -> new TscPrintUI().setVisible(true));
+    }
+}

+ 116 - 0
src/com/mes/print/tsc/TSCLIB.java

@@ -0,0 +1,116 @@
+package com.mes.print.tsc;
+
+import com.sun.jna.Library;
+import com.sun.jna.Native;
+
+/**
+ * TSC 打印机 DLL 接口
+ * 基于 TSCLIB.dll 的 JNA 封装
+ */
+public interface TSCLIB extends Library {
+
+    // 加载 DLL,JNA 查找顺序:jna.library.path → 系统搜索(当前目录/PATH 等)
+    // → 都没有就从 classpath 的 win32-x86-64/TSCLIB.dll 解压加载(jar 内置,见 IDEA artifact 配置)
+    TSCLIB INSTANCE = (TSCLIB) Native.load("TSCLIB", TSCLIB.class);
+
+    // ========== 基本控制函数 ==========
+
+    /**
+     * 打开打印机端口
+     * @param pirnterName 打印机名称(如 "TSC TTP-244 PRO")
+     * @return 成功返回 1,失败返回 0
+     */
+    int openport(String pirnterName);
+
+    /**
+     * 关闭打印机端口
+     */
+    int closeport();
+
+    /**
+     * 设置打印机参数
+     * @param width 标签宽度(单位:mm)
+     * @param height 标签高度(单位:mm)
+     * @param speed 打印速度(1-15)
+     * @param density 打印浓度(0-15)
+     * @param sensor 传感器类型(0=垂直间距传感器, 1=间隙传感器)
+     * @param vertical 垂直间距高度(单位:mm,仅 sensor=0 时有效)
+     * @param offset 标签偏移量(单位:mm)
+     */
+    int setup(String width, String height, String speed, String density,
+              String sensor, String vertical, String offset);
+
+    /**
+     * 清除打印缓冲区
+     */
+    int clearbuffer();
+
+    /**
+     * 校准打印机(自动检测标签间隙)
+     */
+    int calibrate();
+
+    /**
+     * 打印标签
+     * @param sets 打印份数
+     * @param copys 每份打印的标签数
+     */
+    int printlabel(String sets, String copys);
+
+    // ========== 文本函数 ==========
+
+    /**
+     * 打印文本
+     * @param x X坐标(单位:dot)
+     * @param y Y坐标(单位:dot)
+     * @param font 字体名称(如 "TSS24.BF2", "1", "2"...)
+     * @param rotation 旋转角度(0, 90, 180, 270)
+     * @param x_multiplication X方向放大倍数(1-10)
+     * @param y_multiplication Y方向放大倍数(1-10)
+     * @param text 文本内容
+     */
+    int windowsfont(int x, int y, int fontheight, int rotation,
+                    int fontstyle, int fontunderline, String szFaceName, String content);
+
+    /**
+     * 打印条形码(简化版)
+     */
+    int barcode(String x, String y, String type, String height,
+                String readable, String rotation, String narrow, String wide, String code);
+
+    // ========== 条码/二维码函数 ==========
+
+    /**
+     * 打印一维条码
+     * @param x X坐标
+     * @param y Y坐标
+     * @param type 条码类型("128", "EAN13", "CODE39"等)
+     * @param height 条码高度(单位:dot)
+     * @param readable 是否显示可读文字(0=不显示, 1=显示)
+     * @param rotation 旋转角度(0, 90, 180, 270)
+     * @param narrow 窄条宽度(单位:dot)
+     * @param wide 宽条宽度(单位:dot)
+     * @param code 条码内容
+     */
+    int barcode128(String x, String y, String height, String readable,
+                   String rotation, String narrow, String code);
+
+    /**
+     * 打印 QR Code 二维码
+     * @param x X坐标
+     * @param y Y坐标
+     * @param eccLevel 纠错等级("L", "M", "Q", "H")
+     * @param cellWidth 单元宽度(1-10)
+     * @param mode 模式("A"=自动, "M"=手动)
+     * @param rotation 旋转角度(0, 90, 180, 270)
+     * @param code 二维码内容
+     */
+    int qrcode(String x, String y, String eccLevel, String cellWidth,
+               String mode, String rotation, String code);
+
+    /**
+     * 发送原始命令到打印机
+     * @param command TSPL 命令字符串
+     */
+    int sendcommand(String command);
+}

+ 182 - 0
src/com/mes/print/tsc/TscPrinter.java

@@ -0,0 +1,182 @@
+package com.mes.print.tsc;
+
+import com.mes.print.config.LabelConfigLoader;
+import com.mes.print.config.PrinterConfig;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * TSC 标签机 - 通过 TSCLIB.dll 直接发指令打印
+ *
+ * 不依赖 BarTender,不读 .btw 文件。布局来自 config/label-config.json
+ * (按 T09.btw / T68.btw 复刻,可用 label-editor.html 调整):
+ * 程序目录下有就用外部的,没有就用 jar 内打包的那份。
+ *
+ * 标签上的可变内容全部来自扫到的客户码:
+ *   sn           = 码本身(二维码 + 底部批次号)
+ *   productModel = 前缀固定映射:+KB60 → 2510094CDE2200(T09),+KB87 → 2510094ANB0900(T68)
+ *   serialNo     = 码里的 4 位日流水补零到 7 位:+KB60IS3031T929000401 → 0000004
+ *   date         = 打印日期(补打时由调用方传首次打印日期)
+ *
+ * label-config.json 里非 static 元素的 text 只是编辑器预览用的示例值,
+ * 打印时取不到业务数据直接报错,绝不回退到示例值。
+ */
+public class TscPrinter {
+
+    private final TSCLIB tsc = TSCLIB.INSTANCE;
+    private final LabelConfigLoader configLoader;
+
+    /** 默认布局:外部 config/label-config.json 优先,否则 jar 内置 */
+    public TscPrinter() {
+        try {
+            this.configLoader = LabelConfigLoader.load();
+        } catch (Exception e) {
+            throw new RuntimeException("加载标签配置失败: " + e.getMessage(), e);
+        }
+    }
+
+    /** 指定布局文件(测试用) */
+    public TscPrinter(String configPath) {
+        try {
+            this.configLoader = new LabelConfigLoader(configPath);
+        } catch (Exception e) {
+            throw new RuntimeException("加载标签配置失败(" + configPath + "): " + e.getMessage(), e);
+        }
+    }
+
+    /** 打印标签,日期取当天 */
+    public void printLabel(String sn) {
+        printLabel(sn, new Date());
+    }
+
+    /**
+     * 打印标签
+     * @param sn 扫到的客户码
+     * @param printDate 标签上的日期
+     */
+    public void printLabel(String sn, Date printDate) {
+        if (sn == null || sn.trim().isEmpty()) {
+            throw new RuntimeException("客户码为空");
+        }
+        // 先组装数据,数据有问题直接报错,不去占打印机端口
+        Map<String, String> dataMap = prepareDataMap(sn.trim(), printDate == null ? new Date() : printDate);
+
+        String printerName = PrinterConfig.getLabelPrinterName();
+        if (tsc.openport(printerName) != 1) {
+            throw new RuntimeException("打开标签机失败: " + printerName + "(检查 TSC 驱动是否安装、打印机名是否一致)");
+        }
+        try {
+            LabelConfigLoader.LabelSize labelSize = configLoader.getLabelSize();
+            tsc.setup(
+                String.valueOf(labelSize.width),
+                String.valueOf(labelSize.totalHeight),
+                "4",  // 速度
+                "10", // 浓度
+                "0",  // 传感器类型
+                String.valueOf(labelSize.gap),
+                "0"   // 偏移
+            );
+            tsc.clearbuffer();
+            drawLabelFromConfig(dataMap);
+            tsc.printlabel("1", "1");  // 打印1份,每份1张
+            System.out.println("[TSC] 标签打印成功: " + sn + " NO:" + dataMap.get("serialNo") + " @" + printerName);
+        } catch (Exception e) {
+            throw new RuntimeException("打印失败: " + e.getMessage(), e);
+        } finally {
+            // 异常时也要释放端口,否则下一张 openport 会失败
+            tsc.closeport();
+        }
+    }
+
+    /** 按配置逐个绘制元素 */
+    private void drawLabelFromConfig(Map<String, String> dataMap) {
+        List<LabelConfigLoader.LabelElement> elements = configLoader.getElements();
+        for (LabelConfigLoader.LabelElement element : elements) {
+            if ("text".equals(element.type)) {
+                tsc.windowsfont(
+                    element.x,
+                    element.y,
+                    element.fontSize,
+                    0,  // rotation
+                    element.fontStyle,
+                    0,  // underline
+                    element.fontFamily,
+                    resolveData(element, dataMap)
+                );
+            } else if ("qrcode".equals(element.type)) {
+                // qrcode() 函数不工作,用原始 TSPL:QRCODE x,y,eccLevel,cellWidth,mode,rotation,"data"
+                String qrCommand = String.format(
+                    "QRCODE %d,%d,%s,%s,%s,%s,\"%s\"",
+                    element.x,
+                    element.y,
+                    element.eccLevel,
+                    element.cellWidth,
+                    "A",  // mode
+                    "0",  // rotation
+                    resolveData(element, dataMap)
+                );
+                System.out.println("[TSC] " + element.id + " -> " + qrCommand);
+                tsc.sendcommand(qrCommand);
+            }
+        }
+    }
+
+    private Map<String, String> prepareDataMap(String sn, Date printDate) {
+        String productModel = productModelOf(sn);
+        if (productModel == null) throw new RuntimeException("无法识别产品型号: " + sn);
+        String serialNo = serialNoOf(sn);
+        if (serialNo == null) throw new RuntimeException("无法从客户码解析流水号: " + sn);
+
+        String date = new SimpleDateFormat("yyyy/MM/dd").format(printDate);
+        Map<String, String> dataMap = new HashMap<>();
+        dataMap.put("sn", sn);
+        dataMap.put("productModel", productModel);
+        dataMap.put("date", date);
+        dataMap.put("serialNo", serialNo);
+        dataMap.put("dateSerial", date + " NO:" + serialNo);
+        return dataMap;
+    }
+
+    /** static 用配置里的固定文本;其他必须能从业务数据取到,否则报错 */
+    private String resolveData(LabelConfigLoader.LabelElement element, Map<String, String> dataMap) {
+        if ("static".equals(element.dataSource)) {
+            return element.text == null ? "" : element.text;
+        }
+        String v = dataMap.get(element.dataSource);
+        if (v == null || v.isEmpty()) {
+            throw new RuntimeException("标签元素 [" + element.id + "] 数据源 [" + element.dataSource + "] 无数据,已中止打印");
+        }
+        return v;
+    }
+
+    /** 前缀 → 产品型号(固定值),识别不了返回 null */
+    public static String productModelOf(String sn) {
+        if (sn == null) return null;
+        if (sn.startsWith("+KB60")) return "2510094CDE2200";
+        if (sn.startsWith("+KB87")) return "2510094ANB0900";
+        return null;
+    }
+
+    /** 前缀 → 产品(T09 / T68),识别不了返回 null */
+    public static String productOf(String sn) {
+        if (sn == null) return null;
+        if (sn.startsWith("+KB60")) return "T09";
+        if (sn.startsWith("+KB87")) return "T68";
+        return null;
+    }
+
+    /**
+     * 客户码 → 标签 NO(7 位)
+     * 码结构:...T + 月(1) + 日(2) + 日流水(4) + 01,取倒数第 6~3 位的日流水
+     * 解析不了返回 null
+     */
+    public static String serialNoOf(String sn) {
+        if (sn == null || sn.length() < 6) return null;
+        String seq = sn.substring(sn.length() - 6, sn.length() - 2);
+        return seq.matches("\\d{4}") ? "000" + seq : null;
+    }
+}

+ 109 - 0
src/com/mes/print/ui/PrinterSettingDialog.java

@@ -0,0 +1,109 @@
+package com.mes.print.ui;
+
+import com.mes.print.config.PrinterConfig;
+import com.mes.print.util.PrinterUtil;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 标签机设置:选择 Windows 打印机名,保存到 config/print.properties 的 label.printer.name
+ * 保存后立即生效,无需重启。
+ */
+public class PrinterSettingDialog extends JDialog {
+
+	private static final Font FONT_LABEL = new Font("微软雅黑", Font.PLAIN, 18);
+	private static final Font FONT_INPUT = new Font("微软雅黑", Font.PLAIN, 16);
+
+	private JComboBox<String> printerCombo;
+
+	public PrinterSettingDialog(Frame owner) {
+		super(owner, "标签机设置", true);
+		buildUI();
+		reloadPrinters();
+		printerCombo.setSelectedItem(currentConfigName());
+		pack();
+		setLocationRelativeTo(owner);
+	}
+
+	private void buildUI() {
+		setLayout(new BorderLayout(0, 0));
+
+		JPanel center = new JPanel(new GridBagLayout());
+		center.setBorder(BorderFactory.createEmptyBorder(20, 25, 10, 25));
+		GridBagConstraints c = new GridBagConstraints();
+		c.insets = new Insets(10, 8, 10, 8);
+		c.anchor = GridBagConstraints.WEST;
+
+		JLabel label = new JLabel("标签机:");
+		label.setFont(FONT_LABEL);
+		c.gridx = 0; c.gridy = 0;
+		center.add(label, c);
+
+		printerCombo = new JComboBox<>();
+		printerCombo.setEditable(true);
+		printerCombo.setFont(FONT_INPUT);
+		printerCombo.setPreferredSize(new Dimension(360, 32));
+
+		JButton refreshBtn = newButton("刷新");
+		refreshBtn.addActionListener(e -> reloadPrinters());
+
+		JPanel printerPanel = new JPanel(new BorderLayout(6, 0));
+		printerPanel.add(printerCombo, BorderLayout.CENTER);
+		printerPanel.add(refreshBtn, BorderLayout.EAST);
+		c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0;
+		center.add(printerPanel, c);
+
+		JLabel hint = new JLabel("<html><span style='color:gray'>下拉列出本机已安装的打印机,请选 TSC 驱动对应的那一项(名字需与系统里完全一致)</span></html>");
+		hint.setFont(new Font("微软雅黑", Font.PLAIN, 12));
+		c.gridx = 0; c.gridy = 1; c.gridwidth = 2;
+		center.add(hint, c);
+
+		add(center, BorderLayout.CENTER);
+
+		JPanel bottom = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 10));
+		JButton okBtn     = newButton("保存");
+		JButton cancelBtn = newButton("取消");
+		okBtn.addActionListener(e -> doSave());
+		cancelBtn.addActionListener(e -> dispose());
+		bottom.add(okBtn); bottom.add(cancelBtn);
+		add(bottom, BorderLayout.SOUTH);
+	}
+
+	private JButton newButton(String text) {
+		JButton b = new JButton(text);
+		b.setFont(FONT_INPUT);
+		return b;
+	}
+
+	private void reloadPrinters() {
+		Object cur = printerCombo.getSelectedItem();
+		printerCombo.removeAllItems();
+		for (String n : PrinterUtil.listNames()) printerCombo.addItem(n);
+		if (cur != null && !cur.toString().isEmpty()) printerCombo.setSelectedItem(cur);
+	}
+
+	private static String currentConfigName() {
+		try {
+			return PrinterConfig.getLabelPrinterName();
+		} catch (Exception e) {
+			return PrinterConfig.DEFAULT_LABEL_PRINTER;
+		}
+	}
+
+	private void doSave() {
+		Object v = printerCombo.getSelectedItem();
+		String name = v == null ? "" : v.toString().trim();
+		if (name.isEmpty()) {
+			JOptionPane.showMessageDialog(this, "请选择或填写标签机", "提示", JOptionPane.INFORMATION_MESSAGE);
+			return;
+		}
+		try {
+			PrinterConfig.set("label.printer.name", name);
+			JOptionPane.showMessageDialog(this, "标签机已保存: " + name, "提示", JOptionPane.INFORMATION_MESSAGE);
+			dispose();
+		} catch (Exception ex) {
+			JOptionPane.showMessageDialog(this, "保存失败:" + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
+		}
+	}
+}

+ 41 - 0
src/com/mes/print/util/PrinterUtil.java

@@ -0,0 +1,41 @@
+package com.mes.print.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.print.PrintService;
+import javax.print.PrintServiceLookup;
+
+import com.mes.print.PrinterException;
+
+/**
+ * 打印机查找工具
+ */
+public class PrinterUtil {
+
+	/** 按名字精确或模糊匹配(contains),找不到抛异常 */
+	public static PrintService find(String name) {
+		if (name == null || name.trim().isEmpty()) {
+			throw new PrinterException("未配置打印机名 (printer.name)");
+		}
+		PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
+		// 先精确匹配
+		for (PrintService s : services) {
+			if (s.getName().equalsIgnoreCase(name)) return s;
+		}
+		// 再模糊匹配
+		for (PrintService s : services) {
+			if (s.getName().toLowerCase().contains(name.toLowerCase())) return s;
+		}
+		throw new PrinterException("未找到打印机: " + name + ",本机可用: " + listNames());
+	}
+
+	/** 列出本机所有可用打印机名字 */
+	public static List<String> listNames() {
+		List<String> list = new ArrayList<>();
+		for (PrintService s : PrintServiceLookup.lookupPrintServices(null, null)) {
+			list.add(s.getName());
+		}
+		return list;
+	}
+}

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

@@ -0,0 +1,115 @@
+package com.mes.util;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.PrintStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+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 File stateFile;
+    private static ScheduledExecutorService heartbeat;
+    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");
+                stateFile = new File(base, "mes-client.running");
+                if (stateFile.exists()) {
+                    log("WARN", "检测到上一次客户端未正常关闭,可能是强制结束、崩溃、断电或系统终止", null);
+                }
+                writeState();
+                heartbeat = Executors.newSingleThreadScheduledExecutor(r -> {
+                    Thread t = new Thread(r, "mes-log-heartbeat");
+                    t.setDaemon(true);
+                    return t;
+                });
+                heartbeat.scheduleAtFixedRate(() -> writeState(), 5, 5, TimeUnit.SECONDS);
+                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);
+                        } else {
+                            log("WARN", "客户端退出时未收到正常关闭标记", null);
+                        }
+                        if (heartbeat != null) heartbeat.shutdownNow();
+                        if ("USER_CLOSE".equals(exitReason)) stateFile.delete();
+                    }
+                }, "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 void writeState() {
+        synchronized (LOCK) {
+            if (stateFile == null) return;
+            try (FileWriter writer = new FileWriter(stateFile, false)) {
+                writer.write("pid=" + getPid() + "\n");
+                writer.write("heartbeat=" + TIME.format(new Date()) + "\n");
+            } catch (IOException ignored) { }
+        }
+    }
+
+    private static String getPid() {
+        String name = java.lang.management.ManagementFactory.getRuntimeMXBean().getName();
+        int separator = name.indexOf('@');
+        return separator > 0 ? name.substring(0, separator) : name;
+    }
+
+    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();
+        }
+    }
+}