Print Agent 與硬體整合
什麼是 Print Agent?
💡 比喻:Web 和印表機之間的翻譯官 瀏覽器(Web)不能直接跟印表機溝通, 就像你不能直接用中文跟一個只懂日文的人對話。 Print Agent 就是「翻譯官」—— Web 說「請幫我印這張收據」,Print Agent 翻譯成印表機懂的指令。
為什麼需要 Print Agent?
瀏覽器的限制:
─────────────────────────────────────────────
❌ 瀏覽器不能直接控制 USB 印表機
❌ 瀏覽器不能送 ESC/POS 指令
❌ 瀏覽器不能開錢箱
❌ 瀏覽器不能控制客顯螢幕
解決方案:Print Agent(本機代理程式)
─────────────────────────────────────────────
✅ Print Agent 是一個跑在本機的小程式
✅ 它監聽 WebSocket 連線
✅ Web 透過 WebSocket 傳送列印指令
✅ Print Agent 收到後轉給印表機
資料流程:
Web (JS) → WebSocket → Print Agent → USB → 印表機
↑
這就是 Print Agent 扮演「翻譯官」的地方
熱感應印表機原理(ESC/POS 指令)
💡 比喻:印表機的「摩斯密碼」 ESC/POS 是 Epson 發明的印表機指令集, 就像摩斯密碼一樣,每個指令代表不同動作。 例如
\x1b\x40就是「初始化」,\x1d\x56\x00就是「切紙」。
常用 ESC/POS 指令
指令 功能 十六進位
──────────────────────────────────────────────────
ESC @ 初始化印表機 1B 40
LF 換行 0A
ESC a 1 置中對齊 1B 61 01
ESC a 0 靠左對齊 1B 61 00
ESC E 1 粗體開 1B 45 01
ESC E 0 粗體關 1B 45 00
GS V 0 全切紙 1D 56 00
GS V 1 半切紙 1D 56 01
ESC p 0 100 100 開錢箱 1B 70 00 64 64
GS ! 0x11 放大字體 2x2 1D 21 11
用 C# 定義 ESC/POS 指令
// 定義 ESC/POS 指令的靜態類別 // 封裝常用印表機指令
public static class EscPos // ESC/POS 指令類別
{
// 初始化印表機 // 重設所有設定
public static readonly byte[] Init = new byte[] { 0x1B, 0x40 }; // ESC @ 指令 // 印表機重設
// 換行 // 印一行空白
public static readonly byte[] LineFeed = new byte[] { 0x0A }; // LF 指令 // 換行
// 置中對齊 // 文字置中
public static readonly byte[] AlignCenter = new byte[] { 0x1B, 0x61, 0x01 }; // ESC a 1 // 置中
// 靠左對齊 // 文字靠左(預設)
public static readonly byte[] AlignLeft = new byte[] { 0x1B, 0x61, 0x00 }; // ESC a 0 // 靠左
// 靠右對齊 // 文字靠右
public static readonly byte[] AlignRight = new byte[] { 0x1B, 0x61, 0x02 }; // ESC a 2 // 靠右
// 粗體開 // 加粗文字
public static readonly byte[] BoldOn = new byte[] { 0x1B, 0x45, 0x01 }; // ESC E 1 // 粗體
// 粗體關 // 取消加粗
public static readonly byte[] BoldOff = new byte[] { 0x1B, 0x45, 0x00 }; // ESC E 0 // 取消粗體
// 放大字體(2倍寬2倍高)// 用於店名或總金額
public static readonly byte[] DoubleSize = new byte[] { 0x1D, 0x21, 0x11 }; // GS ! 0x11 // 2x2 放大
// 恢復正常字體 // 取消放大
public static readonly byte[] NormalSize = new byte[] { 0x1D, 0x21, 0x00 }; // GS ! 0x00 // 正常大小
// 切紙(全切)// 切斷收據紙
public static readonly byte[] CutPaper = new byte[] { 0x1D, 0x56, 0x00 }; // GS V 0 // 全切
// 開錢箱 // 送電子信號打開錢箱
public static readonly byte[] OpenDrawer = new byte[] { 0x1B, 0x70, 0x00, 0x64, 0x64 }; // ESC p // 開錢箱
// 組合指令的輔助方法 // 把多個 byte 陣列串在一起
public static byte[] Combine(params byte[][] commands) // 組合指令方法
{
var result = new List<byte>(); // 建立 byte 清單 // 收集所有位元組
foreach (var cmd in commands) // 逐一加入指令
{
result.AddRange(cmd); // 加入指令的位元組 // 串接
}
return result.ToArray(); // 轉換為陣列回傳 // 完整的指令序列
}
}
Node.js Print Agent 實作
💡 比喻:一個隨時待命的翻譯官 Print Agent 就是一個 Node.js 程式, 它啟動後會打開一個 WebSocket 伺服器, 等著 Web 端送來列印指令, 收到後就翻譯成 ESC/POS 指令送給印表機。
Node.js Print Agent 程式碼
// Node.js Print Agent 主程式 // 監聽 WebSocket 並控制印表機
const WebSocket = require('ws'); // 引入 WebSocket 套件 // 用於與 Web 端通訊
const { SerialPort } = require('serialport'); // 引入串列埠套件 // USB 印表機用
const escpos = require('escpos'); // 引入 ESC/POS 套件 // 印表機指令
// Print Agent 設定 // 連接埠和印表機設定
const CONFIG = { // 設定物件
wsPort: 9100, // WebSocket 監聽埠 // Web 端連線用
printerPath: '/dev/usb/lp0', // 印表機裝置路徑 // Linux USB 印表機
encoding: 'Big5', // 字元編碼 // 中文用 Big5 或 UTF-8
}; // 設定結束
// 建立 WebSocket 伺服器 // 等待 Web 端連線
const wss = new WebSocket.Server({ port: CONFIG.wsPort }); // 監聽指定埠號
console.log(`Print Agent 啟動,監聽 ws://localhost:${CONFIG.wsPort}`); // 顯示啟動訊息
// 處理 WebSocket 連線 // 每個連線代表一個 Web 端
wss.on('connection', (ws) => { // 監聽連線事件
console.log('Web 端已連線'); // 顯示連線成功
// 處理收到的訊息 // Web 端送來的列印指令
ws.on('message', (message) => { // 監聽訊息事件
try { // 嘗試處理訊息
const data = JSON.parse(message); // 解析 JSON 訊息 // 取得列印資料
console.log('收到列印指令:', data.type); // 顯示指令類型
switch (data.type) { // 根據指令類型處理
case 'print-receipt': // 列印收據指令
printReceipt(data.payload); // 呼叫列印收據方法
ws.send(JSON.stringify({ status: 'ok', message: '收據列印成功' })); // 回傳成功
break; // 結束 case
case 'open-drawer': // 開錢箱指令
openCashDrawer(); // 呼叫開錢箱方法
ws.send(JSON.stringify({ status: 'ok', message: '錢箱已開啟' })); // 回傳成功
break; // 結束 case
case 'test-print': // 測試列印指令
testPrint(); // 呼叫測試列印方法
ws.send(JSON.stringify({ status: 'ok', message: '測試列印成功' })); // 回傳成功
break; // 結束 case
default: // 未知指令
ws.send(JSON.stringify({ status: 'error', message: '未知指令' })); // 回傳錯誤
}
} catch (err) { // 處理錯誤
console.error('處理指令失敗:', err); // 顯示錯誤訊息
ws.send(JSON.stringify({ status: 'error', message: err.message })); // 回傳錯誤
}
}); // message 事件結束
ws.on('close', () => console.log('Web 端已斷線')); // 斷線事件 // 顯示斷線訊息
}); // connection 事件結束
// 列印收據的方法 // 核心列印功能
function printReceipt(receipt) { // 列印收據函式
console.log('開始列印收據...'); // 顯示開始列印
console.log('店名:', receipt.storeName); // 顯示店名
console.log('商品數:', receipt.items.length); // 顯示商品數量
console.log('總金額:', receipt.total); // 顯示總金額
// 實際上這裡會用 escpos 套件送 ESC/POS 指令到印表機 // 這裡簡化為 console.log
}
// 開錢箱的方法 // 送 ESC/POS 開錢箱指令
function openCashDrawer() { // 開錢箱函式
console.log('開啟錢箱...'); // 顯示開啟訊息
// 送 ESC p 0 100 100 指令到印表機 // 透過印表機的 RJ-11 接口控制錢箱
}
// 測試列印的方法 // 確認印表機是否正常
function testPrint() { // 測試列印函式
console.log('執行測試列印...'); // 顯示測試訊息
// 印出一張測試頁 // 包含日期時間和虛線
}
JS 透過 WebSocket 呼叫 Print Agent
// Web 端的 Print Agent 連線管理 // 負責與 Print Agent 通訊
class PrintClient { // 列印客戶端類別
constructor(url = 'ws://localhost:9100') { // 建構函式 // 預設連線到本機
this.url = url; // WebSocket URL // Print Agent 的位址
this.ws = null; // WebSocket 實例 // 初始為 null
this.connected = false; // 連線狀態 // 初始未連線
this.reconnectInterval = 3000; // 重連間隔(毫秒)// 斷線後 3 秒重連
}
// 建立連線的方法 // 連到 Print Agent
connect() { // 連線方法
this.ws = new WebSocket(this.url); // 建立 WebSocket 連線
this.ws.onopen = () => { // 連線成功事件
this.connected = true; // 設定狀態為已連線
console.log('已連線到 Print Agent'); // 顯示連線成功
document.getElementById('printerStatus').textContent = '🟢 印表機已連線'; // 更新狀態顯示
}; // onopen 結束
this.ws.onclose = () => { // 斷線事件
this.connected = false; // 設定狀態為未連線
console.log('與 Print Agent 斷線,準備重連...'); // 顯示斷線訊息
document.getElementById('printerStatus').textContent = '🔴 印表機已斷線'; // 更新狀態顯示
setTimeout(() => this.connect(), this.reconnectInterval); // 排程重連 // 3 秒後重試
}; // onclose 結束
this.ws.onmessage = (event) => { // 收到訊息事件
const response = JSON.parse(event.data); // 解析回應 // JSON 格式
console.log('Print Agent 回應:', response); // 顯示回應內容
}; // onmessage 結束
this.ws.onerror = (err) => { // 錯誤事件
console.error('WebSocket 錯誤:', err); // 顯示錯誤
}; // onerror 結束
}
// 列印收據的方法 // 送列印指令到 Print Agent
printReceipt(order) { // 列印收據方法
if (!this.connected) { // 檢查是否已連線
console.error('未連線到 Print Agent'); // 未連線的錯誤
return false; // 回傳失敗
}
const message = { // 建立訊息物件
type: 'print-receipt', // 指令類型:列印收據
payload: { // 列印資料
storeName: '我的小店', // 店名 // 會印在收據最上方
items: order.items.map(i => ({ // 商品清單 // 轉換格式
name: i.name, // 商品名稱
qty: i.quantity, // 數量
price: i.price, // 單價
subtotal: i.subtotal // 小計
})), // 商品清單結束
total: order.total, // 總金額
payment: order.payment, // 付款方式
time: new Date().toLocaleString('zh-TW') // 交易時間 // 格式化為中文日期
} // payload 結束
}; // 訊息物件結束
this.ws.send(JSON.stringify(message)); // 送出訊息 // 轉為 JSON 字串
return true; // 回傳成功
}
// 開錢箱的方法 // 送開錢箱指令
openDrawer() { // 開錢箱方法
if (!this.connected) return false; // 檢查連線
this.ws.send(JSON.stringify({ type: 'open-drawer' })); // 送開錢箱指令
return true; // 回傳成功
}
}
// 初始化列印客戶端 // 頁面載入時自動連線
const printer = new PrintClient(); // 建立列印客戶端實例
printer.connect(); // 開始連線到 Print Agent
收據模板設計(店名、商品、金額、條碼)
// 定義進階收據模板的類別 // 支援更多格式化選項
public class AdvancedReceiptTemplate // 進階收據模板
{
// 產生 ESC/POS 收據指令的方法 // 回傳 byte 陣列
public static byte[] BuildReceiptBytes(Order order, string storeName) // 建立收據方法
{
var commands = new List<byte[]>(); // 建立指令清單 // 收集所有 ESC/POS 指令
// === 收據頭部 === // 店名和基本資訊
commands.Add(EscPos.Init); // 初始化印表機 // 重設所有設定
commands.Add(EscPos.AlignCenter); // 置中對齊 // 店名要置中
commands.Add(EscPos.DoubleSize); // 放大字體 // 店名要大一點
commands.Add(TextToBytes(storeName)); // 印出店名 // 轉換為 byte 陣列
commands.Add(EscPos.LineFeed); // 換行
commands.Add(EscPos.NormalSize); // 恢復正常字體 // 其他內容用正常大小
commands.Add(TextToBytes("統一編號:12345678")); // 印出統編
commands.Add(EscPos.LineFeed); // 換行
commands.Add(TextToBytes("TEL: 02-1234-5678")); // 印出電話
commands.Add(EscPos.LineFeed); // 換行
commands.Add(TextToBytes("================================")); // 分隔線 // 32 字元
commands.Add(EscPos.LineFeed); // 換行
// === 交易資訊 === // 日期、單號
commands.Add(EscPos.AlignLeft); // 靠左對齊 // 交易資訊靠左
commands.Add(TextToBytes($"日期:{order.OrderTime:yyyy/MM/dd HH:mm}")); // 交易日期
commands.Add(EscPos.LineFeed); // 換行
commands.Add(TextToBytes($"單號:{order.Id:D8}")); // 訂單編號 // 8 位補零
commands.Add(EscPos.LineFeed); // 換行
commands.Add(TextToBytes("--------------------------------")); // 分隔線
commands.Add(EscPos.LineFeed); // 換行
// === 商品明細 === // 逐筆列出
foreach (var item in order.Items) // 逐一列出商品
{
commands.Add(TextToBytes(item.ProductName)); // 商品名稱 // 獨立一行
commands.Add(EscPos.LineFeed); // 換行
var detail = $" {item.Quantity} x ${item.UnitPrice,-8} ${item.Subtotal,8:F0}"; // 格式化明細
commands.Add(TextToBytes(detail)); // 數量 x 單價 = 小計
commands.Add(EscPos.LineFeed); // 換行
}
// === 總計 === // 金額加總
commands.Add(TextToBytes("================================")); // 雙線分隔
commands.Add(EscPos.LineFeed); // 換行
commands.Add(EscPos.BoldOn); // 粗體開 // 總計要醒目
commands.Add(EscPos.DoubleSize); // 放大字體 // 總計要大
commands.Add(TextToBytes($"總計 ${order.TotalAmount:F0}")); // 總金額
commands.Add(EscPos.LineFeed); // 換行
commands.Add(EscPos.NormalSize); // 恢復正常 // 其他內容正常大小
commands.Add(EscPos.BoldOff); // 粗體關
// === 付款資訊 === // 付款方式和找零
commands.Add(TextToBytes($"付款:{order.Payment}")); // 付款方式
commands.Add(EscPos.LineFeed); // 換行
// === 尾部 === // 感謝語
commands.Add(TextToBytes("--------------------------------")); // 分隔線
commands.Add(EscPos.LineFeed); // 換行
commands.Add(EscPos.AlignCenter); // 置中對齊
commands.Add(TextToBytes("謝謝光臨,歡迎再來!")); // 感謝語
commands.Add(EscPos.LineFeed); // 換行
commands.Add(EscPos.LineFeed); // 多換幾行 // 留白方便撕紙
commands.Add(EscPos.LineFeed); // 換行
commands.Add(EscPos.CutPaper); // 切紙 // 自動切斷收據
return EscPos.Combine(commands.ToArray()); // 組合所有指令 // 回傳完整的 byte 陣列
}
// 文字轉 byte 陣列的輔助方法 // 使用 UTF-8 編碼
private static byte[] TextToBytes(string text) // 文字轉 bytes
{
return System.Text.Encoding.UTF8.GetBytes(text); // UTF-8 編碼 // 支援中文字
}
}
錢箱控制(Cash Drawer)
💡 比喻:用電子信號開保險箱 錢箱(Cash Drawer)通常用 RJ-11 線連接到印表機, 印表機收到「開錢箱」的 ESC/POS 指令後, 會透過 RJ-11 接口送出電子脈衝打開錢箱的電磁鎖。 就像用遙控器開車門一樣。
// 定義錢箱控制器的類別 // 管理錢箱的開關
public class CashDrawerController // 錢箱控制器
{
private bool _isOpen = false; // 錢箱狀態 // 初始為關閉
private DateTime? _lastOpenTime; // 上次開啟時間 // 記錄操作時間
// 開啟錢箱的方法 // 送 ESC/POS 指令
public void Open() // 開啟錢箱方法
{
if (_isOpen) // 如果已經開著
{
Console.WriteLine("錢箱已經是開啟狀態"); // 顯示提示訊息
return; // 不需要重複開啟
}
// 送 ESC p 0 100 100 到印表機 // 透過印表機控制錢箱
Console.WriteLine("送出開錢箱指令:ESC p 0 100 100"); // 顯示指令
_isOpen = true; // 更新狀態為開啟
_lastOpenTime = DateTime.Now; // 記錄開啟時間
Console.WriteLine($"錢箱已開啟 - {_lastOpenTime:HH:mm:ss}"); // 顯示開啟時間
}
// 錢箱關閉的回呼 // 通常由感測器觸發
public void OnDrawerClosed() // 錢箱關閉回呼
{
_isOpen = false; // 更新狀態為關閉
Console.WriteLine("錢箱已關閉"); // 顯示關閉訊息
}
}
客顯(Customer Display)
💡 比喻:面對客人的小螢幕 客顯(Customer Display)就是面對客人的那個小螢幕, 顯示商品名稱、價格、總金額,讓客人知道自己要付多少錢。 有些是 VFD(螢光顯示器),有些是小型 LCD。
// 定義客顯控制器的類別 // 控制客戶面對的顯示器
public class CustomerDisplayController // 客顯控制器
{
private const int MaxLineLength = 20; // 每行最多顯示字元數 // VFD 通常是 20 字元
// 顯示歡迎訊息的方法 // 待機時顯示
public void ShowWelcome() // 顯示歡迎訊息
{
var line1 = CenterText("歡迎光臨", MaxLineLength); // 第一行:歡迎光臨 // 置中顯示
var line2 = CenterText("Welcome", MaxLineLength); // 第二行:Welcome // 英文歡迎
Console.WriteLine($"客顯:{line1}"); // 送到客顯第一行
Console.WriteLine($"客顯:{line2}"); // 送到客顯第二行
}
// 顯示商品資訊的方法 // 掃描商品時顯示
public void ShowItem(string name, decimal price) // 顯示商品方法
{
var line1 = name.Length > MaxLineLength // 檢查名稱是否超過顯示寬度
? name[..MaxLineLength] // 太長就截斷 // 只顯示前 20 字
: name; // 沒超過就完整顯示
var line2 = $"NT$ {price:F0}".PadLeft(MaxLineLength); // 價格靠右顯示 // 格式化為整數
Console.WriteLine($"客顯:{line1}"); // 送到客顯第一行
Console.WriteLine($"客顯:{line2}"); // 送到客顯第二行
}
// 顯示總金額的方法 // 結帳時顯示
public void ShowTotal(decimal total) // 顯示總計方法
{
var line1 = CenterText("總 計", MaxLineLength); // 第一行:總計 // 置中
var line2 = $"NT$ {total:F0}".PadLeft(MaxLineLength); // 第二行:金額 // 靠右
Console.WriteLine($"客顯:{line1}"); // 送到客顯第一行
Console.WriteLine($"客顯:{line2}"); // 送到客顯第二行
}
// 文字置中的輔助方法 // 在指定寬度內置中
private static string CenterText(string text, int width) // 置中方法
{
var padding = (width - text.Length) / 2; // 計算左邊空白 // 置中計算
return text.PadLeft(padding + text.Length).PadRight(width); // 左右補空白 // 填滿寬度
}
}
條碼掃描器整合(USB HID → Keyboard Input)
💡 比喻:掃描器就是一個「超快速打字員」 USB 條碼掃描器的運作原理其實很簡單: 它掃到條碼後,會「假裝自己是鍵盤」, 把條碼數字一個一個「打」出來,最後按 Enter。 所以 Web 端只要監聽鍵盤輸入就能收到條碼了!
條碼掃描器的 JavaScript 監聽
// 條碼掃描器監聽器 // 自動偵測快速鍵盤輸入(=掃描器)
class BarcodeScanner { // 掃描器類別
constructor(onScan) { // 建構函式 // onScan 是掃到條碼後的回呼
this.buffer = ''; // 輸入緩衝區 // 暫存掃描到的字元
this.lastKeyTime = 0; // 上次按鍵時間 // 用於判斷是否為掃描器
this.threshold = 50; // 按鍵間隔閾值(毫秒)// 人打字不可能這麼快
this.onScan = onScan; // 掃描完成的回呼函式
this.minLength = 4; // 最短條碼長度 // 避免誤判
this.init(); // 初始化監聽 // 開始監聽鍵盤事件
}
// 初始化鍵盤監聽 // 綁定 keydown 事件
init() { // 初始化方法
document.addEventListener('keydown', (e) => { // 監聽鍵盤按下事件
const now = Date.now(); // 取得當前時間 // 毫秒級時間戳
if (e.key === 'Enter') { // 如果按了 Enter // 掃描器讀完會送 Enter
if (this.buffer.length >= this.minLength) { // 如果緩衝區有足夠長度
this.onScan(this.buffer); // 觸發回呼 // 傳入條碼字串
}
this.buffer = ''; // 清空緩衝區 // 準備下次掃描
return; // 結束處理
}
if (now - this.lastKeyTime > this.threshold * 3) { // 如果距離上次按鍵太久
this.buffer = ''; // 清空緩衝區 // 這不是掃描器的輸入
}
if (e.key.length === 1) { // 如果是可打印字元 // 排除 Shift、Ctrl 等
this.buffer += e.key; // 加入緩衝區 // 累積條碼字串
}
this.lastKeyTime = now; // 更新上次按鍵時間
}); // keydown 事件結束
}
}
// 使用方式 // 建立掃描器並處理掃到的條碼
const scanner = new BarcodeScanner(async (barcode) => { // 建立掃描器實例
console.log('掃描到條碼:', barcode); // 顯示掃到的條碼
const response = await fetch(`/api/products/barcode/${barcode}`); // 用條碼查詢商品 // 呼叫後端 API
if (response.ok) { // 如果查詢成功
const product = await response.json(); // 解析商品資料 // JSON 格式
cart.addItem(product); // 自動加入購物車 // 掃一下就加入
console.log(`已加入:${product.name} $${product.price}`); // 顯示加入的商品
} else { // 查詢失敗
alert(`找不到條碼 ${barcode} 對應的商品`); // 顯示找不到的提示
}
}); // 掃描器建立完成
C# Print Agent 替代方案
💡 比喻:換一個說 C# 的翻譯官 如果你比較熟 C#,也可以用 C# 寫 Print Agent。 功能完全一樣,只是語言不同。 就像有些翻譯官說英文,有些說日文, 但都能把你的話翻譯給印表機聽。
// C# Print Agent 使用 WebSocket // ASP.NET Core 最小 API
// 需要 NuGet:dotnet add package System.IO.Ports // 串列埠套件
// 定義 C# Print Agent 的類別 // 替代 Node.js 方案
public class CSharpPrintAgent // C# Print Agent 類別
{
private const string PrinterPort = "/dev/usb/lp0"; // 印表機裝置路徑 // Linux USB 印表機
// 啟動 Print Agent 的方法 // 建立 WebSocket 伺服器
public static void StartAgent() // 啟動方法
{
var builder = WebApplication.CreateBuilder(); // 建立 Web 應用程式建構器
var app = builder.Build(); // 建構應用程式
app.UseWebSockets(); // 啟用 WebSocket 支援 // 必須加這行
// 設定 WebSocket 端點 // 監聽 /ws 路徑
app.Map("/ws", async (HttpContext context) => // WebSocket 端點
{
if (!context.WebSockets.IsWebSocketRequest) // 檢查是否為 WebSocket 請求
{
context.Response.StatusCode = 400; // 不是 WebSocket 就回 400 // 錯誤的請求
return; // 結束處理
}
var ws = await context.WebSockets.AcceptWebSocketAsync(); // 接受 WebSocket 連線
Console.WriteLine("Web 端已透過 WebSocket 連線"); // 顯示連線訊息
var buffer = new byte[1024 * 4]; // 接收緩衝區 // 4KB
while (ws.State == System.Net.WebSockets.WebSocketState.Open) // 持續接收訊息
{
var result = await ws.ReceiveAsync(buffer, CancellationToken.None); // 接收訊息
if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) // 如果是關閉訊息
break; // 結束迴圈
var message = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count); // 解碼訊息
Console.WriteLine($"收到指令:{message}"); // 顯示收到的指令
// 處理列印指令 // 根據指令類型執行對應操作
var response = "{\"status\":\"ok\"}"; // 回應訊息 // JSON 格式
var responseBytes = System.Text.Encoding.UTF8.GetBytes(response); // 編碼回應
await ws.SendAsync(responseBytes, // 送出回應
System.Net.WebSockets.WebSocketMessageType.Text, // 文字類型
true, CancellationToken.None); // 送出完成
}
}); // WebSocket 端點結束
app.Run("http://0.0.0.0:9100"); // 啟動伺服器 // 監聽所有介面的 9100 埠
}
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:Print Agent 沒有處理斷線重連
// ❌ 錯誤:WebSocket 斷線後就不管了 // 印表機離線就再也印不了
const ws = new WebSocket('ws://localhost:9100'); // 建立連線 // 只建一次
ws.onclose = () => console.log('斷線了'); // 只印訊息 // 沒有重連邏輯
// 如果 Print Agent 重啟或網路中斷,就再也連不上了 // 店員只能重開整個系統
// ✅ 正確:斷線後自動重連 // 確保列印服務持續可用
function connectPrintAgent() { // 封裝連線函式 // 可重複呼叫
const ws = new WebSocket('ws://localhost:9100'); // 建立連線
ws.onopen = () => console.log('已連線到 Print Agent'); // 連線成功
ws.onclose = () => { // 斷線事件
console.log('Print Agent 斷線,3 秒後重連...'); // 顯示重連訊息
setTimeout(connectPrintAgent, 3000); // 3 秒後重新連線 // 遞迴呼叫
}; // 斷線事件結束
return ws; // 回傳 WebSocket 實例
}
❌ 錯誤 2:ESC/POS 中文編碼錯誤
// ❌ 錯誤:用 ASCII 編碼送中文 // 中文會變成亂碼
var bytes = System.Text.Encoding.ASCII.GetBytes("美式咖啡"); // ASCII 不支援中文 // 會變成 ????
// 印出來的收據全是問號或亂碼 // 因為 ASCII 只有英文字母
// ✅ 正確:依照印表機支援的編碼 // 通常是 Big5 或 UTF-8
// 如果印表機支援 UTF-8 // 先送 UTF-8 模式指令
var utf8Bytes = System.Text.Encoding.UTF8.GetBytes("美式咖啡"); // UTF-8 編碼 // 支援中文
// 如果印表機只支援 Big5 // 要用 Big5 編碼
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); // 註冊 Big5 編碼支援
var big5Bytes = System.Text.Encoding.GetEncoding("big5").GetBytes("美式咖啡"); // Big5 編碼 // 繁體中文
❌ 錯誤 3:條碼掃描器把輸入當成普通鍵盤
// ❌ 錯誤:沒有區分掃描器和鍵盤輸入 // 店員打字也會被當成條碼
document.addEventListener('keydown', (e) => { // 監聽所有鍵盤輸入
buffer += e.key; // 全部加入緩衝區 // 不管是掃描器還是鍵盤
if (e.key === 'Enter') { // 按 Enter 就查詢
searchProduct(buffer); // 當成條碼查詢 // 如果店員按 Enter 也會觸發
buffer = ''; // 清空
}
}); // 這樣店員在搜尋框打字再按 Enter 也會被當成掃描
// ✅ 正確:用時間間隔區分 // 掃描器的按鍵速度遠比人快
document.addEventListener('keydown', (e) => { // 監聽鍵盤輸入
const now = Date.now(); // 取得當前時間
if (now - lastKeyTime > 150) buffer = ''; // 間隔超過 150ms 就清空 // 人打字速度較慢
if (e.key === 'Enter' && buffer.length >= 4) { // 按 Enter 且長度足夠
searchProduct(buffer); // 才當成條碼處理
buffer = ''; // 清空
} else if (e.key.length === 1) { // 可打印字元
buffer += e.key; // 加入緩衝區
}
lastKeyTime = now; // 更新時間
}); // 這樣可以正確區分掃描器和人工輸入