核心思路都是:需要一個安裝在用戶電腦上的“中間人”程序(本地客戶端)來接管打印任務,然后通過某種通信方式命令這個客戶端進行打印。
下面我將分平臺詳細闡述各種實現思路、優缺點和適用場景。
一、核心思路與公共組件:本地客戶端
無論哪種方式,都需要一個部署在用戶打印電腦上的本地程序。這個程序的核心職責是:
監聽來自網絡的打印命令。
獲取打印數據和參數(如份數、雙面打印等)。
調用系統打印接口,完成實際打印。
這個本地客戶端通常可以用以下技術開發:
Electron (Node.js, 跨平臺)
二、各平臺調用方案
這是最主流和推薦的方案WebSocket,適用性最廣,尤其是對于瀏覽器環境。
工作原理:
注冊與連接:本地客戶端啟動后,向一個已知的服務器(或直接在本地)建立一個WebSocket連接或開始HTTP長輪詢,并告知服務器“我在這臺電腦上,準備好接收打印任務了”。通常需要客戶端上報一個唯一標識(如MAC地址、登錄用戶名等)。
發送打印任務:APP、網頁或小程序將打印數據(JSON、HTML、PDF文件流等)和打印機參數通過API發送到業務服務器。
服務器轉發:業務服務器根據一定的路由規則(如:用戶A的打印任務要發到他指定的電腦B),通過WebSocket或HTTP將任務推送給正在監聽的目標客戶端。
客戶端執行打印:目標本地客戶端收到任務后,解析數據,調用本地打印機驅動完成打印。
優點:
跨平臺兼容:對APP、網頁、小程序一視同仁,它們只與業務服務器交互,無需關心客戶端具體實現。
穿透性強:只要能上網,無論APP/網頁/小程序在哪里,都能將任務發送到指定地點的打印機。
集中管理:方便在服務端做任務隊列、日志記錄、權限控制等。
缺點:
依賴網絡:必須保證本地客戶端和業務服務器的網絡連通性。
架構復雜:需要額外開發和維護一個業務服務器作為中轉。
適用場景:
企業級應用、ERP、SaaS系統。
需要遠程打印或打印任務需要集中管理的場景。
方案二:自定義URL協議 (PC端網頁常用)
工作原理:
注冊協議:在安裝本地客戶端時,在系統注冊一個自定義URL協議(例如:diygwprint://)。
網頁觸發:在網頁中通過JavaScript代碼觸發這個鏈接(如:window.location.href = 'diygwprint://print?data=...')。
客戶端響應:系統會喚起注冊了該協議的本地客戶端,并將URL中的參數傳遞給它。
客戶端處理:客戶端解析URL參數(如base64編碼的打印數據),執行打印。
優點:
簡單直接:對于本地環境,實現起來非常快速。
無中間服務器:無需業務服務器中轉,延遲低。
缺點:
僅限PC瀏覽器:APP和小程序無法直接使用此方式。
數據量限制:URL長度有限制,不適合傳輸大量數據(如圖片、復雜的HTML)。
安全性:需要防范惡意網站隨意調用。
體驗問題:瀏覽器通常會彈出“是否允許打開此應用”的提示,體驗不完美。
適用場景:
簡單的PC端網頁調用本地客戶端場景,傳輸的數據量較小。
作為WebSocket方案的補充或備選方案。
四、打印數據格式建議
傳遞給本地客戶端的數據最好結構化且通用:
JSON + 模板:發送JSON數據和模板名稱,客戶端根據模板渲染后打印。靈活且數據量小。
HTML:直接發送HTML字符串,客戶端使用內置瀏覽器控件(如C#的WebBrowser)打印。開發簡單,但樣式控制可能不一致。
PDF:服務器端或前端生成PDF文件流/URL,客戶端下載并打印。效果最精確,跨平臺一致性最好,強烈推薦。
五、實戰流程示例 (以最推薦的WebSocket方案為例)
開發本地客戶端:
用Electron寫一個Windows程序。
集成WebSocket客戶端庫,連接至業務服務器的WebSocket服務。
實現登錄認證、心跳保持、接收打印指令({command: ‘print’, data: {...}, printer: ‘...’})。
接收到指令后,解析數據,調用System.Drawing.Printing命名空間下的類進行打印。
開發業務服務器:
提供WebSocket服務端。
提供RESTful API供APP/網頁/小程序提交打印任務。
實現任務路由和轉發邏輯。
const { ipcRenderer } = require('electron');class ElectronHistoryManager {constructor() {this.currentTab = 'history';this.currentPage = 1;this.pageSize = 20;this.totalPages = 1;this.allHistory = [];this.allQueue = [];this.filteredData = [];this.filters = {status: '',date: '',printer: '',search: ''};this.init();}async init() {await this.loadData();this.setupEventListeners();this.renderData();this.updateStats();}setupEventListeners() {// 搜索輸入框事件document.getElementById('searchInput').addEventListener('input', (e) => {this.filters.search = e.target.value;this.applyFilters();});// 篩選器事件document.getElementById('statusFilter').addEventListener('change', (e) => {this.filters.status = e.target.value;this.applyFilters();});document.getElementById('dateFilter').addEventListener('change', (e) => {this.filters.date = e.target.value;this.applyFilters();});document.getElementById('printerFilter').addEventListener('change', (e) => {this.filters.printer = e.target.value;this.applyFilters();});// 模態框點擊外部關閉document.getElementById('contentModal').addEventListener('click', (e) => {if (e.target.id === 'contentModal') {this.closeModal();}});}async loadData() {try {const result = await ipcRenderer.invoke('get-print-history');if (result.success) {this.allHistory = result.history || [];this.allQueue = result.queue || [];this.updatePrinterFilter();} else {console.error('獲取打印歷史失敗:', result.error);this.showError('獲取打印歷史失敗: ' + result.error);}} catch (error) {console.error('加載數據失敗:', error);this.showError('加載數據失敗: ' + error.message);}}updatePrinterFilter() {const printerSelect = document.getElementById('printerFilter');const allData = [...this.allHistory, ...this.allQueue];const printers = [...new Set(allData.map(job => job.printerName).filter(Boolean))];// 清空現有選項(保留"全部打印機")printerSelect.innerHTML = '<option value="">全部打印機</option>';// 添加打印機選項printers.forEach(printer => {const option = document.createElement('option');option.value = printer;option.textContent = printer;printerSelect.appendChild(option);});}updateStats() {const totalJobs = this.allHistory.length;const completedJobs = this.allHistory.filter(job => job.status === 'completed').length;const failedJobs = this.allHistory.filter(job => job.status === 'failed').length;const queueJobs = this.allQueue.length;document.getElementById('totalJobs').textContent = totalJobs;document.getElementById('completedJobs').textContent = completedJobs;document.getElementById('failedJobs').textContent = failedJobs;document.getElementById('queueJobs').textContent = queueJobs;}switchTab(tab) {this.currentTab = tab;this.currentPage = 1;// 更新標簽樣式document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));event.target.classList.add('active');// 顯示對應內容document.getElementById('historyTab').style.display = tab === 'history' ? 'block' : 'none';document.getElementById('queueTab').style.display = tab === 'queue' ? 'block' : 'none';this.applyFilters();}applyFilters() {const sourceData = this.currentTab === 'history' ? this.allHistory : this.allQueue;this.filteredData = sourceData.filter(job => {// 文本搜索if (this.filters.search) {const searchTerm = this.filters.search.toLowerCase();const searchableText = [job.id || '',job.printerName || '',job.content || '',job.userId || '',job.status || ''].join(' ').toLowerCase();if (!searchableText.includes(searchTerm)) {return false;}}// 狀態篩選if (this.filters.status && job.status !== this.filters.status) {return false;}// 日期篩選if (this.filters.date) {const jobDate = new Date(job.createdAt).toISOString().split('T')[0];if (jobDate !== this.filters.date) {return false;}}// 打印機篩選if (this.filters.printer && job.printerName !== this.filters.printer) {return false;}return true;});this.currentPage = 1;this.calculatePagination();this.renderData();}calculatePagination() {this.totalPages = Math.ceil(this.filteredData.length / this.pageSize);if (this.totalPages === 0) this.totalPages = 1;}renderData() {const loadingState = document.getElementById('loadingState');const emptyState = document.getElementById('emptyState');const pagination = document.getElementById('pagination');// 隱藏加載狀態loadingState.style.display = 'none';if (this.filteredData.length === 0) {emptyState.style.display = 'block';pagination.style.display = 'none';return;}emptyState.style.display = 'none';// 計算當前頁的數據const startIndex = (this.currentPage - 1) * this.pageSize;const endIndex = startIndex + this.pageSize;const pageData = this.filteredData.slice(startIndex, endIndex);// 渲染表格const tbody = this.currentTab === 'history' ? document.getElementById('historyTableBody') : document.getElementById('queueTableBody');tbody.innerHTML = '';pageData.forEach(job => {const row = this.createDataRow(job);tbody.appendChild(row);});// 更新分頁this.updatePagination();pagination.style.display = 'flex';}createDataRow(job) {const row = document.createElement('tr');const formatDate = (dateString) => {const date = new Date(dateString);return date.toLocaleString('zh-CN');};const getStatusClass = (status) => {const statusMap = {'success': 'status-completed','completed': 'status-completed','error': 'status-failed','failed': 'status-failed','pending': 'status-pending','queued': 'status-pending','printing': 'status-printing','cancelled': 'status-cancelled'};return statusMap[status] || 'status-pending';};const getStatusText = (status) => {const statusMap = {'success': '已完成','completed': '已完成','error': '失敗','failed': '失敗','pending': '等待中','queued': '已加入隊列','printing': '打印中','cancelled': '已取消'};return statusMap[status] || status;};if (this.currentTab === 'history') {row.innerHTML = `<td>${job.id}</td><td>${formatDate(job.createdAt)}</td><td>${job.printerName || '-'}</td><td><div class="content-preview" onclick="showContentDetail('${job.id}')" title="點擊查看完整內容">${job.content ? job.content.substring(0, 50) + (job.content.length > 50 ? '...' : '') : '-'}</div></td><td><span class="status ${getStatusClass(job.status)}">${getStatusText(job.status)}</span>${job.error ? `<br><small style="color: #dc3545;">${job.error}</small>` : ''}</td><td>${job.copies || 1}</td><td>${job.userId || '-'}</td><td><div class="actions">${(job.status === 'completed' || job.status === 'success' || job.status === 'failed' || job.status === 'error' || job.status === 'cancelled') ? `<button class="btn btn-success btn-sm" onclick="reprintJob('${job.id}')">重打</button>` : ''}</div></td>`;} else {row.innerHTML = `<td>${job.id}</td><td>${formatDate(job.createdAt)}</td><td>${job.printerName || '-'}</td><td><div class="content-preview" onclick="showContentDetail('${job.id}')" title="點擊查看完整內容">${job.content ? job.content.substring(0, 50) + (job.content.length > 50 ? '...' : '') : '-'}</div></td><td><span class="status ${getStatusClass(job.status)}">${getStatusText(job.status)}</span></td><td>${job.copies || 1}</td><td>${job.retryCount || 0}</td><td><div class="actions">${(job.status === 'pending' || job.status === 'queued' || job.status === 'printing') ? `<button class="btn btn-danger btn-sm" onclick="cancelJob('${job.id}')">取消</button>` : ''}</div></td>`;}return row;}updatePagination() {const pageInfo = document.getElementById('pageInfo');pageInfo.textContent = `第 ${this.currentPage} 頁,共 ${this.totalPages} 頁`;// 更新按鈕狀態const prevBtn = document.querySelector('.pagination button:first-child');const nextBtn = document.querySelector('.pagination button:last-child');prevBtn.disabled = this.currentPage === 1;nextBtn.disabled = this.currentPage === this.totalPages;}previousPage() {if (this.currentPage > 1) {this.currentPage--;this.renderData();}}nextPage() {if (this.currentPage < this.totalPages) {this.currentPage++;this.renderData();}}showContentDetail(jobId) {const allData = [...this.allHistory, ...this.allQueue];const job = allData.find(j => j.id === jobId);if (job && job.content) {document.getElementById('contentDetail').textContent = job.content;document.getElementById('contentModal').style.display = 'block';}}closeModal() {document.getElementById('contentModal').style.display = 'none';}async reprintJob(jobId) {const job = this.allHistory.find(j => j.id === jobId);if (!job) {this.showError('找不到指定的打印任務');return;}if (confirm(`確定要重新打印任務 ${jobId} 嗎?`)) {try {const result = await ipcRenderer.invoke('reprint-job', {content: job.content,printerName: job.printerName,copies: job.copies,userId: job.userId,clientId: job.clientId});if (result.success) {this.showSuccess('重打任務已提交');await this.refreshData();} else {this.showError('重打任務提交失敗: ' + result.error);}} catch (error) {console.error('重打任務失敗:', error);this.showError('重打任務失敗: ' + error.message);}}}async cancelJob(jobId) {if (confirm(`確定要取消打印任務 ${jobId} 嗎?`)) {try {const result = await ipcRenderer.invoke('cancel-job', jobId);if (result.success) {this.showSuccess('打印任務已取消');await this.refreshData();} else {this.showError('取消打印任務失敗: ' + result.error);}} catch (error) {console.error('取消打印任務失敗:', error);this.showError('取消打印任務失敗: ' + error.message);}}}async clearHistory() {if (confirm('確定要清除所有歷史記錄嗎?此操作不可恢復。')) {try {const result = await ipcRenderer.invoke('clear-history');if (result.success) {this.showSuccess('歷史記錄已清除');await this.refreshData();} else {this.showError('清除歷史記錄失敗: ' + result.error);}} catch (error) {console.error('清除歷史記錄失敗:', error);this.showError('清除歷史記錄失敗: ' + error.message);}}}clearFilters() {this.filters = {status: '',date: '',printer: '',search: ''};document.getElementById('searchInput').value = '';document.getElementById('statusFilter').value = '';document.getElementById('dateFilter').value = '';document.getElementById('printerFilter').value = '';this.applyFilters();}async refreshData() {document.getElementById('loadingState').style.display = 'block';document.getElementById('historyTab').style.display = 'none';document.getElementById('queueTab').style.display = 'none';document.getElementById('emptyState').style.display = 'none';await this.loadData();this.applyFilters();this.updateStats();// 恢復標簽顯示if (this.currentTab === 'history') {document.getElementById('historyTab').style.display = 'block';} else {document.getElementById('queueTab').style.display = 'block';}}showSuccess(message) {// 簡單的成功提示const toast = document.createElement('div');toast.style.cssText = `position: fixed;top: 20px;right: 20px;background: #28a745;color: white;padding: 15px 20px;border-radius: 4px;z-index: 10000;box-shadow: 0 2px 10px rgba(0,0,0,0.2);`;toast.textContent = message;document.body.appendChild(toast);setTimeout(() => {document.body.removeChild(toast);}, 3000);}showError(message) {// 簡單的錯誤提示const toast = document.createElement('div');toast.style.cssText = `position: fixed;top: 20px;right: 20px;background: #dc3545;color: white;padding: 15px 20px;border-radius: 4px;z-index: 10000;box-shadow: 0 2px 10px rgba(0,0,0,0.2);`;toast.textContent = message;document.body.appendChild(toast);setTimeout(() => {document.body.removeChild(toast);}, 5000);}
}// 全局函數
function switchTab(tab) {if (window.historyManager) {window.historyManager.switchTab(tab);}
}function applyFilters() {if (window.historyManager) {window.historyManager.applyFilters();}
}function clearFilters() {if (window.historyManager) {window.historyManager.clearFilters();}
}function refreshData() {if (window.historyManager) {window.historyManager.refreshData();}
}function clearHistory() {if (window.historyManager) {window.historyManager.clearHistory();}
}function previousPage() {if (window.historyManager) {window.historyManager.previousPage();}
}function nextPage() {if (window.historyManager) {window.historyManager.nextPage();}
}function reprintJob(jobId) {if (window.historyManager) {window.historyManager.reprintJob(jobId);}
}function cancelJob(jobId) {if (window.historyManager) {window.historyManager.cancelJob(jobId);}
}function showContentDetail(jobId) {if (window.historyManager) {window.historyManager.showContentDetail(jobId);}
}function closeModal() {if (window.historyManager) {window.historyManager.closeModal();}
}// 標題欄控制功能
let isMaximized = false;function minimizeWindow() {ipcRenderer.send('history-window-minimize');
}function toggleMaximize() {ipcRenderer.send('history-window-toggle-maximize');
}function closeWindow() {ipcRenderer.send('history-window-close');
}// 監聽窗口狀態變化
ipcRenderer.on('window-maximized', () => {isMaximized = true;updateTitlebarDrag();
});ipcRenderer.on('window-unmaximized', () => {isMaximized = false;updateTitlebarDrag();
});// 更新標題欄拖動狀態
function updateTitlebarDrag() {const titlebar = document.querySelector('.custom-titlebar');if (titlebar) {titlebar.style.webkitAppRegion = isMaximized ? 'no-drag' : 'drag';}
}// 創建全局實例
document.addEventListener('DOMContentLoaded', () => {window.historyManager = new ElectronHistoryManager();// 設置標題欄雙擊事件const titlebar = document.querySelector('.custom-titlebar');if (titlebar) {titlebar.addEventListener('dblclick', (e) => {// 排除控制按鈕區域if (!e.target.closest('.titlebar-controls')) {toggleMaximize();}});}
});