(function () { "use strict"; if (!Element.prototype.matches) { Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector; } if (!Element.prototype.closest) { Element.prototype.closest = function (selector) { var node = this; while (node && node.nodeType === 1) { if (node.matches(selector)) return node; node = node.parentElement || node.parentNode; } return null; }; } if (!String.prototype.repeat) { String.prototype.repeat = function (count) { var value = String(this); var output = ""; var i; count = Number(count) || 0; for (i = 0; i < count; i += 1) output += value; return output; }; } var STORAGE_KEY = "xingyuan-zhiling-app:v1"; var DEFAULT_REPORT_IP = "112.74.41.228"; var LEGACY_REPORT_IP = ["218", "59", "175", "31"].join("."); var DEFAULT_REPORT_PORT = 9956; var DEFAULT_HTTP_BASE = "http://www.kinglive.com.cn/GreenLive-APP/"; var DEFAULT_MEMBER_ID = "437306"; var DEFAULT_PACKAGE_TYPE = "FinleyHsu"; var STATUS_REPORT_INTERVAL_MS = 10 * 60 * 1000; var WEIGHT_SETTLE_DELAY_MS = 500; var WEIGHT_SAMPLE_INTERVAL_MS = 300; var WEIGHT_SAMPLE_COUNT = 3; var BASELINE_WEIGHT_TIMEOUT_MS = 1500; var DELAY_CLOSE_SECONDS = 120; var WORK_INFO_TIMEOUT_MS = 15 * 1000; var STARTUP_CHECK_MAX_ATTEMPTS = 3; var STARTUP_RESULT_HOLD_MS = 3000; var MAX_PORT_COUNT = 4; var RANGE_FULL_CONFIRM_CM = 65; var RANGE_NORMAL_CONFIRM_CM = 75; var RANGE_FULL_CONFIRM_COUNT = 3; var RANGE_NORMAL_CONFIRM_COUNT = 2; var GARBAGE_TYPES = { tinCopper: { code: "1", zh: "镀锡铜", tw: "鍍錫銅", en: "Tinned copper" }, aluminumAlloy: { code: "2", zh: "铝合金", tw: "鋁合金", en: "Aluminum alloy" }, scrapCopper: { code: "3", zh: "废铜", tw: "廢銅", en: "Scrap copper" }, scrapWire: { code: "4", zh: "废电线", tw: "廢電線", en: "Scrap wire" }, scrapAluminum: { code: "5", zh: "废铝", tw: "廢鋁", en: "Scrap aluminum" }, scrapTin: { code: "6", zh: "废锡", tw: "廢錫", en: "Scrap tin" }, custom: { code: "1", zh: "自定义", tw: "自定義", en: "Custom" } }; var I18N = { "zh-CN": { appName: "星元智灵", subtitle: "智能投放终端", homePrompt: "请点击下方按钮开始投放", homeHint: "请选择本次投放的机台号、人员和生产批次", admin: "管理员登录", login: "开始投放", available: "设备可用", unavailable: "设备暂不可用", serviceDown: "服务已断开", maintenance: "设备维护中", full: "设备已装满", choosePort: "请选择投放口", openAllPorts: "打开全部投口", allPorts: "全部投口", allPortsBlocked: "存在异常投口,无法打开全部投口", allPortsOpened: "已打开全部投口", openAllPortsFailed: "全部投口开门失败,请检查连接后重试", delivery: "请投递物品", closeDoor: "关闭投放门", success: "投递成功", backHome: "返回首页", settingsTitle: "系统设置", exitSettings: "退出设置", tabDevice: "设备", tabPorts: "投口", tabCache: "缓存", tabFace: "人脸", tabSystem: "系统", deviceParams: "设备参数", deviceNo: "设备编号", terminalImei: "终端号/IMEI", serialPath: "串口路径", baudRate: "波特率", protocol: "协议", deviceCheck: "设备检测", networkTest: "网络测试", getLowerInfo: "获取下位机信息", restartSelfCheck: "重新启动自检", firmwareVersion: "固件版本号", lowerImei: "下位机IMEI号", maintenanceMode: "维护模式", maintenanceOnDesc: "维护模式已启用:投放和鉴权功能暂停,投放门保持打开。", maintenanceOffDesc: "维护模式未启用,设备按正常流程运行。", closeMaintenance: "关闭维护模式", enableMaintenance: "启用维护模式", deviceDisabledTitle: "设备禁用", deviceDisabledOnDesc: "设备已禁用:首页显示设备维护中,登陆和投放不可用,仅允许打开清运门。", deviceDisabledOffDesc: "设备禁用未启用,设备按正常流程运行。", closeDeviceDisabled: "关闭设备禁用", enableDeviceDisabled: "启用设备禁用", openCollectionDoor: "打开清运门", portCount: "投口数量", enabledPorts: "启用投口", portUnit: "投口", portUnitPlural: "投口", portTypeStatus: "投口类型与状态", portSuffix: "号投口", portWord: "投口", portDisabledSuffix: "(未启用)", garbageType: "垃圾类型", customName: "自定义名称", customTypeId: "自定义 type id", currentWeightKg: "当前重量", sensorSensitivityCm: "传感器感度 cm", temperatureC: "温度 °C", overflow: "满溢", overflowHelp: "满溢后对应投口不可选择", status: "状态", normal: "正常", save: "保存", localBackupMode: "本地备份模式", localBackupOnDesc: "本地备份已启用:投放完成后会写入 smart/cache 文件夹内的 CSV/JSON 备份文件。", localBackupOffDesc: "本地备份未启用:投放数据仅按当前上报/缓存流程处理。", localBackupClose: "本地备份:关闭", localBackupOpen: "本地备份:启用", localBackupStorageHint: "优先保存机身/外部存储 smart/cache 文件夹。", backupPath: "备份路径", generatedAfterEnable: "启用后自动生成", localRecord: "本地记录", recordUnit: "条", recordUnitPlural: "条", cacheRules: "离线缓存规则", retainDays: "保留天数", retryIntervalHours: "重试间隔小时", retryNow: "立即补传", cleanupExpired: "清理过期缓存", pendingQueue: "待传队列", noPendingData: "当前没有待传数据。", languageSystem: "语言与系统", language: "语言", systemTools: "系统工具", changePassword: "修改登录密码", debugLog: "查看日志", exportLogs: "导出日志", logExported: "日志已导出", logExportFailed: "日志导出失败", enabled: "启用", disabled: "禁用", openSystemSettings: "系统设置", openFileManager: "文件管理", passwordCurrentPrompt: "请输入原登录密码", passwordNextPrompt: "请输入新登录密码", passwordConfirmPrompt: "请再次输入新登录密码", savePassword: "保存密码", nextStep: "下一步", cancel: "取消", clear: "清空", backspace: "退格", pass: "通过", fail: "失败", customEditable: "自定义 / 可编辑", reading: "读取中...", readFailed: "获取失败", notEntered: "未输入", password: "密码", adminLoginDesc: "30 秒超时或输错超过 5 次返回首页。", adminTimeout: "管理员登录超时", passwordWrong: "密码错误", adminTooManyErrors: "密码错误超过 5 次", passwordPlaceholder: "请输入管理员密码", showPassword: "显示密码", hidePassword: "隐藏密码", remainingTime: "剩余时间", errorCount: "错误次数", secondUnit: "秒", submitLogin: "登录", networkUnavailableQueue: "网络或服务器不可用,补传继续排队", cacheRetryEmpty: "没有待补传数据", cacheRetryPrefix: "已补传 ", cacheRetrySuffix: " 条缓存", cacheRetryRemainPrefix: ",", cacheRetryRemainSuffix: " 条继续排队", cacheRetryFail: "补传失败,数据继续排队", cacheCleanupPrefix: "已清理 ", cacheCleanupSuffix: " 条过期缓存", localBackupEnabledToast: "本地备份已启用", localBackupDisabledToast: "本地备份已关闭", maintenanceEnabledToast: "维护模式已启用", maintenanceDisabledToast: "维护模式已关闭", deviceDisabledEnabledToast: "设备已禁用", deviceDisabledDisabledToast: "设备已恢复正常", debugLogEnabledToast: "查看日志已启用", debugLogDisabledToast: "查看日志已禁用", portSettingsSaved: "投口设置已保存", passwordCurrentWrong: "原密码错误", passwordRule: "新密码需为 4-12 位数字", passwordMismatch: "两次输入不一致", passwordChanged: "登录密码已修改", networkState: "网络状态", tcpReportParams: "TCP 上报参数", networkTestOk: "网络测试通过", networkTestFail: "网络测试失败", readingLowerVersion: "正在读取下位机版本", lowerVersionMissing: "未读取到下位机版本", lowerVersionRequestFail: "下位机版本请求失败", lowerVersionReceived: "已获取下位机版本", readingLowerImei: "正在读取下位机IMEI", lowerImeiMissing: "未读取到下位机IMEI", lowerImeiRequestFail: "下位机IMEI请求失败", lowerImeiReceived: "已获取下位机IMEI", lowerInfoReceived: "已获取下位机信息", readingLowerInfo: "正在读取下位机信息", lowerInfoTimeout: "下位机信息读取超时", enableDeviceDisabledFirst: "请先启用设备禁用", collectionDoorCommandFail: "清运门开启指令发送失败", collectionDoorCommandSent: "已发送清运门开启指令" }, "zh-TW": { appName: "星元智靈", subtitle: "智慧投放終端", homePrompt: "請點擊下方按鈕開始投放", homeHint: "請選擇本次投放的機台號、人員和生產批次", admin: "管理員登入", login: "開始投放", available: "設備可用", unavailable: "設備暫不可用", serviceDown: "服務已斷開", maintenance: "設備維護中", full: "設備已裝滿", choosePort: "請選擇投放口", openAllPorts: "打開全部投口", allPorts: "全部投口", allPortsBlocked: "存在異常投口,無法打開全部投口", allPortsOpened: "已打開全部投口", openAllPortsFailed: "全部投口開門失敗,請檢查連線後重試", delivery: "請投遞物品", closeDoor: "關閉投放門", success: "投遞成功", backHome: "返回首頁", settingsTitle: "系統設定", exitSettings: "退出設定", tabDevice: "設備", tabPorts: "投口", tabCache: "快取", tabFace: "人臉", tabSystem: "系統", deviceParams: "設備參數", deviceNo: "設備編號", terminalImei: "終端號/IMEI", serialPath: "串口路徑", baudRate: "鮑率", protocol: "協議", deviceCheck: "設備檢測", networkTest: "網路測試", getLowerInfo: "取得下位機資訊", restartSelfCheck: "重新啟動自檢", firmwareVersion: "韌體版本號", lowerImei: "下位機IMEI號", maintenanceMode: "維護模式", maintenanceOnDesc: "維護模式已啟用:投放和鑑權功能暫停,投放門保持打開。", maintenanceOffDesc: "維護模式未啟用,設備按正常流程運行。", closeMaintenance: "關閉維護模式", enableMaintenance: "啟用維護模式", deviceDisabledTitle: "設備停用", deviceDisabledOnDesc: "設備已停用:首頁顯示設備維護中,登入和投放不可用,僅允許打開清運門。", deviceDisabledOffDesc: "設備停用未啟用,設備按正常流程運行。", closeDeviceDisabled: "關閉設備停用", enableDeviceDisabled: "啟用設備停用", openCollectionDoor: "打開清運門", portCount: "投口數量", enabledPorts: "啟用投口", portUnit: "投口", portUnitPlural: "投口", portTypeStatus: "投口類型與狀態", portSuffix: "號投口", portWord: "投口", portDisabledSuffix: "(未啟用)", garbageType: "垃圾類型", customName: "自訂名稱", customTypeId: "自訂 type id", currentWeightKg: "目前重量", sensorSensitivityCm: "感測器感度 cm", temperatureC: "溫度 °C", overflow: "滿溢", overflowHelp: "滿溢後對應投口不可選擇", status: "狀態", normal: "正常", save: "儲存", localBackupMode: "本地備份模式", localBackupOnDesc: "本地備份已啟用:投放完成後會寫入 smart/cache 資料夾內的 CSV/JSON 備份檔。", localBackupOffDesc: "本地備份未啟用:投放資料僅按目前上報/快取流程處理。", localBackupClose: "本地備份:關閉", localBackupOpen: "本地備份:啟用", localBackupStorageHint: "優先儲存機身/外部儲存 smart/cache 資料夾。", backupPath: "備份路徑", generatedAfterEnable: "啟用後自動生成", localRecord: "本地記錄", recordUnit: "條", recordUnitPlural: "條", cacheRules: "離線快取規則", retainDays: "保留天數", retryIntervalHours: "重試間隔小時", retryNow: "立即補傳", cleanupExpired: "清理過期快取", pendingQueue: "待傳佇列", noPendingData: "目前沒有待傳資料。", languageSystem: "語言與系統", language: "語言", systemTools: "系統工具", changePassword: "修改登入密碼", debugLog: "查看日誌", exportLogs: "匯出日誌", logExported: "日誌已匯出", logExportFailed: "日誌匯出失敗", enabled: "啟用", disabled: "停用", openSystemSettings: "系統設定", openFileManager: "檔案管理", passwordCurrentPrompt: "請輸入原登入密碼", passwordNextPrompt: "請輸入新登入密碼", passwordConfirmPrompt: "請再次輸入新登入密碼", savePassword: "儲存密碼", nextStep: "下一步", cancel: "取消", clear: "清空", backspace: "退格", pass: "通過", fail: "失敗", customEditable: "自訂 / 可編輯", reading: "讀取中...", readFailed: "取得失敗", notEntered: "未輸入", password: "密碼", adminLoginDesc: "30 秒逾時或輸錯超過 5 次返回首頁。", adminTimeout: "管理員登入逾時", passwordWrong: "密碼錯誤", adminTooManyErrors: "密碼錯誤超過 5 次", passwordPlaceholder: "請輸入管理員密碼", showPassword: "顯示密碼", hidePassword: "隱藏密碼", remainingTime: "剩餘時間", errorCount: "錯誤次數", secondUnit: "秒", submitLogin: "登入", networkUnavailableQueue: "網路或伺服器不可用,補傳繼續排隊", cacheRetryEmpty: "沒有待補傳資料", cacheRetryPrefix: "已補傳 ", cacheRetrySuffix: " 條快取", cacheRetryRemainPrefix: ",", cacheRetryRemainSuffix: " 條繼續排隊", cacheRetryFail: "補傳失敗,資料繼續排隊", cacheCleanupPrefix: "已清理 ", cacheCleanupSuffix: " 條過期快取", localBackupEnabledToast: "本地備份已啟用", localBackupDisabledToast: "本地備份已關閉", maintenanceEnabledToast: "維護模式已啟用", maintenanceDisabledToast: "維護模式已關閉", deviceDisabledEnabledToast: "設備已停用", deviceDisabledDisabledToast: "設備已恢復正常", debugLogEnabledToast: "查看日誌已啟用", debugLogDisabledToast: "查看日誌已停用", portSettingsSaved: "投口設定已儲存", passwordCurrentWrong: "原密碼錯誤", passwordRule: "新密碼需為 4-12 位數字", passwordMismatch: "兩次輸入不一致", passwordChanged: "登入密碼已修改", networkState: "網路狀態", tcpReportParams: "TCP 上報參數", networkTestOk: "網路測試通過", networkTestFail: "網路測試失敗", readingLowerVersion: "正在讀取下位機版本", lowerVersionMissing: "未讀取到下位機版本", lowerVersionRequestFail: "下位機版本請求失敗", lowerVersionReceived: "已取得下位機版本", readingLowerImei: "正在讀取下位機IMEI", lowerImeiMissing: "未讀取到下位機IMEI", lowerImeiRequestFail: "下位機IMEI請求失敗", lowerImeiReceived: "已取得下位機IMEI", lowerInfoReceived: "已取得下位機資訊", readingLowerInfo: "正在讀取下位機資訊", lowerInfoTimeout: "下位機資訊讀取逾時", enableDeviceDisabledFirst: "請先啟用設備停用", collectionDoorCommandFail: "清運門開啟指令發送失敗", collectionDoorCommandSent: "已發送清運門開啟指令" }, en: { appName: "Xingyuan Zhiling", subtitle: "Smart Delivery Terminal", homePrompt: "Tap the button below to start delivery", homeHint: "Choose the machine number, operator, and production batch", admin: "Admin Login", login: "Start Delivery", available: "Device ready", unavailable: "Device unavailable", serviceDown: "Service disconnected", maintenance: "Under maintenance", full: "Device is full", choosePort: "Choose a chute", openAllPorts: "Open All Chutes", allPorts: "All Chutes", allPortsBlocked: "Some chutes are unavailable. Cannot open all chutes.", allPortsOpened: "All chutes opened", openAllPortsFailed: "Failed to open all chutes. Check the connection and retry.", delivery: "Place recyclables", closeDoor: "Close door", success: "Completed", backHome: "Home", settingsTitle: "System Settings", exitSettings: "Exit Settings", tabDevice: "Device", tabPorts: "Chutes", tabCache: "Cache", tabFace: "Faces", tabSystem: "System", deviceParams: "Device Parameters", deviceNo: "Device No.", terminalImei: "Terminal No./IMEI", serialPath: "Serial Path", baudRate: "Baud Rate", protocol: "Protocol", deviceCheck: "Device Check", networkTest: "Network Test", getLowerInfo: "Get Controller Info", restartSelfCheck: "Restart Self-check", firmwareVersion: "Firmware Version", lowerImei: "Controller IMEI", maintenanceMode: "Maintenance Mode", maintenanceOnDesc: "Maintenance mode is enabled: delivery and authentication are paused, and delivery doors remain open.", maintenanceOffDesc: "Maintenance mode is disabled. The device is running normally.", closeMaintenance: "Disable Maintenance", enableMaintenance: "Enable Maintenance", deviceDisabledTitle: "Device Disabled", deviceDisabledOnDesc: "The device is disabled: the home screen shows maintenance mode, login and delivery are unavailable, and only the collection door can be opened.", deviceDisabledOffDesc: "Device disable mode is off. The device is running normally.", closeDeviceDisabled: "Enable Device", enableDeviceDisabled: "Disable Device", openCollectionDoor: "Open Collection Door", portCount: "Chute Count", enabledPorts: "Enabled Chutes", portUnit: "chute", portUnitPlural: "chutes", portTypeStatus: "Chute Type and Status", portSuffix: " chute", portWord: "Chute", portDisabledSuffix: "(Disabled)", garbageType: "Material Type", customName: "Custom Name", customTypeId: "Custom type id", currentWeightKg: "Current Weight", sensorSensitivityCm: "Sensor Sensitivity cm", temperatureC: "Temperature °C", overflow: "Overflow", overflowHelp: "This chute cannot be selected while overflow is on", status: "Status", normal: "Normal", save: "Save", localBackupMode: "Local Backup Mode", localBackupOnDesc: "Local backup is enabled: delivery records are written to CSV/JSON backup files in the smart/cache folder.", localBackupOffDesc: "Local backup is disabled: delivery data only follows the current upload/cache flow.", localBackupClose: "Local Backup: Off", localBackupOpen: "Local Backup: On", localBackupStorageHint: "Prefer the smart/cache folder on internal or external storage.", backupPath: "Backup Path", generatedAfterEnable: "Generated after enabling", localRecord: "Local Records", recordUnit: "record", recordUnitPlural: "records", cacheRules: "Offline Cache Rules", retainDays: "Retention Days", retryIntervalHours: "Retry Interval Hours", retryNow: "Retry Now", cleanupExpired: "Clean Expired Cache", pendingQueue: "Pending Queue", noPendingData: "There is no pending data.", languageSystem: "Language and System", language: "Language", systemTools: "System Tools", changePassword: "Change Login Password", debugLog: "Debug Log", exportLogs: "Export Logs", logExported: "Logs exported", logExportFailed: "Log export failed", enabled: "Enabled", disabled: "Disabled", openSystemSettings: "System Settings", openFileManager: "File Manager", passwordCurrentPrompt: "Enter current login password", passwordNextPrompt: "Enter new login password", passwordConfirmPrompt: "Enter new login password again", savePassword: "Save Password", nextStep: "Next", cancel: "Cancel", clear: "Clear", backspace: "Backspace", pass: "Pass", fail: "Fail", customEditable: "Custom / Editable", reading: "Reading...", readFailed: "Read failed", notEntered: "Not entered", password: "Password", adminLoginDesc: "Returns home after 30 seconds or more than 5 failed attempts.", adminTimeout: "Admin login timed out", passwordWrong: "Incorrect password", adminTooManyErrors: "More than 5 failed password attempts", passwordPlaceholder: "Enter admin password", showPassword: "Show", hidePassword: "Hide", remainingTime: "Time left", errorCount: "Errors", secondUnit: "s", submitLogin: "Login", networkUnavailableQueue: "Network or server unavailable. Upload remains queued.", cacheRetryEmpty: "No pending data to retry", cacheRetryPrefix: "Retried ", cacheRetrySuffix: " cached item(s)", cacheRetryRemainPrefix: ", ", cacheRetryRemainSuffix: " still queued", cacheRetryFail: "Retry failed; data remains queued", cacheCleanupPrefix: "Cleaned ", cacheCleanupSuffix: " expired cached item(s)", localBackupEnabledToast: "Local backup enabled", localBackupDisabledToast: "Local backup disabled", maintenanceEnabledToast: "Maintenance mode enabled", maintenanceDisabledToast: "Maintenance mode disabled", deviceDisabledEnabledToast: "Device disabled", deviceDisabledDisabledToast: "Device restored", debugLogEnabledToast: "Debug log enabled", debugLogDisabledToast: "Debug log disabled", portSettingsSaved: "Chute settings saved", passwordCurrentWrong: "Current password is incorrect", passwordRule: "New password must be 4-12 digits", passwordMismatch: "Passwords do not match", passwordChanged: "Login password changed", networkState: "Network Status", tcpReportParams: "TCP Report Parameters", networkTestOk: "Network test passed", networkTestFail: "Network test failed", readingLowerVersion: "Reading controller version", lowerVersionMissing: "Controller version not received", lowerVersionRequestFail: "Controller version request failed", lowerVersionReceived: "Controller version received", readingLowerImei: "Reading controller IMEI", lowerImeiMissing: "Controller IMEI not received", lowerImeiRequestFail: "Controller IMEI request failed", lowerImeiReceived: "Controller IMEI received", lowerInfoReceived: "Controller info received", readingLowerInfo: "Reading controller info", lowerInfoTimeout: "Controller info read timed out", enableDeviceDisabledFirst: "Enable device disable mode first", collectionDoorCommandFail: "Failed to send collection door open command", collectionDoorCommandSent: "Collection door open command sent" } }; var app = document.getElementById("app"); var toast = document.getElementById("toast"); var timers = { boot: null, delivery: null, success: null, authFail: null, admin: null, toast: null, queue: null, status: null, reader: null, workInfoTimeout: null, selectTimeout: null, baselineWeight: null, weightTimeout: null, weightSample: null, lowerImei: null, lowerVersion: null, lowerInfo: null, settingsTelemetry: null, faceSync: null }; var readerBuffer = ""; var readerLastAt = 0; var rangeStability = {}; var workContextRequestId = 0; var connectionCheckRequestId = 0; var facePreviewLayoutQueued = false; var state = { screen: "boot", activeTab: "device", config: defaultConfig(), deviceStatus: defaultDeviceStatus(), check: defaultCheck(), auth: { status: "idle", message: "" }, employeeLogin: { open: false, employeeNo: "", message: "" }, faceSync: { syncing: false, lastAt: 0, count: 0, message: "尚未同步" }, faceRecognition: { status: "starting", message: "正在启动人脸识别", enrolledCount: 0 }, faceEnrollment: { employeeNo: "", name: "", selectedFaceId: "", capturing: false, message: "" }, faceDirectory: [], facePeople: [], workInfo: defaultWorkInfo(), adminLogin: { tries: 0, secondsLeft: 30, message: "", value: "", showPassword: false }, passwordChange: { open: false, step: "current", current: "", next: "", confirm: "", message: "" }, session: null, delivery: null, offlineQueue: [], localBackups: [], logs: [], settingsDraft: null, portSettingsPort: 0, testResults: null, lowerVersion: null, lowerImei: null, serialPorts: { loaded: false, loading: false, items: [], message: "" } }; function defaultConfig() { return { language: "zh-CN", appVersion: "1.2.18-wal-stability-fix", adminPassword: "88888888", portCount: 2, debugLog: { enabled: false }, guest: { enabled: false }, localBackup: { enabled: true, path: "" }, device: { deviceNo: "", terminalId: "", serialPath: "/dev/ttyS4", baudRate: 9600, protocol: "680068xx", board: "RK3288" }, server: { tcpHost: DEFAULT_REPORT_IP, tcpPort: DEFAULT_REPORT_PORT, httpBase: DEFAULT_HTTP_BASE, upgradeEndpoint: "autoMachine/version.do", defaultMemberId: DEFAULT_MEMBER_ID, packageType: DEFAULT_PACKAGE_TYPE }, portTypes: { 1: "tinCopper", 2: "aluminumAlloy", 3: "tinCopper", 4: "aluminumAlloy" }, customPortTypes: { 1: { name: "自定义1", code: "1" }, 2: { name: "自定义2", code: "2" }, 3: { name: "自定义3", code: "1" }, 4: { name: "自定义4", code: "2" } }, cache: { retainDays: 30, retryIntervalHours: 1 }, face: { autoSyncHours: 4, matchThreshold: 0.75 } }; } function defaultDeviceStatus() { return { serialOnline: false, networkOnline: true, tcpConnected: true, httpReachable: true, maintenance: false, deviceDisabled: false, collectionDoorClosed: true, ports: { 1: { overflow: false, temperature: 26, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 }, 2: { overflow: false, temperature: 25, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 }, 3: { overflow: false, temperature: 27, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 }, 4: { overflow: false, temperature: 26, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 } } }; } function defaultCheck() { return { attempt: 0, serial: false, network: false, completed: false, available: false, skipped: false, running: false }; } function defaultWorkInfo() { return { loading: false, loaded: false, error: "", machineNo: "", person: "", batch: "", pickerField: "", options: [] }; } function deepMerge(base, incoming) { if (!incoming || typeof incoming !== "object") return base; Object.keys(incoming).forEach(function (key) { if ( incoming[key] && typeof incoming[key] === "object" && !Array.isArray(incoming[key]) && base[key] && typeof base[key] === "object" && !Array.isArray(base[key]) ) { deepMerge(base[key], incoming[key]); } else { base[key] = incoming[key]; } }); return base; } function cloneData(value) { return JSON.parse(JSON.stringify(value || {})); } function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function firstArrayValue(source, keys) { var i; if (Array.isArray(source)) return source; if (!isPlainObject(source)) return []; for (i = 0; i < keys.length; i += 1) { if (Array.isArray(source[keys[i]])) return source[keys[i]]; } return []; } function sanitizeQueueItem(item) { var output; var createdAt; if (!isPlainObject(item)) return null; output = cloneData(item); createdAt = Number(output.createdAt || Date.now()); if (!isFinite(createdAt)) createdAt = Date.now(); output.id = String(output.id || "Q" + createdAt); output.kind = String(output.kind || "delivery"); output.kindText = String(output.kindText || (output.kind === "delivery" ? "投递记录" : "缓存记录")); output.createdAt = createdAt; output.status = String(output.status || "queued"); output.summary = String(output.summary || output.kindText); if (!isPlainObject(output.payload)) output.payload = {}; return output; } function sanitizeOfflineQueue(items) { return (Array.isArray(items) ? items : []) .map(sanitizeQueueItem) .filter(function (item) { return Boolean(item); }) .slice(0, 500); } function defaultPortStatus(port) { var defaults = defaultDeviceStatus().ports; return cloneData(defaults[port] || defaults[1]); } function normalizeTemperatureValue(value, fallback) { var number = Number(value); if (!isFinite(number) || number <= 0 || number > 100) return fallback == null ? null : fallback; return Number(number.toFixed(1)); } function ensurePortStatus(port) { var info; var defaults = defaultPortStatus(port); if (!state.deviceStatus.ports || typeof state.deviceStatus.ports !== "object") { state.deviceStatus.ports = {}; } info = state.deviceStatus.ports[port]; if (!info || typeof info !== "object") info = {}; info = deepMerge(defaults, info); info.overflow = Boolean(info.overflow); info.infraredBlocked = Boolean(info.infraredBlocked); info.temperature = normalizeTemperatureValue(info.temperature, defaults.temperature); info.weight = isFinite(Number(info.weight)) ? Math.max(0, Number(info.weight)) : defaults.weight; info.weightKnown = Boolean(info.weightKnown); if (!info.weightKnown) info.weight = 0; info.range = normalizeRangeValue(info.range) == null ? defaults.range : normalizeRangeValue(info.range); state.deviceStatus.ports[port] = info; return info; } function createSettingsDraft() { return { config: { portCount: state.config.portCount, portTypes: cloneData(state.config.portTypes), customPortTypes: cloneData(state.config.customPortTypes), server: cloneData(state.config.server) }, deviceStatus: { ports: cloneData(state.deviceStatus.ports) } }; } function ensureSettingsDraft() { if (!state.settingsDraft) state.settingsDraft = createSettingsDraft(); return state.settingsDraft; } function syncSettingsDraftPortTelemetry(port) { var draftPort; var livePort; if (!state.settingsDraft || !state.settingsDraft.deviceStatus || !state.settingsDraft.deviceStatus.ports) return; livePort = state.deviceStatus.ports && state.deviceStatus.ports[port]; if (!livePort) return; draftPort = state.settingsDraft.deviceStatus.ports[port] || defaultPortStatus(port); draftPort.weight = livePort.weight; draftPort.range = livePort.range; draftPort.temperature = livePort.temperature; state.settingsDraft.deviceStatus.ports[port] = draftPort; } function loadPersisted() { try { var raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; var saved = JSON.parse(raw); state.config = deepMerge(defaultConfig(), saved.config || {}); state.config.appVersion = defaultConfig().appVersion; disableRemovedFeatures(); migrateDefaultServer(saved.config || {}); migrateSerialPath(saved.config || {}); migrateTerminalImei(saved.config || {}); migratePortConfig(); state.deviceStatus = deepMerge(defaultDeviceStatus(), saved.deviceStatus || {}); migratePortStatus(); state.offlineQueue = sanitizeOfflineQueue(saved.offlineQueue); state.localBackups = Array.isArray(saved.localBackups) ? saved.localBackups : []; state.faceSync = deepMerge(state.faceSync, saved.faceSync || {}); state.faceSync.syncing = false; state.faceDirectory = Array.isArray(saved.faceDirectory) ? saved.faceDirectory : []; } catch (error) { showToast("本地配置读取失败,已使用默认配置"); } } function migrateDefaultServer(savedConfig) { var savedServer = savedConfig.server || {}; var savedHost = String(savedServer.tcpHost || "").trim(); var savedHttpBase = String(savedServer.httpBase || "").trim(); if (!savedHost || savedHost === "20.2.128.145" || savedHost === LEGACY_REPORT_IP) { state.config.server.tcpHost = DEFAULT_REPORT_IP; } if (!savedServer.tcpPort || Number(savedServer.tcpPort) === 9878) { state.config.server.tcpPort = DEFAULT_REPORT_PORT; } if (!savedHttpBase || /20\.2\.128\.145|112\.74\.41\.228/.test(savedHttpBase)) { state.config.server.httpBase = DEFAULT_HTTP_BASE; } if (!savedServer.defaultMemberId) { state.config.server.defaultMemberId = DEFAULT_MEMBER_ID; } if (!savedServer.packageType || savedServer.packageType === "Five_NEW_AI_SJZ" || savedServer.packageType === "Five_NEW_AI") { state.config.server.packageType = DEFAULT_PACKAGE_TYPE; } } function migrateSerialPath(savedConfig) { var savedDevice = savedConfig.device || {}; var savedPath = String(savedDevice.serialPath || "").trim(); // 旧版本曾默认写入 S1;升级后自动迁回下位机实际连接的 S4。 if (!savedPath || savedPath === "/dev/ttyS1") { state.config.device.serialPath = "/dev/ttyS4"; } } function migrateTerminalImei(savedConfig) { var savedDevice = savedConfig.device || {}; var terminalId = String(savedDevice.terminalId || "").trim(); if (/^\d{10,20}$/.test(terminalId)) { state.config.device.terminalId = terminalId; state.lowerImei = terminalId; return; } state.config.device.terminalId = ""; state.lowerImei = null; } function migratePortConfig() { state.config.portCount = clampPortCount(state.config.portCount || 2); if (!state.config.customPortTypes) state.config.customPortTypes = {}; [1, 2, 3, 4].forEach(function (port) { if (!GARBAGE_TYPES[state.config.portTypes[port]]) { state.config.portTypes[port] = port % 2 === 0 ? "aluminumAlloy" : "tinCopper"; } customType(port); }); } function disableRemovedFeatures() { if (!state.config.guest) state.config.guest = { enabled: false }; state.config.guest.enabled = false; if (!state.config.face) state.config.face = {}; state.config.face.matchThreshold = Math.max(0.50, Math.min(0.95, Number(state.config.face.matchThreshold || 0.75))); // 清除旧版本持久化的模拟配置,升级后不能继续绕过真实串口和身份校验。 delete state.config.simulation; delete state.config.capture; } function migratePortStatus() { state.deviceStatus.deviceDisabled = Boolean(state.deviceStatus.deviceDisabled); if (!state.deviceStatus.deviceDisabled) { state.deviceStatus.collectionDoorClosed = true; } [1, 2, 3, 4].forEach(function (port) { ensurePortStatus(port); resetRangeStability(port); }); } function savePersisted() { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ config: state.config, deviceStatus: state.deviceStatus, offlineQueue: state.offlineQueue, localBackups: state.localBackups, faceSync: state.faceSync, faceDirectory: state.faceDirectory }) ); configureNativeReporter(); } catch (error) { showToast("本地配置保存失败"); } } function isAdminPasswordValid(password) { var value = String(password || ""); return value === String(state.config.adminPassword); } function t(key) { var lang = state.config.language || "zh-CN"; return (I18N[lang] && I18N[lang][key]) || I18N["zh-CN"][key] || key; } function isEnglish() { return (state.config.language || "zh-CN") === "en"; } function portDisplayName(port) { return isEnglish() ? t("portWord") + " " + port : port + " " + t("portSuffix"); } function portCountLabel(count) { return isEnglish() ? count + " " + (Number(count) === 1 ? t("portUnit") : t("portUnitPlural")) : count + " " + t("portUnit"); } function recordCountLabel(count) { return isEnglish() ? count + " " + (Number(count) === 1 ? t("recordUnit") : t("recordUnitPlural")) : count + " " + t("recordUnit"); } function localizedReadStatus(value) { var text = String(value == null ? "" : value); if (text === "读取中..." || text === "讀取中..." || text === "Reading...") return t("reading"); if (text === "获取失败" || text === "取得失敗" || text === "Read failed") return t("readFailed"); return text; } function localizedList(items) { return items.join(isEnglish() ? ", " : "、"); } function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function attr(value) { return escapeHtml(value); } function checked(value) { return value ? " checked" : ""; } function selected(value, current) { return String(value) === String(current) ? " selected" : ""; } function money(value) { return Number(value || 0).toFixed(2); } function numericMoney(value) { var cleaned = String(value == null ? "" : value).replace(/[^0-9.\-]/g, ""); var parsed = Number(cleaned); return isFinite(parsed) ? parsed : 0; } function plainMoney(value) { return money(numericMoney(value)).replace(/^[^\d]+/, ""); } function kg(value) { return Number(value || 0).toFixed(2) + " kg"; } function kgCompact(value) { return Number(value || 0).toFixed(2) + "kg"; } function cleanWeightNumber(value) { var number = Number(value); return isFinite(number) ? Number(number.toFixed(6)) : 0; } function nowText(timestamp) { var d = new Date(timestamp || Date.now()); var pad = function (n) { return n < 10 ? "0" + n : String(n); }; return ( d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) ); } function maskAccount(account) { var text = String(account || ""); if (text.length <= 4) return text; return text.slice(0, 2) + "*".repeat(Math.max(3, text.length - 4)) + text.slice(-2); } function maskPin(value) { var text = String(value || ""); return text ? "*".repeat(text.length) : t("notEntered"); } function isLogEnabled() { return Boolean(state.config.debugLog && state.config.debugLog.enabled); } function logTimeText(timestamp) { var d = new Date(timestamp || Date.now()); var pad = function (n) { return n < 10 ? "0" + n : String(n); }; return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); } function addLog(kind, message, detail) { message = String(message || "").trim(); if (!message) return; state.logs.unshift({ at: Date.now(), kind: String(kind || "窗口"), message: message, detail: detail == null ? "" : String(detail) }); if (state.logs.length > 80) state.logs.length = 80; if (isLogEnabled() && state.screen !== "home") render(); } function renderLogWindow() { if (!isLogEnabled()) return ""; var entries = state.logs.slice(0, 10); return ( '
' + '
窗口日志输出传输日志
' + '
' + (entries.length ? entries.map(function (item) { return ( '
' + '' + escapeHtml(logTimeText(item.at)) + "" + '' + escapeHtml(item.kind) + "" + '' + escapeHtml(item.message) + (item.detail ? " / " + escapeHtml(item.detail) : "") + "" + "
" ); }).join("") : '
等待日志输出
') + "
" + "
" ); } function showToast(message) { addLog("窗口", message); window.clearTimeout(timers.toast); toast.textContent = message; toast.classList.add("show"); timers.toast = window.setTimeout(function () { toast.classList.remove("show"); }, 2200); } function setPath(root, path, value) { var parts = path.split("."); var target = root; for (var i = 0; i < parts.length - 1; i += 1) { if (!target[parts[i]]) target[parts[i]] = {}; target = target[parts[i]]; } target[parts[parts.length - 1]] = value; } function getPath(root, path) { return path.split(".").reduce(function (obj, key) { return obj && obj[key]; }, root); } function clampPortCount(value) { var count = Number(value); if (!isFinite(count)) count = 2; return Math.min(MAX_PORT_COUNT, Math.max(1, Math.round(count))); } function allPhysicalPorts() { var ports = []; var i; for (i = 1; i <= MAX_PORT_COUNT; i += 1) ports.push(i); return ports; } function enabledPorts() { var count = clampPortCount(state.config.portCount || 2); var ports = []; for (var i = 1; i <= count; i += 1) ports.push(i); return ports; } function customType(port) { if (!state.config.customPortTypes) state.config.customPortTypes = {}; if (!state.config.customPortTypes[port]) state.config.customPortTypes[port] = { name: "自定义" + port, code: String(port % 2 === 0 ? 2 : 1) }; if (String(state.config.customPortTypes[port].code || "") === "18") { state.config.customPortTypes[port].code = String(port % 2 === 0 ? 2 : 1); } return state.config.customPortTypes[port]; } function draftCustomType(port) { var draft = ensureSettingsDraft(); if (!draft.config.customPortTypes) draft.config.customPortTypes = {}; if (!draft.config.customPortTypes[port]) draft.config.customPortTypes[port] = { name: "自定义" + port, code: String(port % 2 === 0 ? 2 : 1) }; if (String(draft.config.customPortTypes[port].code || "") === "18") { draft.config.customPortTypes[port].code = String(port % 2 === 0 ? 2 : 1); } return draft.config.customPortTypes[port]; } function garbageName(key, port) { if (key === "custom") return customType(port).name || "自定义"; var type = GARBAGE_TYPES[key] || GARBAGE_TYPES.tinCopper; var lang = state.config.language === "en" ? "en" : state.config.language === "zh-TW" ? "tw" : "zh"; return type[lang]; } function garbageCode(key, port) { if (key === "custom") return String(customType(port).code || "1"); return (GARBAGE_TYPES[key] || GARBAGE_TYPES.tinCopper).code; } function deliveryGarbageName(key, port) { if (key === "allPorts") return t("allPorts"); return garbageName(key, port); } function deliveryGarbageCode(key, port) { if (key === "allPorts") return "0"; return garbageCode(key, port); } function numericGarbageCode(key, port) { return Number(garbageCode(key, port)); } function typeOptions(current) { return Object.keys(GARBAGE_TYPES) .map(function (key) { var label = key === "custom" ? t("customEditable") : garbageName(key) + " / " + garbageCode(key); return '"; }) .join(""); } function workOptions() { var info = state.workInfo || {}; return Array.isArray(info.options) ? info.options : []; } function machineOptions() { return workOptions().map(function (item) { return item.machineNo; }); } function selectedMachine() { var machineNo = String((state.workInfo && state.workInfo.machineNo) || "").trim(); var options = workOptions(); var i; for (i = 0; i < options.length; i += 1) { if (String(options[i].machineNo || "") === machineNo) return options[i]; } return null; } function personOptions() { var machine = selectedMachine(); if (machine && Array.isArray(machine.persons)) return machine.persons; return uniqueWorkValues("persons"); } function batchOptions() { var machine = selectedMachine(); if (machine && Array.isArray(machine.batches)) return machine.batches; return uniqueWorkValues("batches"); } function uniqueWorkValues(key) { var seen = {}; var output = []; workOptions().forEach(function (item) { (item[key] || []).forEach(function (value) { value = String(value || "").trim(); if (value && !seen[value]) { seen[value] = true; output.push(value); } }); }); return output; } function uniqueTextList(values) { var seen = {}; var output = []; (Array.isArray(values) ? values : []).forEach(function (value) { value = cleanWorkText(value, 48); if (value && !seen[value]) { seen[value] = true; output.push(value); } }); return output; } function cleanWorkText(value, maxLength) { var text = String(value == null ? "" : value).replace(/\s+/g, " ").trim(); var limit = Math.max(1, Number(maxLength || 48)); return text.length > limit ? text.slice(0, limit) : text; } function syncWorkInfoForMachine(force) { var info = state.workInfo || defaultWorkInfo(); var machine = selectedMachine(); var persons; var batches; if (!machine) return false; persons = Array.isArray(machine.persons) ? machine.persons : []; batches = Array.isArray(machine.batches) ? machine.batches : []; if (persons.length && (force ? persons.indexOf(info.person) < 0 : !info.person)) info.person = persons[0]; if (batches.length && (force ? batches.indexOf(info.batch) < 0 : !info.batch)) info.batch = batches[0]; state.workInfo = info; return true; } function normalizeWorkContext(raw) { var list = firstArrayValue(raw, ["items", "data", "list", "rows", "machines", "records"]); var byMachine = {}; var order = []; list.forEach(function (item) { var persons; var batches; var machineNo; var target; if (!isPlainObject(item)) return; persons = item.persons || item.people || item.operators || item.operator || item.staff || item.person || item.personName || item.userName || []; batches = item.batches || item.productionBatches || item.productionBatch || item.batchNo || item.batch || []; if (!Array.isArray(persons)) persons = String(persons || "").split(/[,,]/); if (!Array.isArray(batches)) batches = String(batches || "").split(/[,,]/); machineNo = cleanWorkText(item.machineNo || item.machine || item.machineId || item.machineCode || item.deviceNo || item.no, 48); if (!machineNo) return; if (!byMachine[machineNo]) { byMachine[machineNo] = { machineNo: machineNo, persons: [], batches: [] }; order.push(machineNo); } target = byMachine[machineNo]; target.persons = uniqueTextList(target.persons.concat(persons)); target.batches = uniqueTextList(target.batches.concat(batches)); }); var normalized = order.map(function (machineNo) { return byMachine[machineNo]; }); return normalized; } function sessionWorkInfo() { return (state.session && state.session.workInfo) || { machineNo: "", person: "", employeeName: "", employeeNo: "", identifiedAt: 0, batch: "" }; } function sensorHeight(port) { var info = ensurePortStatus(port); var value = normalizeRangeValue(info.range); return value == null ? 150 : value; } function draftSensorHeight(port) { var draft = ensureSettingsDraft(); var info = (draft.deviceStatus.ports && draft.deviceStatus.ports[port]) || {}; var value = normalizeRangeValue(info.range); return value == null ? 150 : value; } function resetRangeStability(port) { rangeStability[port] = { full: 0, normal: 0 }; } function normalizeRangeValue(value) { var number = Number(value); if (!isFinite(number) || number <= 0) return null; return Math.max(1, Math.min(255, Math.round(number))); } function applyRangeReading(port, value) { var info = state.deviceStatus.ports[port]; var range = normalizeRangeValue(value); var tracker; if (!info || range == null) return false; info.range = range; tracker = rangeStability[port] || { full: 0, normal: 0 }; if (range <= RANGE_FULL_CONFIRM_CM) { tracker.full += 1; tracker.normal = 0; if (tracker.full >= RANGE_FULL_CONFIRM_COUNT) info.overflow = true; } else if (range >= RANGE_NORMAL_CONFIRM_CM) { tracker.normal += 1; tracker.full = 0; if (tracker.normal >= RANGE_NORMAL_CONFIRM_COUNT) info.overflow = false; } else { tracker.full = 0; tracker.normal = 0; } rangeStability[port] = tracker; return true; } function portTemperature(port) { var info = ensurePortStatus(port); return normalizeTemperatureValue(info.temperature, defaultPortStatus(port).temperature); } function portBlockReason(port) { if (state.deviceStatus.maintenance) return t("maintenanceMode"); if (state.deviceStatus.deviceDisabled) return t("maintenance"); if (ensurePortStatus(port).overflow) return t("full"); return ""; } function portStatusText(port) { var reason = portBlockReason(port); return reason || t("normal"); } function portStatusTextForInfo(info) { return info && info.overflow ? t("full") : t("normal"); } function isPortBlocked(port) { return Boolean(portBlockReason(port)); } function topbar(subtitle, rightExtra) { var actions = rightExtra || ""; return ( '
' + '
' + '
' + '

' + escapeHtml(t("appName")) + '

' + escapeHtml(subtitle || t("subtitle")) + "

" + "
" + (actions ? '
' + actions + "
" : "") + "
" ); } function statusPill(label, ok, badLabel) { return '' + escapeHtml(ok ? label : badLabel) + ""; } function render() { var html = ""; if (state.screen === "boot") html = renderBoot(); if (state.screen === "home") html = renderHome(); if (state.screen === "auth") html = renderAuth(); if (state.screen === "workInfo") html = renderWorkInfo(); if (state.screen === "select") html = renderSelect(); if (state.screen === "delivery") html = renderDelivery(); if (state.screen === "success") html = renderSuccess(); if (state.screen === "adminLogin") html = renderAdminLogin(); if (state.screen === "settings") html = renderSettings(); app.innerHTML = html + renderLogWindow(); var bridge = nativeBridge(); var facePreviewActive = isFacePreviewActive(); if (bridge && typeof bridge.setFaceRecognitionActive === "function") { try { bridge.setFaceRecognitionActive(JSON.stringify({ active: facePreviewActive })); } catch (error) { addLog("人脸", "切换摄像头状态失败", error.message || error); } } syncFacePreviewLayout(facePreviewActive); } function isFacePreviewActive() { // 工号弹窗显示时先隐藏原生 SurfaceView,否则摄像头层会盖住 WebView 中央弹窗。 return (state.screen === "home" && !(state.employeeLogin && state.employeeLogin.open)) || Boolean( state.screen === "settings" && state.activeTab === "face" && state.faceEnrollment && state.faceEnrollment.capturing ); } function syncFacePreviewLayout(visible) { var bridge = nativeBridge(); if (!bridge || typeof bridge.setFacePreviewLayout !== "function") return; if (!visible) { facePreviewLayoutQueued = false; bridge.setFacePreviewLayout(JSON.stringify({ visible: false })); return; } if (facePreviewLayoutQueued) return; facePreviewLayoutQueued = true; window.requestAnimationFrame(function () { window.requestAnimationFrame(function () { facePreviewLayoutQueued = false; var host = document.getElementById("face-camera-preview"); if (!host) { bridge.setFacePreviewLayout(JSON.stringify({ visible: false })); return; } if (host.classList.contains("home-camera-preview")) { host.style.height = Math.round(host.getBoundingClientRect().width * 0.75) + "px"; } var rect = host.getBoundingClientRect(); var scale = Number(window.devicePixelRatio || 1); var inset = 4; var visibleLeft = Math.max(rect.left + inset, 0); var visibleTop = Math.max(rect.top + inset, 0); var visibleRight = Math.min(rect.right - inset, window.innerWidth); var visibleBottom = Math.min(rect.bottom - inset, window.innerHeight); if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) { bridge.setFacePreviewLayout(JSON.stringify({ visible: false })); return; } bridge.setFacePreviewLayout(JSON.stringify({ visible: true, left: Math.round(visibleLeft * scale), top: Math.round(visibleTop * scale), width: Math.max(1, Math.round((visibleRight - visibleLeft) * scale)), height: Math.max(1, Math.round((visibleBottom - visibleTop) * scale)) })); }); }); } function renderBoot() { var serialState = state.check.serial ? "ok" : state.check.completed ? "bad" : ""; var networkState = state.check.network ? "ok" : state.check.completed ? "bad" : ""; var bootDesc = state.check.completed ? state.check.available ? "串口和网络检测均已通过,检测结果显示 3 秒后自动进入首页。" : "启动检测未通过,可以重新检测或点击“进入首页”。" : "正在检查串口和 TCP/HTTP 网络状态,串口和网络均通过后才会自动进入首页。"; return ( '
' + '
' + '
' + '

星元智灵启动自检

' + '

' + escapeHtml(bootDesc) + "

" + '
' + checkRow("串口通讯", "SerialPort " + state.config.device.serialPath + " / " + state.config.device.baudRate, serialState, state.check.serial ? "已连接" : state.check.completed ? "未连接" : "检测中") + checkRow("网络服务", state.config.server.tcpHost + ":" + state.config.server.tcpPort, networkState, state.check.network ? "已连接" : state.check.completed ? "未连接" : "检测中") + "
" + '

连接次数:' + state.check.attempt + " / " + STARTUP_CHECK_MAX_ATTEMPTS + "

" + '
' + (state.check.completed && state.check.available ? '' : '') + (state.check.completed && !state.check.available ? '' : "") + "
" + "
" + "
" + "
" ); } function checkRow(name, meta, stateClass, text) { var cls = stateClass ? " " + stateClass : ""; return ( '
' + '
' + escapeHtml(name) + '
' + escapeHtml(meta) + "
" + '
' + escapeHtml(text) + "
" + "
" ); } function renderHome() { var reason = homeBlockReason(); var faceFeedback = homeFaceFeedback(); return ( '
' + topbar(t("subtitle")) + '
' + '
' + '
' + '

' + escapeHtml("请正对摄像头进行人脸识别") + "

" + '

' + escapeHtml("人脸识别失败可选择工号进行登陆投放") + "

" + '
正在连接 USB 摄像头画面…
' + (faceFeedback ? '
' + escapeHtml(faceFeedback) + '
' : '') + '
' + escapeHtml(reason || t("available")) + "
" + "
" + renderHomeActions(reason) + '" + "
" + renderEmployeeLoginDialog() + "
" + "
" ); } function homeFaceFeedback() { var face = state.faceRecognition || {}; var status = String(face.status || ""); if (["face_error", "no_match", "error", "camera_error", "permission", "searching"].indexOf(status) < 0) { return ""; } return String(face.message || "人脸识别失败"); } function renderHomeActions(reason) { var serialBlocked = !state.deviceStatus.serialOnline; return ( '
' + '" + "
" ); } function renderEmployeeLoginDialog() { var login = state.employeeLogin || { open: false, employeeNo: "", message: "" }; var people = employeeLoginPeople(); var serialBlocked = !state.deviceStatus.serialOnline; if (!login.open) return ""; var choices = people.length ? people.map(function (person) { var active = String(login.employeeNo || "") === person.employeeNo; return '"; }).join("") : '
暂无人员,请先到“设置 → 人脸”新增人员。
'; return ( '
' + '
" ); } function employeeLoginPeople() { var seen = {}; return (Array.isArray(state.facePeople) && state.facePeople.length ? state.facePeople : state.faceDirectory || []) .map(function (person) { return { employeeNo: String(person && person.employeeNo || "").trim(), name: String(person && person.name || "").trim() }; }) .filter(function (person) { if (!person.employeeNo || seen[person.employeeNo]) return false; seen[person.employeeNo] = true; return true; }) .sort(function (first, second) { return first.employeeNo.localeCompare(second.employeeNo); }); } function homeBlockReason() { if (state.deviceStatus.maintenance || state.deviceStatus.deviceDisabled) return t("maintenance"); if (!state.deviceStatus.collectionDoorClosed) return "收运门未关闭"; if (!state.deviceStatus.serialOnline) return "串口故障:请检查串口占用情况以及下位机连接"; if (!state.deviceStatus.networkOnline) return "网络故障"; if (!state.deviceStatus.tcpConnected) return "服务器断开"; if (state.check.completed && !state.check.available) return t("unavailable"); return ""; } function renderAuth() { return ( '
' + topbar("身份鉴权") + '
' + '

读取用户身份

' + '

二维码扫码器和 IC 卡读取结果会按协议上传。

' + '
' + '

' + (state.auth.status === "reading" ? "正在请求服务器鉴权" : state.auth.status === "failed" ? "鉴权失败" : "准备鉴权") + "

" + '

' + escapeHtml(state.auth.message || "请稍候,正在建立用户会话。") + "

" + '
' + '" + "
" + "
" + "
" + "
" ); } function renderWorkInfo() { var info = state.workInfo || defaultWorkInfo(); var user = (state.session && state.session.user) || {}; return ( '
' + topbar("投放信息") + '
' + '

信息核对

' + '

请核对员工信息,并模糊输入或从列表选择机台号。15 秒未完成将返回首页。

' + '
员工姓名' + escapeHtml(user.name || "-") + '
工号' + escapeHtml(user.employeeNo || user.account || "-") + '
识别时间' + escapeHtml(user.identifiedAtText || "-") + "
" + (info.loading ? '

正在获取投放信息

请稍候。

' : renderWorkInfoForm(info)) + "
" + (!info.loading ? renderWorkPicker(info) : "") + "
" ); } function renderWorkInfoForm(info) { return ( '
' + (info.error ? '

' + escapeHtml(info.error) + "

" : "") + pickerInput("机台号", "machineNo", info.machineNo, "请输入或选择机台号") + '
' + '' + '' + '' + "
" + "
" ); } function pickerInput(label, field, value, placeholder) { return ( '
' ); } function workInput(label, field, value, options, placeholder) { var listId = "work-" + field + "-options"; return ( '
' + '' + options.map(function (item) { return ''; }).join("") + "
" ); } function workPickerTitle(field) { if (field === "person") return "选择人员"; if (field === "batch") return "选择生产批次"; return "选择机台号"; } function workPickerOptions(field) { if (field === "person") return personOptions(); if (field === "batch") return batchOptions(); return machineOptions(); } function renderWorkPicker(info) { var field = info.pickerField || ""; if (!field) return ""; var options = workPickerOptions(field); var rows = []; var i; for (i = 0; i < options.length; i += 3) { rows.push(options.slice(i, i + 3)); } return ( '
' + '
" ); } function renderSelect() { var work = sessionWorkInfo(); var ports = enabledPorts(); return ( '
' + topbar("投口选择") + '
' + '

' + escapeHtml(t("choosePort")) + "

" + '

请在 15 秒内选择投口。

' + renderWorkSummary(work) + '
' + ports.map(renderPortCard).join("") + "
" + '
' + '" + '" + "
" + "
" + "
" ); } function renderWorkSummary(work) { work = work || {}; return ( '
' + '
机台号' + escapeHtml(work.machineNo || "-") + "
" + '
员工姓名' + escapeHtml(work.employeeName || work.person || "-") + "
" + '
工号' + escapeHtml(work.employeeNo || "-") + "
" + "
" ); } function renderPortCard(port) { var typeKey = state.config.portTypes[port] || "other"; var blocked = isPortBlocked(port); return ( '" ); } function renderDelivery() { var d = state.delivery || {}; var phase = deliveryPhaseText(); return ( '
' + topbar("投递中") + '
' + '
' + '
' + escapeHtml(phase) + "
" + '
' + (d.secondsLeft == null ? 30 : d.secondsLeft) + "
" + '
' + escapeHtml(deliverySafetyText()) + "
" + '
' + '" + '" + '" + "
" + "
" + "
" + "
" ); } function deliveryPhaseText() { var d = state.delivery || {}; if (d.baselineWaiting) return d.allPorts ? "正在读取全部投口的投放前重量" : "正在读取投放前重量"; if (d.closing) return d.allPorts ? "正在关闭全部投口" : "正在关闭投放门"; if (!d.opened) return d.allPorts ? "正在打开全部投口" : "正在打开投放门"; if (d.secondsLeft <= 5) return "即将关闭投放门"; return t("delivery"); } function deliverySafetyText() { var d = state.delivery || {}; if (d.baselineWaiting) return "正在锁定称重基准,完成后将自动开门,请暂时不要投放物品。"; if (d.closing) return d.allPorts ? "请确认手部和物品已离开所有投口,正在等待下位机返回称重结果。" : "请确认手部和物品已离开投口,正在等待下位机返回称重结果。"; if (d.delayClose) return d.allPorts ? "已延时关门,倒计时结束后将自动关闭全部投口。" : "已延时关门,倒计时结束后将自动关闭投放门。"; if (d.secondsLeft <= 5) return "投放门即将关闭,请勿将手伸入投口。"; return "请在倒计时结束前完成投递,也可以手动关闭投放门。"; } function renderSuccess() { var s = state.session || {}; var r = s.result || {}; return ( '
' + topbar("本次投递") + '
' + '
' + '

' + escapeHtml(t("success")) + "

" + '

10 秒后自动返回首页

' + renderSuccessPortCards(r) + '
' + '" + "
" + "
" + "
" + "
" ); } function successPortDetails(result) { var details = result && Array.isArray(result.portDetails) && result.portDetails.length ? result.portDetails : [{ port: result && result.port ? result.port : 1, garbageTypeName: deliveryGarbageName(result && result.typeKey, result && result.port), weight: result && result.weight != null ? result.weight : 0 }]; if (result && Array.isArray(result.ports) && result.ports.length) { var visiblePorts = result.ports.map(function (port) { return Number(port); }); details = details.filter(function (item) { return visiblePorts.indexOf(Number(item.port)) >= 0; }); } return details; } function renderSuccessPortCards(result) { var details = successPortDetails(result); return ( '
' + details.map(function (item) { return ( '
' + '
' + escapeHtml(item.port + "号投口") + "
" + '
垃圾类型' + escapeHtml(item.garbageTypeName || "-") + "
" + '
重量' + escapeHtml(kgCompact(item.weight)) + "
" + "
" ); }).join("") + "
" ); } function renderAdminLogin() { var pin = state.adminLogin.value || ""; var showPassword = Boolean(state.adminLogin.showPassword); var pinText = pin ? (showPassword ? pin : "*".repeat(pin.length)) : t("passwordPlaceholder"); var adminMetaSeparator = isEnglish() ? " / " : ";"; return ( '
' + topbar(t("admin")) + '
' + '" + "
" + "
" ); } function renderAdminKeypad() { var keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; return ( '
' + keys.map(function (key) { return '"; }).join("") + '" + '' + '" + "
" ); } function renderSettings() { return ( '
' + topbar(t("settingsTitle"), '") + '
' + '" + '
' + renderSettingsTab() + "
" + "
" + renderPortSettingDialog() + "
" ); } function tabButton(tab, label) { return '"; } function renderSettingsTab() { if (state.activeTab === "device") return renderDeviceSettings(); if (state.activeTab === "ports") return renderPortSettings(); if (state.activeTab === "cache") return renderCacheSettings(); if (state.activeTab === "face") return renderFaceSettings(); return renderSystemSettings(); } function renderDeviceSettings() { return ( '
' + '

' + escapeHtml(t("deviceParams")) + '

' + field(t("deviceNo"), "config.device.deviceNo", state.config.device.deviceNo || "") + field(t("terminalImei"), "config.device.terminalId", state.config.device.terminalId) + serialPathField() + "
" + renderMaintenanceSettings() + renderDeviceDisabledSettings() + '

' + escapeHtml(t("deviceCheck")) + "

" + '
' + '" + '" + '" + "
" + renderTestResults() + (state.lowerVersion ? '

' + escapeHtml(t("firmwareVersion")) + ":" + escapeHtml(localizedReadStatus(state.lowerVersion)) + "

" : "") + (state.lowerImei ? '

' + escapeHtml(t("lowerImei")) + ":" + escapeHtml(localizedReadStatus(state.lowerImei)) + "

" : "") + "
" + "
" ); } function serialPathField() { var current = String(state.config.device.serialPath || "/dev/ttyS4"); var serialState = state.serialPorts || { loaded: false, loading: false, items: [], message: "" }; var items = Array.isArray(serialState.items) ? serialState.items.slice() : []; var hasCurrent = items.some(function (item) { return item.path === current; }); if (!hasCurrent) { items.unshift({ path: current, readable: false, writable: false, configuredOnly: true }); } if (!serialState.loaded && !serialState.loading) { serialState.loading = true; state.serialPorts = serialState; window.setTimeout(function () { refreshSerialPorts(false); }, 0); } var options = items.map(function (item) { var permission = item.configuredOnly ? "当前配置,未检测到" : item.readable && item.writable ? "可读写" : "需获取权限"; return '"; }).join(""); var hint = serialState.loading ? "正在扫描安卓设备串口…" : serialState.message || "选择串口后,请点击“重新启动自检”验证连接。"; return ( '
" + '
" + '
' + '

' + escapeHtml(hint) + "

" ); } function refreshSerialPorts(notify) { var bridge = nativeBridge(); var serialState = state.serialPorts || { loaded: false, loading: false, items: [], message: "" }; serialState.loading = true; state.serialPorts = serialState; try { if (!bridge || typeof bridge.listSerialPorts !== "function") { throw new Error("当前版本未加载串口扫描模块"); } var result = JSON.parse(String(bridge.listSerialPorts("{}") || "{}")); if (!result.ok) throw new Error(result.error || "串口扫描失败"); var seen = {}; serialState.items = (Array.isArray(result.ports) ? result.ports : []).filter(function (item) { var path = String(item && item.path || ""); if (!/^\/dev\/[A-Za-z0-9._/-]+$/.test(path) || seen[path]) return false; seen[path] = true; item.path = path; return true; }); serialState.message = serialState.items.length ? "已发现 " + serialState.items.length + " 个串口设备。" : "未发现串口设备,已保留当前配置。"; if (notify) showToast(serialState.message); } catch (error) { serialState.items = []; serialState.message = "串口扫描失败:" + (error.message || error); if (notify) showToast(serialState.message); } serialState.loaded = true; serialState.loading = false; state.serialPorts = serialState; render(); } function renderMaintenanceSettings() { return ( '

' + escapeHtml(t("maintenanceMode")) + "

" + '

' + (state.deviceStatus.maintenance ? escapeHtml(t("maintenanceOnDesc")) : escapeHtml(t("maintenanceOffDesc"))) + "

" + '
' + '" + "
" + "
" ); } function renderDeviceDisabledSettings() { return ( '

' + escapeHtml(t("deviceDisabledTitle")) + "

" + '

' + (state.deviceStatus.deviceDisabled ? escapeHtml(t("deviceDisabledOnDesc")) : escapeHtml(t("deviceDisabledOffDesc"))) + "

" + '
' + '" + (state.deviceStatus.deviceDisabled ? '" : "") + "
" + "
" ); } function renderPortSettings() { var draft = ensureSettingsDraft(); var cfg = draft.config; var options = allPhysicalPorts(); return ( '
' + '

' + escapeHtml(t("portCount")) + "

" + '
" + '

' + escapeHtml(t("portTypeStatus")) + "

" + '
' + allPhysicalPorts().map(renderPortSettingButton).join("") + "
" + "
" + "
" ); } function renderPortSettingButton(port) { var draft = ensureSettingsDraft(); var cfg = draft.config; var enabled = port <= Number(cfg.portCount); var typeKey = cfg.portTypes[port] || "other"; return ( '" ); } function renderPortSettingDialog() { var draft = ensureSettingsDraft(); var cfg = draft.config; var port = Number(state.portSettingsPort || 0); var enabled = port && port <= Number(cfg.portCount); if (state.activeTab !== "ports") return ""; if (!enabled) return ""; return renderPortSettingEditor(port); } function renderPortSettingEditor(port) { var draft = ensureSettingsDraft(); var cfg = draft.config; var liveInfo = ensurePortStatus(port); var typeKey = cfg.portTypes[port] || "other"; return ( '
' + '" + "
" ); } function openPortSettings(target) { var draft = ensureSettingsDraft(); var port = Number(target.getAttribute("data-port")); if (!port || port > Number(draft.config.portCount || 0)) return; state.portSettingsPort = port; render(); scheduleSettingsPortTelemetry([port], 60); } function closePortSettings() { state.portSettingsPort = 0; render(); } function renderCacheSettings() { if (!state.config.localBackup) state.config.localBackup = { enabled: false, path: "" }; return ( '
' + '

' + escapeHtml(t("localBackupMode")) + "

" + '

' + (state.config.localBackup.enabled ? escapeHtml(t("localBackupOnDesc")) : escapeHtml(t("localBackupOffDesc"))) + "

" + '
' + '" + "
" + '

' + escapeHtml(t("localBackupStorageHint")) + "

" + '

' + escapeHtml(t("backupPath")) + ":" + escapeHtml(state.config.localBackup.path || t("generatedAfterEnable")) + "

" + '

' + escapeHtml(t("localRecord")) + ":" + escapeHtml(recordCountLabel(state.localBackups.length)) + "

" + "
" + '

' + escapeHtml(t("cacheRules")) + "

" + '
' + field(t("retainDays"), "config.cache.retainDays", state.config.cache.retainDays, "number") + field(t("retryIntervalHours"), "config.cache.retryIntervalHours", state.config.cache.retryIntervalHours, "number") + "
" + '
' + '" + '" + "
" + '

' + escapeHtml(t("pendingQueue")) + "(" + state.offlineQueue.length + ")

" + renderQueue() + "
" + "
" ); } function renderFaceSettings() { var faceEnrollment = state.faceEnrollment || { employeeNo: "", name: "", selectedFaceId: "", capturing: false, message: "" }; var people = Array.isArray(state.facePeople) ? state.facePeople : []; var selectedPerson = people.filter(function (person) { return String(person.faceId || "") === String(faceEnrollment.selectedFaceId || ""); })[0] || null; var peopleRows = people.length ? people.map(function (person) { return ( '' + escapeHtml(person.name || "-") + '' + '' + escapeHtml(person.employeeNo || "-") + '' + '' + (person.registered ? "已录入" : "未录入") + '' + '' + '' + '' + '' ); }).join("") : '暂无人员,请先新增人员信息'; return ( '
' + '

人脸匹配阈值

' + '

数值越高越严格。允许范围 0.50~0.95,当前建议值为 0.75。

' + '
' + '
' + '
' + '

第一步:新增人员信息

' + '

填写工号和姓名并保存人员,再到人员列表中录入人脸。

' + '
' + '
' + '
' + '
' + '
' + (selectedPerson ? '

第二步:录入人脸

' + '

当前人员:' + escapeHtml(selectedPerson.name) + '(' + escapeHtml(selectedPerson.employeeNo) + ')。请让本人正对设备摄像头,确保摄像头前只有一人,然后点击开始采集。

' + (faceEnrollment.message ? '

' + escapeHtml(faceEnrollment.message) + '

' : '') + (faceEnrollment.capturing ? '

SeetaFace6 人脸采集

' + '

请保持正脸、光线均匀,并确保画面中只有当前人员。

' + '
正在调取摄像头画面…
' + '

' + escapeHtml((state.faceRecognition && state.faceRecognition.message) || "请正对摄像头") + '

' + '
' + '
' : '
' + '
') + '
' : '') + '

人员管理

' + '

当前共 ' + people.length + ' 人。未录入人员可点击“录入人脸”继续第二步。

' + '
' + '
' + peopleRows + '
姓名工号人脸状态操作
' + "
" ); } function renderSystemSettings() { var logEnabled = isLogEnabled(); return ( '
' + '

' + escapeHtml(t("languageSystem")) + '

' + '
" + '

' + escapeHtml(t("changePassword")) + "

" + '
" + renderPasswordChangePanel() + "
" + '

' + escapeHtml(t("systemTools")) + "

" + '
" + "
" + "
" ); } function renderPasswordChangePanel() { var flow = state.passwordChange; if (!flow || !flow.open) return ""; var labels = { current: t("passwordCurrentPrompt"), next: t("passwordNextPrompt"), confirm: t("passwordConfirmPrompt") }; var value = flow.step === "current" ? flow.current : flow.step === "next" ? flow.next : flow.confirm; return ( '
' + '
' + '
' + escapeHtml(labels[flow.step] || labels.current) + "
" + '
' + escapeHtml(maskPin(value)) + "
" + (flow.message ? '

' + escapeHtml(flow.message) + "

" : "") + "
" + '
' + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(function (num) { return '"; }).join("") + '" + '' + '" + "
" + '
' + '" + '" + "
" + "
" ); } function field(label, path, value, type) { var inputType = type || "text"; return ( '
' ); } function draftField(label, path, value, type) { var inputType = type || "text"; return ( '
' ); } function readonlyField(label, value) { return ( '
' + escapeHtml(value == null ? "" : String(value)) + "
" ); } function toggle(label, path, value, help) { return ( '
' + '
' + escapeHtml(label) + "" + (help ? "" + escapeHtml(help) + "" : "") + "
" + '' + "
" ); } function draftToggle(label, path, value, help) { return ( '
' + '
' + escapeHtml(label) + "" + (help ? "" + escapeHtml(help) + "" : "") + "
" + '' + "
" ); } function renderTestResults() { if (!state.testResults) return ""; return ( '
' + state.testResults.map(function (item) { return '
' + escapeHtml(item.label) + "" + escapeHtml(item.ok ? t("pass") : t("fail")) + "
"; }).join("") + "
" ); } function renderQueue() { var queue = sanitizeOfflineQueue(state.offlineQueue); if (queue.length !== state.offlineQueue.length) state.offlineQueue = queue; if (!queue.length) return '

' + escapeHtml(t("noPendingData")) + "

"; return ( '
' + queue.map(function (item) { return ( '
' + '
' + escapeHtml(item.kindText) + "" + '
' + escapeHtml(nowText(item.createdAt)) + " / " + escapeHtml(item.summary) + "
" + '' + escapeHtml(item.status || "queued") + "" + "
" ); }).join("") + "
" ); } function beginStartupCheck() { window.clearTimeout(timers.boot); state.screen = "boot"; state.check = defaultCheck(); state.check.running = true; render(); requestConnectionCheck("startup"); } function finishStartupCheck(available, skipped) { window.clearTimeout(timers.boot); state.check.completed = true; state.check.running = false; state.check.available = Boolean( available && state.check.serial && state.check.network ); state.check.skipped = skipped; savePersisted(); render(); if (state.check.available) { timers.boot = window.setTimeout(function () { if (state.screen === "boot" && state.check.completed && state.check.available) { enterHomeAfterStartup(false); } }, STARTUP_RESULT_HOLD_MS); } } function enterHomeAfterStartup(skipped) { window.clearTimeout(timers.boot); connectionCheckRequestId += 1; state.check.skipped = Boolean(skipped); state.screen = "home"; savePersisted(); render(); } function hasValidTerminalId() { return /^\d{10,20}$/.test(String(state.config.device.terminalId || "").trim()); } function refreshNetworkState(networkState, logChange) { var changed = false; var networkOnline = Boolean(networkState && networkState.networkOnline); var tcpConnected = Boolean(networkState && networkState.tcpConnected); var httpReachable = Boolean(networkState && networkState.httpReachable); if ( state.deviceStatus.networkOnline !== networkOnline || state.deviceStatus.tcpConnected !== tcpConnected || state.deviceStatus.httpReachable !== httpReachable ) { state.deviceStatus.networkOnline = networkOnline; state.deviceStatus.tcpConnected = tcpConnected; state.deviceStatus.httpReachable = httpReachable; changed = true; if (logChange) { addLog( "状态", networkOnline ? "网络连接恢复" : "网络连接断开", tcpConnected ? "业务服务器可连接" : "业务服务器不可连接" ); } } return changed; } function requestConnectionCheck(mode) { var bridge = nativeBridge(); var requestId = ++connectionCheckRequestId; if (mode === "startup") state.check.attempt += 1; if (!bridge || typeof bridge.requestConnectionCheck !== "function") { handleConnectionCheck({ requestId: requestId, mode: mode, error: "原生检测接口不可用" }); return; } bridge.requestConnectionCheck(JSON.stringify({ requestId: requestId, mode: mode, path: state.config.device.serialPath, baudRate: Number(state.config.device.baudRate || 9600), host: state.config.server.tcpHost, port: Number(state.config.server.tcpPort || DEFAULT_REPORT_PORT), pingHost: "www.baidu.com", timeoutSeconds: 2 })); window.clearTimeout(timers.boot); timers.boot = window.setTimeout(function () { if (requestId !== connectionCheckRequestId) return; handleConnectionCheck({ requestId: requestId, mode: mode, error: "检测超时" }); }, 4500); } function handleConnectionCheck(payload) { var mode = payload && payload.mode || "startup"; if (!payload || Number(payload.requestId || 0) !== connectionCheckRequestId) return; window.clearTimeout(timers.boot); if (mode === "network") { applyNetworkTestResult(payload); return; } state.deviceStatus.serialOnline = Boolean(payload.serialOnline); refreshNetworkState(payload, false); state.check.serial = state.deviceStatus.serialOnline; state.check.network = Boolean( state.deviceStatus.networkOnline && state.deviceStatus.tcpConnected && state.deviceStatus.httpReachable ); render(); if (state.check.serial && state.check.network) { if (!hasValidTerminalId()) addLog("串口", "串口在线但尚未获取IMEI"); finishStartupCheck(true, false); } else if (state.check.attempt < STARTUP_CHECK_MAX_ATTEMPTS) { timers.boot = window.setTimeout(function () { requestConnectionCheck("startup"); }, 500); } else { finishStartupCheck(false, false); } } function requestPortTelemetry(ports) { var sent = false; (Array.isArray(ports) && ports.length ? ports : allPhysicalPorts()).forEach(function (port) { var payload = serialPayload({ port: port }); sent = nativeBooleanCallSilent("queryWeight", payload) || sent; sent = nativeBooleanCallSilent("queryFill", payload) || sent; sent = nativeBooleanCallSilent("queryTemperature", payload) || sent; }); return sent; } function enterSelectScreen() { state.screen = "select"; render(); startSelectTimeout(); } function cancelSettingsPortTelemetry() { window.clearTimeout(timers.settingsTelemetry); } function scheduleSettingsPortTelemetry(ports, delay) { cancelSettingsPortTelemetry(); timers.settingsTelemetry = window.setTimeout(function () { refreshSettingsPortTelemetry(ports); }, delay == null ? 120 : delay); } function refreshSettingsPortTelemetry(ports) { var targetPorts; var index = 0; var sent = false; targetPorts = (Array.isArray(ports) && ports.length ? ports : enabledPorts()) .map(function (port) { return Number(port); }) .filter(function (port) { return port >= 1 && port <= MAX_PORT_COUNT; }); if (!targetPorts.length) return; cancelSettingsPortTelemetry(); function queryNextPort() { var port; if (state.screen !== "settings" || state.activeTab !== "ports") return; if (index >= targetPorts.length) { if (sent) addLog("串口", "设置页读取投口实时数据", targetPorts.length + "个投口"); return; } port = targetPorts[index]; index += 1; sent = requestPortTelemetry([port]) || sent; timers.settingsTelemetry = window.setTimeout(queryNextPort, 90); } queryNextPort(); } function failAuthAndReturn(message) { state.auth = { status: "failed", message: message || "会员鉴权失败" }; addLog("窗口", "身份鉴权失败", state.auth.message); render(); window.clearTimeout(timers.authFail); timers.authFail = window.setTimeout(function () { if (state.screen === "auth" && state.auth.status === "failed") { goHome(); } }, 1800); } function authPriority(source) { if (source === "face") return 3; if (source === "card") return 2; return 1; } function authFailureMessage(source, fallback) { if (source === "employee") return "工号错误"; if (source === "card") return "卡片识别错误"; if (source === "face") return "人脸识别失败"; return fallback || "身份鉴权失败"; } function openEmployeeLogin() { if (!state.deviceStatus.serialOnline) { showToast("串口异常,无法选择工号"); return; } state.employeeLogin = { open: true, employeeNo: "", message: "" }; render(); } function closeEmployeeLogin() { state.employeeLogin = { open: false, employeeNo: "", message: "" }; render(); } function selectEmployeeForLogin(target) { if (!state.deviceStatus.serialOnline || !state.employeeLogin || !state.employeeLogin.open) return; var employeeNo = String(target.getAttribute("data-employee-no") || "").trim(); if (!employeeNo || !employeeLoginPeople().some(function (person) { return person.employeeNo === employeeNo; })) return; state.employeeLogin.employeeNo = employeeNo; state.employeeLogin.message = ""; render(); } function submitEmployeeLogin() { var reason; if (!state.deviceStatus.serialOnline) { state.employeeLogin.message = "串口异常,无法使用工号登录"; render(); return; } var value = String((state.employeeLogin && state.employeeLogin.employeeNo) || "").trim(); if (!value || !employeeLoginPeople().some(function (person) { return person.employeeNo === value; })) { state.employeeLogin.message = "请选择人员"; render(); return; } reason = homeBlockReason(); if (reason) { // 鉴权前设备状态可能发生变化;保持弹窗状态一致并直接展示阻断原因。 state.employeeLogin.message = reason; render(); return; } state.employeeLogin.open = false; startAuth("employee", value, 3); } function verifyFaceIdentity(identity) { var bridge = nativeBridge(); if (bridge && typeof bridge.verifyFace === "function") { try { return JSON.parse(String(bridge.verifyFace(JSON.stringify({ faceId: identity })) || "{}")); } catch (error) { addLog("传输", "本地人脸校验失败", error.message || error); } } return verifyNativeMember("face", identity, 4); } function syncFaceData(notify) { var bridge = nativeBridge(); if (state.faceSync.syncing) return; state.faceSync.syncing = true; state.faceSync.message = "正在同步人脸数据"; render(); window.setTimeout(function () { var result = { ok: false, count: 0, error: "人脸数据接口未接入" }; try { if (bridge && typeof bridge.syncFaceData === "function") { result = JSON.parse(String(bridge.syncFaceData("{}") || "{}")); } } catch (error) { result = { ok: false, count: 0, error: error.message || "同步失败" }; } state.faceSync.syncing = false; state.faceSync.lastAt = result.ok ? Date.now() : state.faceSync.lastAt; state.faceSync.count = Number(result.count || 0); state.faceSync.message = result.ok ? "同步完成,共 " + state.faceSync.count + " 人" : (result.error || "同步失败"); savePersisted(); addLog("传输", result.ok ? "人脸数据同步完成" : "人脸数据同步失败", state.faceSync.message); if (notify) showToast(state.faceSync.message); render(); }, 100); } function enrollCurrentFace() { var bridge = nativeBridge(); if (!bridge || typeof bridge.enrollCurrentFace !== "function") { showToast("当前版本未加载人脸识别模块"); return; } var selectedFaceId = String(state.faceEnrollment.selectedFaceId || ""); var person = (state.facePeople || []).filter(function (item) { return String(item.faceId || "") === selectedFaceId; })[0]; if (!person) { state.faceEnrollment.message = "请先新增人员,再从人员列表选择录入人脸"; render(); return; } var employeeNo = String(person.employeeNo || "").trim(); var name = String(person.name || "").trim(); try { var result = JSON.parse(String(bridge.enrollCurrentFace(JSON.stringify({ faceId: selectedFaceId, employeeNo: employeeNo, name: name })) || "{}")); state.faceEnrollment.message = result.ok ? "采集成功:" + name + "(" + employeeNo + ")" : (result.error || "采集失败"); if (result.ok) { state.faceRecognition.enrolledCount = Number(result.count || state.faceRecognition.enrolledCount || 0); state.faceEnrollment.capturing = false; refreshFacePeople(false); } addLog("人脸", result.ok ? "现场人脸采集成功" : "现场人脸采集失败", state.faceEnrollment.message); showToast(state.faceEnrollment.message); render(); } catch (error) { state.faceEnrollment.message = "采集失败:" + (error.message || error); showToast(state.faceEnrollment.message); render(); } } function refreshFacePeople(renderAfter) { var bridge = nativeBridge(); var result = { ok: false, people: [] }; var registered = []; try { if (bridge && typeof bridge.listFacePeople === "function") { result = JSON.parse(String(bridge.listFacePeople("{}") || "{}")); } } catch (error) { result = { ok: false, people: [], error: error.message || "读取人员列表失败" }; } if (result.ok) { registered = Array.isArray(result.people) ? result.people : []; state.faceRecognition.enrolledCount = Number(result.count || registered.length || 0); } else if (result.error) { showToast(result.error); } var directory = Array.isArray(state.faceDirectory) ? state.faceDirectory.slice() : []; registered.forEach(function (person) { if (!directory.some(function (saved) { return String(saved.faceId) === String(person.faceId); })) { directory.push({ faceId: person.faceId, employeeNo: person.employeeNo, name: person.name }); } }); state.faceDirectory = directory; state.facePeople = directory.map(function (person) { var match = registered.filter(function (item) { return String(item.faceId) === String(person.faceId); })[0]; return { faceId: String(person.faceId || ""), employeeNo: String(person.employeeNo || ""), name: String(person.name || ""), registered: Boolean(match) }; }); savePersisted(); if (renderAfter !== false) render(); } function addFacePerson() { var employeeNo = String(state.faceEnrollment.employeeNo || "").trim(); var name = String(state.faceEnrollment.name || "").trim(); if (!employeeNo || !name) { showToast("请填写员工工号和姓名"); return; } if ((state.faceDirectory || []).some(function (person) { return String(person.employeeNo) === employeeNo; })) { showToast("该员工工号已存在,请在人员列表中操作"); return; } var faceId = "FACE-" + employeeNo; state.faceDirectory.push({ faceId: faceId, employeeNo: employeeNo, name: name }); state.faceEnrollment = { employeeNo: "", name: "", selectedFaceId: faceId, capturing: false, message: "人员信息已保存,请继续采集人脸" }; savePersisted(); refreshFacePeople(false); showToast("人员信息已新增,请继续录入人脸"); render(); } function resetFaceForm() { state.faceEnrollment.employeeNo = ""; state.faceEnrollment.name = ""; render(); } function editFacePerson(target) { state.faceEnrollment = { employeeNo: "", name: "", selectedFaceId: String(target.getAttribute("data-face-id") || ""), capturing: false, message: "请让该人员正对设备摄像头,然后点击“开始采集人脸”" }; render(); } function cancelFaceEnrollment() { state.faceEnrollment.selectedFaceId = ""; state.faceEnrollment.capturing = false; state.faceEnrollment.message = ""; render(); } function openFaceCapture() { if (!state.faceEnrollment.selectedFaceId) { showToast("请先选择需要录入人脸的人员"); return; } state.faceEnrollment.capturing = true; state.faceEnrollment.message = "正在启动 SeetaFace6,请正对摄像头"; render(); window.setTimeout(function () { var bridge = nativeBridge(); try { if (!bridge || typeof bridge.prepareFaceEnrollment !== "function") { throw new Error("当前版本未加载 SeetaFace6 人脸采集模块"); } if (bridge.prepareFaceEnrollment("{}") === false) { throw new Error("SeetaFace6 人脸采集启动失败"); } syncFacePreviewLayout(true); } catch (error) { state.faceEnrollment.message = error.message || "SeetaFace6 人脸采集启动失败"; addLog("人脸", "准备录入失败", state.faceEnrollment.message); showToast(state.faceEnrollment.message); render(); } }, 80); } function closeFaceCapture() { state.faceEnrollment.capturing = false; render(); } function syncFacePeoplePlaceholder() { addLog("人脸", "同步人员信息", "服务器接口暂未开发完成"); showToast("同步接口暂未开发完成,当前为预留功能"); } function applyFaceMatchThreshold(notify) { var requested = Number(state.config.face && state.config.face.matchThreshold); if (!isFinite(requested)) requested = 0.75; requested = Math.round(Math.max(0.50, Math.min(0.95, requested)) * 100) / 100; state.config.face.matchThreshold = requested; var bridge = nativeBridge(); try { if (!bridge || typeof bridge.setFaceMatchThreshold !== "function") { throw new Error("原生人脸模块不支持动态阈值"); } var result = JSON.parse(String(bridge.setFaceMatchThreshold(JSON.stringify({ threshold: requested })) || "{}")); if (!result.ok) throw new Error(result.error || "阈值设置失败"); state.config.face.matchThreshold = Number(result.threshold || requested); if (notify) showToast("人脸匹配阈值已设置为 " + state.config.face.matchThreshold.toFixed(2)); return true; } catch (error) { if (notify) showToast(error.message || "人脸匹配阈值设置失败"); return false; } } function saveFaceMatchThreshold() { var input = document.querySelector("[data-face-threshold]"); var value = Number(input && input.value); if (!isFinite(value) || value < 0.50 || value > 0.95) { showToast("请输入 0.50~0.95 之间的阈值"); return; } state.config.face.matchThreshold = Math.round(value * 100) / 100; if (applyFaceMatchThreshold(true)) { savePersisted(); render(); } } function deleteFacePerson(target) { var bridge = nativeBridge(); var faceId = String(target.getAttribute("data-face-id") || ""); var registered = target.getAttribute("data-registered") === "true"; if (!faceId) return; if (!window.confirm("确认删除该人员及其人脸数据?")) return; try { if (registered && bridge && typeof bridge.deleteFacePerson === "function") { var result = JSON.parse(String(bridge.deleteFacePerson(JSON.stringify({ faceId: faceId })) || "{}")); if (!result.ok) throw new Error(result.error || "原生人脸数据删除失败"); } else if (registered) { throw new Error("当前版本未加载人脸识别模块"); } state.faceDirectory = (state.faceDirectory || []).filter(function (person) { return String(person.faceId) !== faceId; }); if (String(state.faceEnrollment.selectedFaceId || "") === faceId) cancelFaceEnrollment(); savePersisted(); refreshFacePeople(false); showToast("人员已删除"); render(); } catch (error) { showToast("删除失败:" + (error.message || error)); } } function startAuth(source, identityRaw, identityType) { var priority = authPriority(source); var requestToken; var reason = homeBlockReason(); if (reason) { showToast(reason); return; } if (state.screen !== "home" && state.screen !== "auth") return; if (state.screen === "auth" && priority <= Number(state.auth.priority || 0)) return; if (!identityRaw) { showToast(source === "face" ? "等待人脸识别结果" : source === "employee" ? "请选择工号" : "请刷 IC/ID 卡"); return; } var identity = identityRaw; if (isLikelyIcCard(identity)) { source = "card"; identityType = 1; } var resolvedIdType = serverIdentityType(source, identityType); window.clearTimeout(timers.success); state.screen = "auth"; state.auth = { status: "reading", priority: priority, token: Date.now() + "-" + Math.random(), message: source === "face" ? "已识别人脸,正在核对本地人脸库。" : source === "card" ? "已读取 IC/ID 卡,正在向门口终端机鉴权。" : "请稍候,正在建立用户会话。" }; requestToken = state.auth.token; render(); window.setTimeout(function () { if (state.screen !== "auth" || state.auth.token !== requestToken) return; if (!state.deviceStatus.networkOnline || !state.deviceStatus.tcpConnected) { failAuthAndReturn(t("serviceDown")); return; } var member = source === "face" ? verifyFaceIdentity(identity) : verifyNativeMember(source, identity, resolvedIdType); if (!member || !member.ok) { failAuthAndReturn(authFailureMessage(source, member && member.error)); return; } var identifiedAt = Date.now(); state.session = { user: { account: member && member.memberCode ? member.memberCode : accountFromIdentity(identity), employeeNo: member && (member.employeeNo || member.memberCode) ? (member.employeeNo || member.memberCode) : accountFromIdentity(identity), memberId: member && member.memberId ? member.memberId : "", name: member && member.name ? member.name : "员工", identityRaw: identity, identifiedAt: identifiedAt, identifiedAtText: nowText(identifiedAt), balance: member && member.balance != null ? Number(member.balance) : 0, balanceText: member && member.balance != null ? String(member.balance) : "0.00" }, source: source, idType: resolvedIdType }; openWorkInfo(true); }, 850); } function openWorkInfo(preserveSession) { var reason = homeBlockReason(); if (reason) { showToast(reason); return; } window.clearTimeout(timers.success); window.clearTimeout(timers.workInfoTimeout); window.clearTimeout(timers.selectTimeout); if (!preserveSession) state.session = null; state.delivery = null; state.screen = "workInfo"; state.workInfo = defaultWorkInfo(); if (state.session && state.session.user) { state.workInfo.person = state.session.user.name || ""; } render(); fetchWorkContext(); startWorkInfoTimeout(); } function fetchWorkContext() { var bridge = nativeBridge(); var requestId = ++workContextRequestId; startWorkInfoTimeout(); state.workInfo.loading = true; state.workInfo.error = ""; state.workInfo.pickerField = ""; render(); window.setTimeout(function () { var options = null; if (requestId !== workContextRequestId || state.screen !== "workInfo") return; if (bridge && typeof bridge.fetchWorkContext === "function") { try { options = normalizeWorkContext(JSON.parse(String(bridge.fetchWorkContext("{}") || "[]"))); addLog("传输", "已获取投放信息接口数据", options.length + " 台机台"); } catch (error) { addLog("传输", "投放信息接口解析失败", error.message || error); } } if (!options) { options = []; state.workInfo.error = "投放信息接口暂不可用,可手动输入机台号"; } if (requestId !== workContextRequestId || state.screen !== "workInfo") return; state.workInfo.loading = false; state.workInfo.loaded = true; state.workInfo.options = options; if (!state.workInfo.machineNo && options[0]) state.workInfo.machineNo = options[0].machineNo; syncWorkInfoForMachine(false); startWorkInfoTimeout(); render(); }, 350); } function scheduleRender() { window.setTimeout(function () { render(); }, 0); } function applyWorkInfoInput(target) { var field = target.getAttribute("data-work-field"); var linked = false; if (!field || !state.workInfo) return false; startWorkInfoTimeout(); state.workInfo[field] = target.value; state.workInfo.error = ""; if (field === "machineNo" && syncWorkInfoForMachine(true)) { linked = true; } return linked; } function openWorkPicker(target) { var field = target.getAttribute("data-picker-field") || "machineNo"; if (!state.workInfo || state.workInfo.loading) return; startWorkInfoTimeout(); state.workInfo.pickerField = field; render(); } function closeWorkPicker() { if (!state.workInfo) return; startWorkInfoTimeout(); state.workInfo.pickerField = ""; render(); } function pickWorkValue(target) { var field = target.getAttribute("data-picker-field") || ""; var value = target.getAttribute("data-picker-value") || ""; if (!state.workInfo || !field || !value) return; startWorkInfoTimeout(); state.workInfo[field] = value; state.workInfo.error = ""; state.workInfo.pickerField = ""; if (field === "machineNo") syncWorkInfoForMachine(true); render(); } function submitWorkInfo() { var info = state.workInfo || defaultWorkInfo(); if (info.loading) { showToast("正在获取投放信息,请稍候"); startWorkInfoTimeout(); return; } info.machineNo = cleanWorkText(info.machineNo, 48); if (!info.machineNo) { startWorkInfoTimeout(); info.error = "请输入或选择机台号"; state.workInfo = info; render(); return; } if (!state.session || !state.session.user) { showToast("员工身份已失效,请重新登录"); goHome(); return; } window.clearTimeout(timers.success); window.clearTimeout(timers.workInfoTimeout); state.session.workInfo = { machineNo: info.machineNo, person: state.session.user.name || "", employeeName: state.session.user.name || "", employeeNo: state.session.user.employeeNo || state.session.user.account || "", identifiedAt: state.session.user.identifiedAt || 0, batch: "" }; addLog("窗口", "投放信息已确认", info.machineNo + " / " + state.session.workInfo.employeeName + " / " + state.session.workInfo.employeeNo); enterSelectScreen(); } function startWorkInfoTimeout() { window.clearTimeout(timers.workInfoTimeout); timers.workInfoTimeout = window.setTimeout(function () { if (state.screen === "workInfo") { showToast("填写投放信息超时"); goHome(); } }, WORK_INFO_TIMEOUT_MS); } function startSelectTimeout() { window.clearTimeout(timers.selectTimeout); timers.selectTimeout = window.setTimeout(function () { if (state.screen === "select") { showToast("选择投口超时"); goHome(); } }, 15000); } function accountFromIdentity(identity) { var text = String(identity || "").replace(/\s+/g, ""); var digits = text.replace(/\D+/g, ""); if (digits.length >= 8) return digits.slice(-11) || digits; return text.slice(0, 24) || "UNKNOWN"; } function handleReaderKey(event) { if (state.screen !== "home") return; if (state.employeeLogin && state.employeeLogin.open) return; if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.key === "Enter") { event.preventDefault(); flushReaderBuffer(); return; } if (event.key === "Escape") { readerBuffer = ""; return; } if (event.key.length !== 1) return; var now = Date.now(); if (now - readerLastAt > 650) readerBuffer = ""; readerLastAt = now; readerBuffer += event.key; window.clearTimeout(timers.reader); timers.reader = window.setTimeout(flushReaderBuffer, 320); } function flushReaderBuffer() { var identity = readerBuffer.trim(); readerBuffer = ""; window.clearTimeout(timers.reader); if (state.screen !== "home" || identity.length < 4) return; startAuth(inferIdentitySource(identity), identity); } function inferIdentitySource(identity) { var text = String(identity || "").trim(); if (/card|ic/i.test(text)) return "card"; if (isLikelyIcCard(text)) return "card"; return "qr"; } function selectPort(port) { port = Number(port); var info; var bridge = nativeBridge(); if (state.screen !== "select" || state.delivery) return; if (!state.session || !state.session.workInfo) { window.clearTimeout(timers.selectTimeout); addLog("窗口", "投口选择失败", "投放信息会话不存在"); showToast("投放信息已失效,请重新登陆"); goHome(); return; } if (enabledPorts().indexOf(port) < 0) { showToast("投口编号异常"); startSelectTimeout(); return; } info = ensurePortStatus(port); if (!info || isPortBlocked(port)) { var reason = portBlockReason(port); showToast(reason ? port + "号投口" + reason : t("full")); return; } state.session.selectedPort = port; state.session.selectedType = state.config.portTypes[port] || "other"; if (!bridge) { showToast("原生串口模块不可用,无法打开投口"); startSelectTimeout(); return; } state.delivery = createHardwareDelivery([port], false); window.clearTimeout(timers.selectTimeout); state.screen = "delivery"; render(); startBaselineBeforeOpen(); } function openAllPorts() { var ports = enabledPorts(); var bridge = nativeBridge(); if (state.screen !== "select" || state.delivery) return; if (!state.session || !state.session.workInfo) { window.clearTimeout(timers.selectTimeout); addLog("窗口", "全部投口打开失败", "投放信息会话不存在"); showToast("投放信息已失效,请重新登陆"); goHome(); return; } if (ports.some(isPortBlocked)) { showToast(t("allPortsBlocked")); startSelectTimeout(); return; } state.session.selectedPort = 0; state.session.selectedType = "allPorts"; if (!bridge) { showToast("原生串口模块不可用,无法打开投口"); startSelectTimeout(); return; } state.delivery = createHardwareDelivery(ports, true); window.clearTimeout(timers.selectTimeout); state.screen = "delivery"; render(); startBaselineBeforeOpen(); } function createHardwareDelivery(ports, allPorts) { var beforeWeights = {}; ports.forEach(function (port) { var status = ensurePortStatus(port); beforeWeights[port] = cleanWeightNumber(status && status.weightKnown && status.weight != null ? status.weight : 0); }); return { port: allPorts ? 0 : Number(ports[0]), ports: ports.slice(), allPorts: Boolean(allPorts), weightItems: {}, pendingWeightPorts: allPorts ? ports.slice() : [], baselinePendingPorts: ports.slice(), beforeWeights: beforeWeights, weightSamples: {}, baselineWaiting: false, secondsLeft: 30, opened: false, closing: false, manualClose: false, realHardware: true }; } function startBaselineBeforeOpen() { var delivery = state.delivery; var pending; if (!delivery || !delivery.realHardware || delivery.opened || delivery.closing) return; window.clearTimeout(timers.baselineWeight); delivery.baselineWaiting = true; pending = (delivery.baselinePendingPorts || []).slice(); pending.forEach(function (port) { if (!nativeBooleanCallSilent("queryWeight", serialPayload({ port: port }))) { delivery.baselinePendingPorts = delivery.baselinePendingPorts.filter(function (pendingPort) { return Number(pendingPort) !== Number(port); }); addLog("串口", "投放前重量查询发送失败", port + "号投口 / 使用缓存值"); } }); render(); if (!delivery.baselinePendingPorts.length) { openHardwareDeliveryAfterBaseline(false); return; } timers.baselineWeight = window.setTimeout(function () { openHardwareDeliveryAfterBaseline(true); }, BASELINE_WEIGHT_TIMEOUT_MS); } function openHardwareDeliveryAfterBaseline(timedOut) { var delivery = state.delivery; var ports; var failed = false; if (!delivery || !delivery.realHardware || delivery.opened || delivery.closing || !delivery.baselineWaiting) return; window.clearTimeout(timers.baselineWeight); ports = (delivery.ports || []).slice(); if (timedOut && delivery.baselinePendingPorts.length) { addLog("串口", "投放前重量读取超时", delivery.baselinePendingPorts.join("、") + "号投口 / 使用缓存值"); } delivery.baselineWaiting = false; delivery.baselinePendingPorts = []; ports.forEach(function (port) { if (!nativeBooleanCall("openThrowDoor", serialPayload({ port: port }))) { failed = true; addLog("串口", delivery.allPorts ? "全部投口开门失败" : "开门指令发送失败", port + "号投口"); } }); if (failed) { nativeBooleanCallSilent("closeThrowDoor", serialPayload({ closeAll: true, ports: allPhysicalPorts() })); state.delivery = null; state.deviceStatus.serialOnline = false; state.screen = "select"; showToast(delivery.allPorts ? t("openAllPortsFailed") : "串口开门指令发送失败,请检查连接后重试"); render(); startSelectTimeout(); return; } state.deviceStatus.serialOnline = true; delivery.opened = true; if (delivery.allPorts) showToast(t("allPortsOpened")); render(); startDeliveryTimer(); } function captureDeliveryBaseline(payload) { var delivery = state.delivery; var port = Number(payload && payload.port || 0); var pending; var weight; if (!delivery || delivery.closing || !payload || !payload.queryResponse || !port) return false; pending = delivery.baselinePendingPorts || []; if (pending.indexOf(port) < 0) return false; weight = payload.afterWeight != null ? Number(payload.afterWeight) : Number(payload.weight); if (!isFinite(weight)) return false; delivery.beforeWeights[port] = cleanWeightNumber(Math.max(0, weight)); delivery.baselinePendingPorts = pending.filter(function (pendingPort) { return Number(pendingPort) !== port; }); addLog("串口", "锁定投放前重量", port + "号投口 / " + delivery.beforeWeights[port] + "kg"); if (delivery.baselineWaiting && !delivery.baselinePendingPorts.length) { openHardwareDeliveryAfterBaseline(false); } return true; } function startDeliveryTimer() { window.clearInterval(timers.delivery); timers.delivery = window.setInterval(function () { if (!state.delivery || state.delivery.closing) return; state.delivery.secondsLeft -= 1; if (state.delivery.secondsLeft <= 0) { closeDoor(false); } else { render(); } }, 1000); } function delayCloseDoor() { if (!state.delivery || state.delivery.closing || !state.delivery.opened || state.delivery.delayClose) return; state.delivery.secondsLeft = DELAY_CLOSE_SECONDS; state.delivery.delayClose = true; addLog("窗口", "已延时关门", "倒计时重置为" + DELAY_CLOSE_SECONDS + "秒"); showToast("已延时关门,倒计时已重置为" + DELAY_CLOSE_SECONDS + "秒"); render(); startDeliveryTimer(); } function deliveryWeightPorts() { if (!state.delivery) return []; if (state.delivery.allPorts) return (state.delivery.ports || allPhysicalPorts()).slice(); return [Number(state.delivery.port || state.session.selectedPort || 1)]; } function closeDoor(manual) { var bridge = nativeBridge(); var closePorts = allPhysicalPorts(); var weightPorts; if (!state.delivery || state.delivery.closing || !state.delivery.opened) return; window.clearInterval(timers.delivery); state.delivery.manualClose = Boolean(manual); weightPorts = deliveryWeightPorts(); if (!bridge || !nativeBooleanCall("closeThrowDoor", serialPayload({ closeAll: true, ports: closePorts }))) { state.deviceStatus.serialOnline = false; state.delivery.closing = false; state.delivery.secondsLeft = Math.max(5, Number(state.delivery.secondsLeft || 0)); addLog("串口", "关门指令发送失败", "全部投口"); showToast("串口关门指令发送失败,请重试"); render(); startDeliveryTimer(); return; } state.delivery.closing = true; render(); startStableWeightSampling(weightPorts); showToast("已发送关门指令,等待秤盘稳定"); startWeightTimeout(weightPorts); } function completeHardwareDelivery(item) { var port; var pendingPorts; if (!item || !state.session || !state.delivery) return; if (item.queryResponse) { item = collectStableWeightSample(item); if (!item) return; } if (state.delivery.allPorts) { port = Number(item.port || 0); if (state.delivery.ports.indexOf(port) < 0) { addLog("串口", "忽略非本次投口称重", item.port + "号投口"); return; } pendingPorts = state.delivery.pendingWeightPorts || []; if (pendingPorts.indexOf(port) < 0 && state.delivery.weightItems && state.delivery.weightItems[port]) { addLog("串口", "忽略重复称重回包", port + "号投口"); return; } state.delivery.weightItems[port] = item; state.delivery.pendingWeightPorts = pendingPorts.filter(function (pendingPort) { return Number(pendingPort) !== port; }); if (!state.delivery.pendingWeightPorts.length) { completeDeliveryWithWeights({ allPorts: true, ports: state.delivery.ports, items: state.delivery.weightItems }); } return; } if (item.port && Number(item.port) !== Number(state.delivery.port)) { addLog("串口", "忽略非本次投口称重", item.port + "号投口"); return; } completeDeliveryWithWeights(item); } function completeDelivery() { if (!state.delivery || !state.session) return; completeDeliveryWithWeights({}); } function completeDeliveryWithWeights(item) { if (!state.delivery || !state.session) return; window.clearTimeout(timers.weightTimeout); window.clearTimeout(timers.weightSample); var repeatAfterClose = Boolean(state.delivery.repeatAfterClose); var isAllPorts = Boolean((item && item.allPorts) || state.delivery.allPorts); var ports = isAllPorts ? ((item && item.ports) || state.delivery.ports || allPhysicalPorts()).slice() : []; var port = isAllPorts ? 0 : Number(item.port || (state.delivery && state.delivery.port) || state.session.selectedPort || 1); var typeKey = isAllPorts ? "allPorts" : state.config.portTypes[port] || state.session.selectedType || "other"; var portStatus = isAllPorts ? null : ensurePortStatus(port); var beforeWeights = state.delivery.beforeWeights || {}; var weights = isAllPorts ? resolveAllDeliveryWeights(ports, (item && item.items) || state.delivery.weightItems || {}, beforeWeights) : resolveDeliveryWeights(portStatus, item, beforeWeights[port]); var portDetails = isAllPorts ? weights.details || [] : [deliveryPortDetail(port, weights)]; var work = sessionWorkInfo(); if (!isAllPorts && portStatus) { portStatus.weight = weights.after; portStatus.weightKnown = true; } var record = { id: "D" + Date.now(), kind: "delivery", kindText: "投递记录", createdAt: Date.now(), status: "pending", summary: (isAllPorts ? t("allPorts") : port + " 号投口") + " / " + kg(weights.delta) + " / " + deliveryGarbageName(typeKey, port), payload: { deviceNo: String(state.config.device.deviceNo || ""), deviceUniqueId: String(state.config.device.terminalId || state.lowerImei || ""), deviceName: "星元智灵回收箱", deliveryTime: nowText(Date.now()), createdAt: Date.now(), employeeName: work.employeeName || work.person || "", employeeNo: work.employeeNo || "", identifiedAt: work.identifiedAt || 0, idType: state.session.idType || 0, idContent: (state.session.user && state.session.user.identityRaw) || "", machineNo: work.machineNo, person: work.person, productionBatch: work.batch, port: port, ports: isAllPorts ? ports : [port], garbageTypeName: deliveryGarbageName(typeKey, port), typeCode: deliveryGarbageCode(typeKey, port), portDetails: portDetails, beforeWeight: weights.before, afterWeight: weights.after, weight: weights.delta } }; var uploaded = reportDeliveryResult(record); record.status = uploaded ? "sent" : "queued"; if (!uploaded) enqueue(record); state.session.result = { port: port, ports: isAllPorts ? ports : [port], allPorts: isAllPorts, typeKey: typeKey, weight: weights.delta, portDetails: portDetails, uploaded: uploaded, backupRecord: record }; state.delivery = null; state.screen = "success"; savePersisted(); saveResultLocalBackup(); if (repeatAfterClose) { showToast("投口已关闭,请继续下一次投放"); openWorkInfo(true); return; } render(); timers.success = window.setTimeout(function () { goHome(); }, 10000); } function startWeightTimeout(ports) { var targetPorts = Array.isArray(ports) ? ports.slice() : [Number(ports)]; window.clearTimeout(timers.weightTimeout); timers.weightTimeout = window.setTimeout(function () { if (!state.delivery || !state.delivery.closing) return; if (state.delivery.allPorts) { var sampledItems = stableWeightItemsFromSamples(targetPorts); addLog("串口", "全部投口称重回包超时", "未返回投口按0kg记录"); showToast("称重回包超时,未返回投口按0kg记录"); completeDeliveryWithWeights({ allPorts: true, ports: targetPorts, items: Object.assign({}, sampledItems, state.delivery.weightItems || {}), timeout: true }); return; } if (Number(state.delivery.port) !== Number(targetPorts[0])) return; var sampledItem = stableWeightItemFromSamples(targetPorts[0]); if (sampledItem) { addLog("串口", "称重采样不足,使用已有稳定值", targetPorts[0] + "号投口"); completeDeliveryWithWeights(sampledItem); return; } addLog("串口", "称重回包超时,未记录新增重量", targetPorts[0] + "号投口"); showToast("称重回包超时,未记录新增重量"); completeDeliveryWithWeights({ port: targetPorts[0], timeout: true }); }, 10000); } function startStableWeightSampling(ports) { var round = 0; var targetPorts = ports.slice(); window.clearTimeout(timers.weightSample); function sampleNextRound() { if (!state.delivery || !state.delivery.closing) return; targetPorts.forEach(function (port) { if (!nativeBooleanCallSilent("queryWeight", serialPayload({ port: port }))) { addLog("串口", "称重查询指令发送失败", port + "号投口"); } }); round += 1; if (round < WEIGHT_SAMPLE_COUNT) { timers.weightSample = window.setTimeout(sampleNextRound, WEIGHT_SAMPLE_INTERVAL_MS); } } timers.weightSample = window.setTimeout(sampleNextRound, WEIGHT_SETTLE_DELAY_MS); } function collectStableWeightSample(item) { var delivery = state.delivery; var port = Number(item && item.port || 0); var value = Number(item && item.afterWeight != null ? item.afterWeight : item && item.weight); if (!delivery || !port || !isFinite(value)) return null; if (!delivery.weightSamples) delivery.weightSamples = {}; if (!delivery.weightSamples[port]) delivery.weightSamples[port] = []; delivery.weightSamples[port].push(Math.max(0, value)); if (delivery.weightSamples[port].length < WEIGHT_SAMPLE_COUNT) return null; return stableWeightItemFromSamples(port); } function stableWeightItemFromSamples(port) { var samples = state.delivery && state.delivery.weightSamples && state.delivery.weightSamples[port]; var sorted; var value; if (!samples || !samples.length) return null; sorted = samples.slice().sort(function (a, b) { return a - b; }); value = cleanWeightNumber(sorted[Math.floor(sorted.length / 2)]); return { port: Number(port), weight: value, beforeWeight: value, afterWeight: value, queryResponse: true, stableSamples: samples.length }; } function stableWeightItemsFromSamples(ports) { var items = {}; ports.forEach(function (port) { var item = stableWeightItemFromSamples(port); if (item) items[port] = item; }); return items; } function resolveDeliveryWeights(portStatus, item, lockedBeforeWeight) { var hasLockedBefore = isFinite(Number(lockedBeforeWeight)); var before = hasLockedBefore ? Number(lockedBeforeWeight) : Number(portStatus && portStatus.weight != null ? portStatus.weight : 0); var after = before; var delta = 0; var currentBefore = before; var itemWeight = item && isFinite(Number(item.weight)) ? Number(item.weight) : null; var itemBefore = item && isFinite(Number(item.beforeWeight)) ? Number(item.beforeWeight) : null; var itemAfter = item && isFinite(Number(item.afterWeight)) ? Number(item.afterWeight) : null; var hasThrowWeightRange = item && item.range != null; if (itemBefore != null && itemAfter != null && (hasThrowWeightRange || itemAfter > itemBefore)) { before = itemBefore; after = itemAfter; delta = hasThrowWeightRange && itemWeight != null && itemWeight > 0 ? itemWeight : Math.max(0, itemAfter - itemBefore); } else if (itemAfter != null && itemAfter > currentBefore) { after = itemAfter; delta = itemAfter - currentBefore; } else if (hasThrowWeightRange && itemWeight != null && itemWeight > 0) { delta = itemWeight; after = before + delta; } before = cleanWeightNumber(before); delta = Math.max(0, cleanWeightNumber(delta)); after = cleanWeightNumber(Math.max(before, after)); if (after === before && delta > 0) after = cleanWeightNumber(before + delta); return { before: before, after: after, delta: Math.max(0, cleanWeightNumber(delta || after - before)) }; } function deliveryPortDetail(port, weights) { var typeKey = state.config.portTypes[port] || "other"; weights = weights || { before: 0, after: 0, delta: 0 }; return { port: port, garbageTypeName: garbageName(typeKey, port), typeId: garbageCode(typeKey, port), beforeWeight: cleanWeightNumber(weights.before), afterWeight: cleanWeightNumber(weights.after), weight: cleanWeightNumber(weights.delta) }; } function resolveAllDeliveryWeights(ports, items, beforeWeights) { var total = { before: 0, after: 0, delta: 0 }; var details = []; (Array.isArray(ports) && ports.length ? ports : allPhysicalPorts()).forEach(function (port) { var status = ensurePortStatus(port); var weights = resolveDeliveryWeights( status, items && items[port] ? items[port] : { port: port, timeout: true }, beforeWeights && beforeWeights[port] ); status.weight = weights.after; status.weightKnown = true; total.before += weights.before; total.after += weights.after; total.delta += weights.delta; details.push(deliveryPortDetail(port, weights)); }); return { before: cleanWeightNumber(total.before), after: cleanWeightNumber(total.after), delta: cleanWeightNumber(total.delta), details: details }; } function reportDeliveryResult(record) { var bridge = nativeBridge(); if (bridge && typeof bridge.reportXingyuanDelivery === "function") { return nativeUploadCall("reportXingyuanDelivery", record.payload); } if (bridge && typeof bridge.reportDeliveryResult === "function") { return nativeUploadCall("reportDeliveryResult", record.payload); } addLog("传输", "投放结果上报接口不可用", record.summary); return false; } function sessionBalance() { var user = state.session && state.session.user; if (!user) return 0; return numericMoney(user.balanceText != null ? user.balanceText : user.balance); } function refreshRewardFromServer(balanceBefore) { window.setTimeout(function () { if (!state.session || !state.session.result || state.screen !== "success") return; if (state.session.user && state.session.user.guest) { state.session.result.rewardPending = false; state.session.result.reward = 0; saveResultLocalBackup(balanceBefore); render(); return; } var user = state.session.user || {}; var member = verifyNativeMember(state.session.source, user.identityRaw, state.session.idType); if (!member || !member.ok || member.balance == null) { state.session.result.rewardPending = false; state.session.result.reward = 0; saveResultLocalBackup(balanceBefore); addLog("传输", "奖励余额刷新失败", (member && member.error) || "未返回余额"); render(); return; } var afterBalance = numericMoney(member.balance); var reward = Math.round((afterBalance - numericMoney(balanceBefore)) * 100) / 100; state.session.user.balance = afterBalance; state.session.user.balanceText = String(member.balance); state.session.result.balanceAfter = afterBalance; state.session.result.reward = reward; state.session.result.rewardPending = false; saveResultLocalBackup(afterBalance); addLog("传输", "奖励余额刷新", "投放后余额 " + plainMoney(afterBalance) + " / 奖励 " + plainMoney(reward)); render(); }, 1400); } function localBackupEnabled() { return Boolean(state.config.localBackup && state.config.localBackup.enabled); } function saveResultLocalBackup() { if (!localBackupEnabled() || !state.session || !state.session.result || state.session.result.backupSaved) return; var result = state.session.result; var record = result.backupRecord; if (!record || !record.payload) return; var work = sessionWorkInfo(); var resultDetails = Array.isArray(result.portDetails) && result.portDetails.length ? result.portDetails : []; var payloadDetails = Array.isArray(record.payload.portDetails) && record.payload.portDetails.length ? record.payload.portDetails : []; var details = result.allPorts && (payloadDetails.length || resultDetails.length) ? (payloadDetails.length ? payloadDetails : resultDetails) : [{ port: Number(record.payload.port || result.port || 1), garbageTypeName: record.payload.garbageTypeName, typeId: record.payload.typeCode, weight: record.payload.weight, beforeWeight: record.payload.beforeWeight, afterWeight: record.payload.afterWeight }]; details.forEach(function (detail, index) { var port = Number(detail.port || 1); var typeKey = state.config.portTypes[port] || result.typeKey || "other"; var backup = { id: record.id + "-P" + port + "-" + index, time: nowText(record.createdAt), deliveryTime: nowText(record.createdAt), timestamp: record.createdAt, deviceNo: String(state.config.device.deviceNo || ""), deviceUniqueId: String(state.config.device.terminalId || state.lowerImei || ""), deviceName: "星元智灵回收箱", machineNo: work.machineNo || record.payload.machineNo || "", person: work.employeeName || work.person || record.payload.employeeName || record.payload.person || "", employeeNo: work.employeeNo || record.payload.employeeNo || "", productionBatch: work.batch || record.payload.productionBatch || "", port: String(port), garbageTypeName: backupGarbageName(detail, typeKey, port), typeId: detail.typeId || detail.typeCode || garbageCode(typeKey, port), weight: formatBackupWeight(detail.weight), beforeWeight: formatBackupWeight(detail.beforeWeight), afterWeight: formatBackupWeight(detail.afterWeight), uploadStatus: record.status || "sent" }; state.localBackups.unshift(backup); persistNativeLocalBackup(backup); }); if (state.localBackups.length > 500) state.localBackups.length = 500; result.backupSaved = true; savePersisted(); } function formatBackupWeight(value) { return String(cleanWeightNumber(value || 0)); } function backupGarbageName(detail, typeKey, port) { var name = String((detail && detail.garbageTypeName) || "").trim(); return name && !isAllPortsName(name) ? name : garbageName(typeKey, port); } function isAllPortsName(name) { return ["全部投口", "All Chutes"].indexOf(String(name || "").trim()) >= 0; } function backupUserText(user) { user = user || {}; var account = String(user.account || "GUEST"); var name = String(user.name || ""); return name ? account + "/" + name : account; } function persistNativeLocalBackup(backup) { var bridge = nativeBridge(); if (!bridge || typeof bridge.saveLocalBackup !== "function") { addLog("存储", "本地备份已写入应用缓存", backup.id); return false; } try { var path = String(bridge.saveLocalBackup(JSON.stringify({ record: backup })) || ""); if (path) { if (!state.config.localBackup) state.config.localBackup = { enabled: false, path: "" }; state.config.localBackup.path = path; addLog("存储", "本地备份已保存", path); return true; } } catch (error) { addLog("存储", "本地备份写入异常", error.message || error); return false; } addLog("存储", "本地备份写入失败", backup.id); return false; } function exportLogs() { var bridge = nativeBridge(); var payload = { exportedAt: Date.now(), exportedAtText: nowText(Date.now()), appVersion: state.config.appVersion, deviceNo: state.config.device.deviceNo || "", terminalId: state.config.device.terminalId || "", logs: state.logs.slice().reverse() }; var path = ""; if (!bridge || typeof bridge.exportLogs !== "function") { addLog("存储", "日志导出失败", "原生导出接口未接入"); showToast(t("logExportFailed")); return false; } try { path = String(bridge.exportLogs(JSON.stringify(payload)) || ""); if (path) { addLog("存储", "日志已导出", path); showToast(t("logExported") + ":" + path); return true; } } catch (error) { addLog("存储", "日志导出异常", error.message || error); } showToast(t("logExportFailed")); return false; } function loadNativeLocalBackups() { var bridge = nativeBridge(); if (!bridge || typeof bridge.readLocalBackups !== "function") return; try { var raw = bridge.readLocalBackups("{}"); var records = JSON.parse(String(raw || "[]")); if (!Array.isArray(records) || !records.length) return; var seen = {}; state.localBackups.forEach(function (item) { if (item && item.id) seen[item.id] = true; }); records.reverse().forEach(function (item) { if (item && item.id && !seen[item.id]) { state.localBackups.unshift(item); seen[item.id] = true; } }); if (state.localBackups.length > 500) state.localBackups.length = 500; addLog("存储", "已读取本地备份", records.length + " 条"); } catch (error) { addLog("存储", "读取本地备份失败", error.message || error); } } function canUpload() { return ( state.deviceStatus.networkOnline && state.deviceStatus.tcpConnected ); } function nativeBridge() { return window.XingyuanNative || window.ZhitouNative || null; } function nativeLogKind(method) { if (/report|Heartbeat|configure|verifyMember/.test(method)) return "传输"; if (/Serial|Imei|Version|ThrowDoor|ReceiveDoor|Weight/.test(method)) return "串口"; return "窗口"; } function nativeLogMessage(method) { var labels = { configure: "配置上报连接", startHeartbeat: "启动心跳 1001", reportMemberVerify: "发送会员校验 4301", reportDelivery: "发送投递记录 6011", reportXingyuanDelivery: "发送星元智灵投放结果", reportDeliveryResult: "发送投放结果", fetchWorkContext: "获取投放信息", exportLogs: "导出日志", reportDeviceStatus: "发送设备状态 2003", reportAlarm: "发送报警 6006", verifyMember: "请求会员校验 4301", checkSerial: "串口自检", getLowerImei: "读取下位机IMEI", getLowerVersion: "读取下位机版本", openThrowDoor: "发送开门指令", openReceiveDoor: "发送清运门开启指令", closeThrowDoor: "发送关门指令", queryWeight: "查询下位机称重", pingHost: "Ping 网络 www.baidu.com", openSystemSettings: "打开系统设置", openFileManager: "打开文件管理" }; return labels[method] || method; } function nativeCall(method, payload) { var bridge = nativeBridge(); if (!bridge || typeof bridge[method] !== "function") { addLog(nativeLogKind(method), nativeLogMessage(method) + "未接入"); return false; } try { bridge[method](JSON.stringify(payload || {})); addLog(nativeLogKind(method), nativeLogMessage(method) + "已调用"); return true; } catch (error) { addLog(nativeLogKind(method), nativeLogMessage(method) + "调用失败", error.message || error); return false; } } function nativeUploadCall(method, payload) { var bridge = nativeBridge(); var result; if (!bridge || typeof bridge[method] !== "function") { addLog(nativeLogKind(method), nativeLogMessage(method) + "未接入"); return false; } try { result = bridge[method](JSON.stringify(payload || {})); if (result === false || String(result).toLowerCase() === "false") { addLog(nativeLogKind(method), nativeLogMessage(method) + "失败"); return false; } addLog(nativeLogKind(method), nativeLogMessage(method) + "已调用"); return true; } catch (error) { addLog(nativeLogKind(method), nativeLogMessage(method) + "调用失败", error.message || error); return false; } } function nativeBooleanCall(method, payload) { var bridge = nativeBridge(); if (!bridge || typeof bridge[method] !== "function") { addLog(nativeLogKind(method), nativeLogMessage(method) + "未接入"); return false; } try { var ok = bridge[method](JSON.stringify(payload || {})) === true; addLog(nativeLogKind(method), nativeLogMessage(method) + (ok ? "成功" : "失败")); return ok; } catch (error) { addLog(nativeLogKind(method), nativeLogMessage(method) + "异常", error.message || error); return false; } } function nativeBooleanCallSilent(method, payload) { var bridge = nativeBridge(); if (!bridge || typeof bridge[method] !== "function") return false; try { return bridge[method](JSON.stringify(payload || {})) === true; } catch (error) { return false; } } function nativeCallSilent(method, payload) { var bridge = nativeBridge(); if (!bridge || typeof bridge[method] !== "function") return false; try { bridge[method](JSON.stringify(payload || {})); return true; } catch (error) { return false; } } function serialPayload(extra) { var payload = { path: state.config.device.serialPath || "/dev/ttyS4", baudRate: Number(state.config.device.baudRate || 9600) }; Object.keys(extra || {}).forEach(function (key) { payload[key] = extra[key]; }); return payload; } function configureNativeReporter() { nativeCall("configure", { host: state.config.server.tcpHost, port: Number(state.config.server.tcpPort || DEFAULT_REPORT_PORT), terminalId: state.config.device.terminalId || "", appVersion: state.config.appVersion, defaultMemberId: state.config.server.defaultMemberId || DEFAULT_MEMBER_ID, packageType: state.config.server.packageType || DEFAULT_PACKAGE_TYPE, httpBase: state.config.server.httpBase || DEFAULT_HTTP_BASE }); } function startNativeHeartbeat() { configureNativeReporter(); nativeCall("startHeartbeat", {}); } function serverIdentityType(source, explicitType) { var parsed = Number(explicitType || 0); if (parsed > 0 && parsed <= 255) return parsed; if (source === "card") return 1; if (source === "employee") return 3; if (source === "face") return 4; return 2; } function isLikelyIcCard(identity) { return /^[0-9A-F]{8}$/i.test(String(identity || "").trim()); } function decodeIdentityPart(value) { try { return decodeURIComponent(String(value || "").replace(/\+/g, "%20")); } catch (error) { return String(value || ""); } } function pickQueryIdentity(text) { var source = String(text || ""); var question = source.indexOf("?"); var query = question >= 0 ? source.slice(question + 1) : source; var parts = query.split(/[&;]/); var keys = { yueng: true, idcontent: true, memberkey: true, resident_id: true, qrcode: true, qr: true, code: true }; var i; for (i = 0; i < parts.length; i += 1) { var part = parts[i]; var eq = part.indexOf("="); if (eq <= 0) continue; var key = part.slice(0, eq).toLowerCase(); if (keys[key]) return decodeIdentityPart(part.slice(eq + 1)).trim(); } return ""; } function normalizeIdentityForServer(source, identityRaw, idType) { var type = serverIdentityType(source, idType); var text = String(identityRaw || "").trim(); if (type === 1 || source === "card") { return text.replace(/\s+/g, ""); } text = text.replace(/\s+/g, ""); if (/^yueng=/i.test(text)) { return decodeIdentityPart(text.slice(text.indexOf("=") + 1)).trim(); } var picked = pickQueryIdentity(text); return picked || text; } function verifyNativeMember(source, identityRaw, idType) { if (!canUpload()) { addLog("传输", "会员校验未发送", t("serviceDown")); return { ok: false, error: t("serviceDown") }; } var bridge = nativeBridge(); if (!bridge || typeof bridge.verifyMember !== "function") { addLog("传输", "会员校验 4301 未接入"); return { ok: false, error: "未接入会员鉴权接口" }; } try { var resolvedType = serverIdentityType(source, idType); var identityContent = normalizeIdentityForServer(source, identityRaw, resolvedType); if (identityContent !== String(identityRaw || "").trim()) { addLog("传输", "二维码内容已按协议清洗", identityContent); } addLog("传输", "发送会员校验 4301", identityContent || ""); var raw = bridge.verifyMember(JSON.stringify({ idType: resolvedType, idContent: identityContent || "", source: source })); var result = JSON.parse(String(raw || "{}")); addLog("传输", result.ok ? "会员校验 4302 通过" : "会员校验失败", result.ok ? result.memberCode || result.memberId || "" : result.error || ""); return result; } catch (error) { addLog("传输", "会员校验返回解析失败", error.message || error); return { ok: false, error: "会员鉴权返回解析失败" }; } } function reportNativeMemberVerify(source, identityRaw, idType) { if (!canUpload()) { addLog("传输", "会员校验未发送", t("serviceDown")); return false; } var resolvedType = serverIdentityType(source, idType); return nativeCall("reportMemberVerify", { idType: resolvedType, idContent: normalizeIdentityForServer(source, identityRaw, resolvedType) || "", source: source }); } function rangeForPort(port) { return sensorHeight(port); } function buildStatusPayload() { return { temperature: portTemperature(1), humidity: 0, signal: 30, deviceEnable: state.deviceStatus.maintenance || state.deviceStatus.deviceDisabled ? 0 : 1, lat: "0", lng: "0", altitude: "0", ports: enabledPorts().map(function (port) { var info = state.deviceStatus.ports[port]; var typeKey = state.config.portTypes[port] || "other"; return { port: port, garbageType: numericGarbageCode(typeKey, port), range: rangeForPort(port), weight: Number(info.weight || 0), temperature: portTemperature(port), overflow: Boolean(info.overflow) }; }) }; } function reportNativeDeviceStatus() { if (!state.deviceStatus.networkOnline) { addLog("传输", "设备状态 2003 未发送", t("serviceDown")); return false; } configureNativeReporter(); return nativeCall("reportDeviceStatus", buildStatusPayload()); } function reportNativeDelivery(record, source) { if (!canUpload()) { addLog("传输", "投递记录 6011 未发送", t("serviceDown")); return false; } addLog("传输", "准备投递记录 6011", record.payload.port + "号投口 / " + record.payload.weight + "kg"); var resolvedType = serverIdentityType(source, record.payload.idType || (state.session && state.session.idType)); return nativeCall("reportDelivery", { idType: resolvedType, idContent: normalizeIdentityForServer(source, record.payload.identityRaw || record.payload.account || "", resolvedType) || "", port: record.payload.port, garbageType: Number(record.payload.typeCode), range: rangeForPort(record.payload.port), beforeWeight: record.payload.beforeWeight, afterWeight: record.payload.afterWeight, throwWeight: record.payload.weight, reward: record.payload.reward, createdAt: record.createdAt }); } function reportNativeAlarms() { if (!canUpload()) return false; var alarms = []; enabledPorts().forEach(function (port) { var info = state.deviceStatus.ports[port]; var typeKey = state.config.portTypes[port] || "other"; if (info.overflow) { alarms.push({ port: port, garbageType: numericGarbageCode(typeKey, port), errorCode: 301, isError: 1, temperature: portTemperature(port) }); } }); if (!alarms.length) return false; return nativeCall("reportAlarm", { alarms: alarms }); } function enqueue(item) { item = sanitizeQueueItem(item); if (!item) { addLog("传输", "缓存入队失败", "数据格式异常"); return; } state.offlineQueue.unshift(item); if (state.offlineQueue.length > 500) state.offlineQueue.length = 500; savePersisted(); } function retryQueuedItem(item) { if (!item || item.kind !== "delivery") { addLog("传输", "跳过未知缓存类型", item && item.kind ? item.kind : "unknown"); return false; } return reportDeliveryResult(item); } function retryCache() { var before; var sent = 0; var remaining = []; cleanupCache(false); state.offlineQueue = sanitizeOfflineQueue(state.offlineQueue); before = state.offlineQueue.length; if (!before) { savePersisted(); showToast(t("cacheRetryEmpty")); render(); return; } if (!canUpload()) { showToast(t("networkUnavailableQueue")); render(); return; } state.offlineQueue.forEach(function (item) { item.status = "retrying"; if (retryQueuedItem(item)) { sent += 1; } else { item.status = "queued"; remaining.push(item); } }); state.offlineQueue = remaining; savePersisted(); if (sent) { showToast( t("cacheRetryPrefix") + sent + t("cacheRetrySuffix") + (remaining.length ? t("cacheRetryRemainPrefix") + remaining.length + t("cacheRetryRemainSuffix") : "") ); } else { showToast(t("cacheRetryFail")); } render(); } function repeatDelivery() { if (!state.delivery || state.delivery.closing || !state.delivery.opened) return; state.delivery.repeatAfterClose = true; addLog("窗口", "再次投放", "先安全关闭当前投口并记录本次重量"); closeDoor(true); } function cleanupCache(notify) { var retainMs = Number(state.config.cache.retainDays || 30) * 24 * 60 * 60 * 1000; var cutoff = Date.now() - retainMs; var before = state.offlineQueue.length; state.offlineQueue = state.offlineQueue.filter(function (item) { return item.createdAt >= cutoff; }); savePersisted(); if (notify) showToast(t("cacheCleanupPrefix") + (before - state.offlineQueue.length) + t("cacheCleanupSuffix")); } function goHome() { window.clearInterval(timers.delivery); window.clearTimeout(timers.success); window.clearTimeout(timers.authFail); window.clearTimeout(timers.workInfoTimeout); window.clearTimeout(timers.selectTimeout); window.clearTimeout(timers.baselineWeight); window.clearTimeout(timers.weightTimeout); window.clearTimeout(timers.weightSample); cancelSettingsPortTelemetry(); window.clearInterval(timers.admin); state.screen = "home"; state.settingsDraft = null; state.portSettingsPort = 0; state.delivery = null; state.auth = { status: "idle", message: "" }; state.employeeLogin = { open: false, employeeNo: "", message: "" }; render(); } function openAdminLogin() { window.clearInterval(timers.admin); state.screen = "adminLogin"; state.adminLogin = { tries: 0, secondsLeft: 30, message: "", value: "", showPassword: false }; render(); timers.admin = window.setInterval(function () { state.adminLogin.secondsLeft -= 1; if (state.adminLogin.secondsLeft <= 0) { showToast(t("adminTimeout")); goHome(); } else if (state.screen === "adminLogin") { render(); } }, 1000); } function submitAdmin() { var password = state.adminLogin.value || ""; if (isAdminPasswordValid(password)) { window.clearInterval(timers.admin); state.screen = "settings"; state.activeTab = "device"; state.settingsDraft = createSettingsDraft(); render(); return; } state.adminLogin.tries += 1; state.adminLogin.message = t("passwordWrong"); state.adminLogin.value = ""; state.adminLogin.showPassword = false; if (state.adminLogin.tries >= 5) { showToast(t("adminTooManyErrors")); goHome(); return; } render(); } function appendAdminDigit(digit) { if (state.screen !== "adminLogin") return; if ((state.adminLogin.value || "").length >= 12) return; state.adminLogin.value = (state.adminLogin.value || "") + String(digit); state.adminLogin.message = ""; render(); } function backspaceAdminDigit() { if (state.screen !== "adminLogin") return; state.adminLogin.value = (state.adminLogin.value || "").slice(0, -1); state.adminLogin.message = ""; render(); } function clearAdminDigits() { if (state.screen !== "adminLogin") return; state.adminLogin.value = ""; state.adminLogin.message = ""; render(); } function toggleAdminPasswordVisibility() { if (state.screen !== "adminLogin") return; state.adminLogin.showPassword = !state.adminLogin.showPassword; render(); } function runNetworkTest() { state.testResults = [{ label: t("networkState"), ok: false, pending: true }]; showToast("正在后台检测网络..."); render(); requestConnectionCheck("network"); } function applyNetworkTestResult(networkState) { var pingOk = Boolean(networkState && networkState.pingOk); var tcpOk = Boolean(networkState && networkState.tcpConnected); refreshNetworkState(networkState || {}, false); state.testResults = [ { label: "Ping www.baidu.com", ok: pingOk }, { label: t("tcpReportParams") + " " + state.config.server.tcpHost + ":" + state.config.server.tcpPort, ok: tcpOk }, { label: t("networkState"), ok: state.deviceStatus.networkOnline } ]; addLog("窗口", state.deviceStatus.networkOnline ? "网络测试通过" : "网络测试失败", tcpOk ? "业务 TCP 可连接" : "业务 TCP 不可连接"); savePersisted(); showToast(state.deviceStatus.networkOnline ? t("networkTestOk") : t("networkTestFail")); render(); } function checkLowerVersion() { window.clearTimeout(timers.lowerVersion); var bridge = nativeBridge(); try { if (bridge && typeof bridge.requestLowerVersion === "function") { state.lowerVersion = "读取中..."; showToast(t("readingLowerVersion")); bridge.requestLowerVersion(JSON.stringify(serialPayload())); timers.lowerVersion = window.setTimeout(function () { if (state.lowerVersion === "读取中...") { state.lowerVersion = "获取失败"; showToast(t("lowerVersionMissing")); render(); } }, 3500); render(); return; } } catch (error) { addLog("串口", "读取下位机版本异常", error.message || error); } state.lowerVersion = "获取失败"; showToast(t("lowerVersionRequestFail")); render(); } function applyLowerImei(imei, silent) { imei = String(imei || "").trim(); if (!/^\d{10,20}$/.test(imei)) return false; window.clearTimeout(timers.lowerImei); state.deviceStatus.serialOnline = true; var changed = state.config.device.terminalId !== imei || state.lowerImei !== imei; state.config.device.terminalId = imei; state.lowerImei = imei; if (changed) { savePersisted(); configureNativeReporter(); window.setTimeout(function () { reportNativeDeviceStatus(); }, 120); } addLog("串口", "获取下位机IMEI", imei); if (!silent) showToast(t("lowerImeiReceived") + ":" + imei); render(); return true; } function applyLowerVersion(version, silent) { version = String(version || "").trim(); if (!version) return false; window.clearTimeout(timers.lowerVersion); state.deviceStatus.serialOnline = true; state.lowerVersion = version; addLog("串口", "获取下位机版本", version); if (!silent) showToast(t("lowerVersionReceived") + ":" + version); render(); return true; } function requestNativeLowerImei(silent) { var bridge = nativeBridge(); var imei = ""; try { if (bridge && typeof bridge.getLowerImei === "function") { imei = String(bridge.getLowerImei(JSON.stringify(serialPayload())) || ""); } } catch (error) { addLog("串口", "读取下位机IMEI异常", error.message || error); return false; } if (/^\d{10,20}$/.test(imei)) { return applyLowerImei(imei, silent); } addLog("串口", "未读取到下位机IMEI", imei || "空回包"); return false; } function getLowerImei() { window.clearTimeout(timers.lowerImei); var bridge = nativeBridge(); try { if (bridge && typeof bridge.requestLowerImei === "function") { state.lowerImei = "读取中..."; showToast(t("readingLowerImei")); bridge.requestLowerImei(JSON.stringify(serialPayload())); timers.lowerImei = window.setTimeout(function () { if (state.lowerImei === "读取中...") { state.lowerImei = "获取失败"; showToast(t("lowerImeiMissing")); render(); } }, 3500); render(); return; } } catch (error) { addLog("串口", "读取下位机IMEI异常", error.message || error); } state.lowerImei = "获取失败"; showToast(t("lowerImeiRequestFail")); render(); } function getLowerInfo() { window.clearTimeout(timers.lowerImei); window.clearTimeout(timers.lowerVersion); window.clearTimeout(timers.lowerInfo); var bridge = nativeBridge(); state.lowerVersion = "读取中..."; state.lowerImei = "读取中..."; try { if (bridge && typeof bridge.requestLowerVersion === "function") { bridge.requestLowerVersion(JSON.stringify(serialPayload())); } else { state.lowerVersion = "获取失败"; } if (bridge && typeof bridge.requestLowerImei === "function") { bridge.requestLowerImei(JSON.stringify(serialPayload())); } else { state.lowerImei = "获取失败"; } } catch (error) { addLog("串口", "读取下位机信息异常", error.message || error); state.lowerVersion = "获取失败"; state.lowerImei = "获取失败"; } showToast(t("readingLowerInfo")); timers.lowerInfo = window.setTimeout(function () { var changed = false; if (state.lowerVersion === "读取中...") { state.lowerVersion = "获取失败"; changed = true; } if (state.lowerImei === "读取中...") { state.lowerImei = "获取失败"; changed = true; } if (changed) { showToast(t("lowerInfoTimeout")); render(); } }, 4000); render(); } function openPasswordChange() { state.passwordChange = { open: true, step: "current", current: "", next: "", confirm: "", message: "" }; render(); } function passwordValueKey() { var flow = state.passwordChange || {}; return flow.step === "next" ? "next" : flow.step === "confirm" ? "confirm" : "current"; } function appendPasswordDigit(digit) { var flow = state.passwordChange; if (!flow || !flow.open) return; var key = passwordValueKey(); if ((flow[key] || "").length >= 12) return; flow[key] = (flow[key] || "") + String(digit); flow.message = ""; render(); } function backspacePasswordDigit() { var flow = state.passwordChange; if (!flow || !flow.open) return; var key = passwordValueKey(); flow[key] = (flow[key] || "").slice(0, -1); flow.message = ""; render(); } function clearPasswordDigits() { var flow = state.passwordChange; if (!flow || !flow.open) return; flow[passwordValueKey()] = ""; flow.message = ""; render(); } function nextPasswordStep() { var flow = state.passwordChange; if (!flow || !flow.open) return; if (flow.step === "current") { if (!isAdminPasswordValid(flow.current)) { flow.current = ""; flow.message = t("passwordCurrentWrong"); } else { flow.step = "next"; flow.message = ""; } render(); return; } if (flow.step === "next") { if (!/^\d{4,12}$/.test(flow.next || "")) { flow.message = t("passwordRule"); } else { flow.step = "confirm"; flow.message = ""; } render(); return; } if (String(flow.confirm) !== String(flow.next)) { flow.confirm = ""; flow.message = t("passwordMismatch"); render(); return; } state.config.adminPassword = String(flow.next); state.passwordChange = { open: false, step: "current", current: "", next: "", confirm: "", message: "" }; savePersisted(); showToast(t("passwordChanged")); render(); } function cancelPasswordChange() { state.passwordChange = { open: false, step: "current", current: "", next: "", confirm: "", message: "" }; render(); } function toggleLocalBackupMode() { if (!state.config.localBackup) state.config.localBackup = { enabled: false, path: "" }; state.config.localBackup.enabled = !state.config.localBackup.enabled; if (state.config.localBackup.enabled) { var bridge = nativeBridge(); try { if (bridge && typeof bridge.getLocalBackupPath === "function") { state.config.localBackup.path = String(bridge.getLocalBackupPath("{}") || state.config.localBackup.path || ""); } } catch (error) { addLog("存储", "读取本地备份路径失败", error.message || error); } } savePersisted(); showToast(state.config.localBackup.enabled ? t("localBackupEnabledToast") : t("localBackupDisabledToast")); render(); } function toggleMaintenanceMode() { var enabling = !state.deviceStatus.maintenance; state.deviceStatus.maintenance = enabling; state.check.available = Boolean( !enabling && !state.deviceStatus.deviceDisabled && state.deviceStatus.serialOnline && state.deviceStatus.networkOnline && state.deviceStatus.tcpConnected ); if (enabling) { enabledPorts().forEach(function (port) { nativeBooleanCallSilent("openThrowDoor", serialPayload({ port: port })); }); } else { nativeBooleanCallSilent("closeThrowDoor", serialPayload()); } if (enabling && state.screen !== "settings") goHome(); savePersisted(); addLog("窗口", enabling ? "维护模式已启用" : "维护模式已关闭", enabling ? "已发送投放门开启指令" : "已发送投放门关闭指令"); showToast(enabling ? t("maintenanceEnabledToast") : t("maintenanceDisabledToast")); render(); } function toggleDeviceDisabled() { var enabling = !state.deviceStatus.deviceDisabled; state.deviceStatus.deviceDisabled = enabling; if (!enabling) { state.deviceStatus.collectionDoorClosed = true; } state.check.available = Boolean( !enabling && !state.deviceStatus.maintenance && state.deviceStatus.serialOnline && state.deviceStatus.networkOnline && state.deviceStatus.tcpConnected ); if (enabling && state.screen !== "settings") goHome(); savePersisted(); addLog("窗口", enabling ? "设备禁用已启用" : "设备禁用已关闭", enabling ? "仅允许打开清运门" : "恢复正常流程"); showToast(enabling ? t("deviceDisabledEnabledToast") : t("deviceDisabledDisabledToast")); render(); } function openCollectionDoor() { if (!state.deviceStatus.deviceDisabled) { showToast(t("enableDeviceDisabledFirst")); return; } if (!nativeBooleanCall("openReceiveDoor", serialPayload({ port: 1 }))) { showToast(t("collectionDoorCommandFail")); return; } state.deviceStatus.collectionDoorClosed = false; state.check.available = false; savePersisted(); addLog("串口", "已发送清运门开启指令", "0x04"); showToast(t("collectionDoorCommandSent")); render(); } function toggleDebugLog() { if (!state.config.debugLog) state.config.debugLog = { enabled: false }; state.config.debugLog.enabled = !isLogEnabled(); savePersisted(); addLog("窗口", state.config.debugLog.enabled ? "日志窗口已启用" : "日志窗口已禁用"); showToast(state.config.debugLog.enabled ? t("debugLogEnabledToast") : t("debugLogDisabledToast")); render(); } function applyPortSettings(notify, shouldRender) { var draft = ensureSettingsDraft(); var livePorts = state.deviceStatus.ports || {}; state.config.portCount = clampPortCount(draft.config.portCount || 1); state.config.portTypes = cloneData(draft.config.portTypes); state.config.customPortTypes = cloneData(draft.config.customPortTypes); state.deviceStatus.ports = cloneData(draft.deviceStatus.ports); [1, 2, 3, 4].forEach(function (port) { if (!state.deviceStatus.ports[port]) state.deviceStatus.ports[port] = defaultPortStatus(port); if (livePorts[port]) { state.deviceStatus.ports[port].weight = livePorts[port].weight; state.deviceStatus.ports[port].range = livePorts[port].range; state.deviceStatus.ports[port].temperature = normalizeTemperatureValue(livePorts[port].temperature, defaultPortStatus(port).temperature); } }); migratePortConfig(); migratePortStatus(); savePersisted(); if (notify) showToast(t("portSettingsSaved")); if (shouldRender) render(); } function savePortSettings() { applyPortSettings(true, true); } function handleBoundInput(target) { var path = target.getAttribute("data-bind"); if (!path) return; var root = path.indexOf("config.") === 0 ? state : state; var value; if (target.type === "checkbox") { value = target.checked; } else if (target.getAttribute("data-type") === "number") { value = Number(target.value); } else { value = target.value; } setPath(root, path, value); if (path === "config.portCount") state.config.portCount = clampPortCount(value || 1); if (/^config\.customPortTypes\.\d+\.code$/.test(path)) { var parsedCode = Math.max(0, Math.min(255, Number(value || 0))); setPath(root, path, String(parsedCode)); } savePersisted(); } function handleDraftInput(target) { var path = target.getAttribute("data-draft"); if (!path) return; var draft = ensureSettingsDraft(); var value; if (target.type === "checkbox") { value = target.checked; } else if (target.getAttribute("data-type") === "number") { value = Number(target.value); } else { value = target.value; } setPath(draft, path, value); if (path === "config.portCount") { draft.config.portCount = clampPortCount(value || 1); if (state.portSettingsPort > draft.config.portCount) state.portSettingsPort = 0; } if (/^config\.customPortTypes\.\d+\.code$/.test(path)) { var parsedCode = Math.max(0, Math.min(255, Number(value || 0))); setPath(draft, path, String(parsedCode)); } applyPortSettings(false, false); } function bindEvents() { window.addEventListener("resize", function () { syncFacePreviewLayout(isFacePreviewActive()); }); document.addEventListener("scroll", function () { if (isFacePreviewActive()) { syncFacePreviewLayout(true); } }, true); document.addEventListener("click", function (event) { var target = event.target.closest("[data-action]"); if (!target) return; var action = target.getAttribute("data-action"); if (action === "restart-check") beginStartupCheck(); if (action === "enter-home-after-check") enterHomeAfterStartup(true); if (action === "start-auth") startAuth(target.getAttribute("data-source") || "qr"); if (action === "open-work-info") openWorkInfo(false); if (action === "open-employee-login") openEmployeeLogin(); if (action === "close-employee-login") closeEmployeeLogin(); if (action === "select-employee") selectEmployeeForLogin(target); if (action === "submit-employee-login") submitEmployeeLogin(); if (action === "refresh-work-info") fetchWorkContext(); if (action === "submit-work-info") submitWorkInfo(); if (action === "open-work-picker") openWorkPicker(target); if (action === "close-work-picker") closeWorkPicker(); if (action === "pick-work-value") pickWorkValue(target); if (action === "go-home") goHome(); if (action === "admin-login") openAdminLogin(); if (action === "submit-admin") submitAdmin(); if (action === "admin-key") appendAdminDigit(target.getAttribute("data-key")); if (action === "admin-backspace") backspaceAdminDigit(); if (action === "admin-clear") clearAdminDigits(); if (action === "toggle-admin-password") toggleAdminPasswordVisibility(); if (action === "settings-tab") { state.activeTab = target.getAttribute("data-tab") || "device"; if (state.activeTab !== "ports") state.portSettingsPort = 0; if (state.activeTab === "face") refreshFacePeople(false); render(); } if (action === "open-port-settings") openPortSettings(target); if (action === "close-port-settings") closePortSettings(); if (action === "select-port") selectPort(target.getAttribute("data-port")); if (action === "open-all-ports") openAllPorts(); if (action === "delay-close") delayCloseDoor(); if (action === "close-door") closeDoor(true); if (action === "repeat-delivery") repeatDelivery(); if (action === "toggle-local-backup") toggleLocalBackupMode(); if (action === "toggle-maintenance") toggleMaintenanceMode(); if (action === "toggle-device-disabled") toggleDeviceDisabled(); if (action === "open-collection-door") openCollectionDoor(); if (action === "toggle-debug-log") toggleDebugLog(); if (action === "export-logs") exportLogs(); if (action === "run-network-test") runNetworkTest(); if (action === "check-lower-version") checkLowerVersion(); if (action === "get-lower-imei") getLowerImei(); if (action === "get-lower-info") getLowerInfo(); if (action === "refresh-serial-ports") refreshSerialPorts(true); if (action === "retry-cache") retryCache(); if (action === "cleanup-cache") { cleanupCache(true); render(); } if (action === "enroll-current-face") enrollCurrentFace(); if (action === "open-face-capture") openFaceCapture(); if (action === "close-face-capture") closeFaceCapture(); if (action === "add-face-person") addFacePerson(); if (action === "cancel-face-enrollment") cancelFaceEnrollment(); if (action === "sync-face-people") syncFacePeoplePlaceholder(); if (action === "save-face-threshold") saveFaceMatchThreshold(); if (action === "refresh-face-people") refreshFacePeople(true); if (action === "reset-face-form") resetFaceForm(); if (action === "edit-face-person") editFacePerson(target); if (action === "delete-face-person") deleteFacePerson(target); if (action === "open-password-change") openPasswordChange(); if (action === "password-key") appendPasswordDigit(target.getAttribute("data-key")); if (action === "password-backspace") backspacePasswordDigit(); if (action === "password-clear") clearPasswordDigits(); if (action === "password-next") nextPasswordStep(); if (action === "password-cancel") cancelPasswordChange(); if (action === "open-system-settings") nativeCall("openSystemSettings", {}); if (action === "open-file-manager") nativeCall("openFileManager", {}); }); document.addEventListener("keydown", handleReaderKey); document.addEventListener("change", function (event) { var target = event.target; if (target.matches("[data-bind]")) { handleBoundInput(target); if (target.type === "checkbox" || target.tagName === "SELECT") render(); } if (target.matches("[data-draft]")) { handleDraftInput(target); if (target.type === "checkbox" || target.tagName === "SELECT") render(); } if (target.matches("[data-work-field]")) { if (applyWorkInfoInput(target)) scheduleRender(); } if (target.matches("[data-face-enroll]")) { state.faceEnrollment[target.getAttribute("data-face-enroll")] = target.value; } }); document.addEventListener("input", function (event) { var target = event.target; if (target.matches("[data-bind]") && target.type !== "checkbox" && target.tagName !== "SELECT") { handleBoundInput(target); } if (target.matches("[data-draft]") && target.type !== "checkbox" && target.tagName !== "SELECT") { handleDraftInput(target); } if (target.matches("[data-work-field]")) { applyWorkInfoInput(target); } if (target.matches("[data-face-enroll]")) { state.faceEnrollment[target.getAttribute("data-face-enroll")] = target.value; } }); document.addEventListener("keydown", function (event) { if (state.screen === "adminLogin" && /^[0-9]$/.test(event.key)) appendAdminDigit(event.key); if (state.screen === "adminLogin" && event.key === "Backspace") backspaceAdminDigit(); if (state.screen === "adminLogin" && event.key === "Enter") submitAdmin(); if (state.screen === "home" && state.employeeLogin && state.employeeLogin.open && event.key === "Enter") submitEmployeeLogin(); if (state.screen === "workInfo" && event.key === "Enter") submitWorkInfo(); if (state.screen === "delivery" && event.key === "Escape") closeDoor(true); }); } function startCacheTicker() { window.clearInterval(timers.queue); timers.queue = window.setInterval(function () { if (!state.offlineQueue.length) return; var retryMs = Number(state.config.cache.retryIntervalHours || 1) * 60 * 60 * 1000; var oldest = state.offlineQueue[state.offlineQueue.length - 1]; if (Date.now() - oldest.createdAt >= retryMs) retryCache(); }, 30000); } function startStatusReporter() { window.clearInterval(timers.status); window.setTimeout(function () { reportNativeDeviceStatus(); reportNativeAlarms(); }, 3000); window.setTimeout(function () { reportNativeDeviceStatus(); reportNativeAlarms(); }, 15000); timers.status = window.setInterval(function () { reportNativeDeviceStatus(); reportNativeAlarms(); }, STATUS_REPORT_INTERVAL_MS); } window.ZhitouSerial = { onNativeLog: function (kind, message) { addLog(kind || "传输", message || ""); }, onConnectionCheck: function (payload) { handleConnectionCheck(payload || {}); }, onImei: function (imei) { applyLowerImei(imei, state.lowerImei !== "读取中..."); }, onVersion: function (version) { applyLowerVersion(version, state.lowerVersion !== "读取中..."); }, onIdentity: function (type, identity) { identity = String(identity || "").trim(); if (!identity || state.screen !== "home") return; addLog("串口", "收到身份输入", identity); state.deviceStatus.serialOnline = true; state.check.completed = true; state.check.available = Boolean( state.deviceStatus.networkOnline && state.deviceStatus.tcpConnected && !state.deviceStatus.maintenance && !state.deviceStatus.deviceDisabled ); savePersisted(); startAuth(Number(type) === 1 || isLikelyIcCard(identity) ? "card" : Number(type) === 3 ? "employee" : "qr", identity, Number(type) === 1 || isLikelyIcCard(identity) ? 1 : Number(type) || null); }, onFaceRecognized: function (payload) { var face = typeof payload === "string" ? { faceId: payload } : (payload || {}); if (!face.faceId || (state.screen !== "home" && state.screen !== "auth")) return; addLog("窗口", "收到人脸识别结果", face.faceId); startAuth("face", String(face.faceId), 4); }, onFaceStatus: function (payload) { var face = payload || {}; state.faceRecognition.status = String(face.status || "unknown"); state.faceRecognition.message = String(face.message || "人脸识别状态未知"); state.faceRecognition.enrolledCount = Number(face.enrolledCount || 0); addLog("人脸", state.faceRecognition.message); if (state.screen === "home" || (state.screen === "settings" && state.activeTab === "face")) render(); }, onThrowWeight: function (payload) { var isDeliveryWeight = Boolean(state.delivery && state.delivery.closing); state.deviceStatus.serialOnline = true; addLog("串口", "收到称重回包", (payload && payload.port ? payload.port + "号投口 / " : "") + (payload && payload.weight != null ? payload.weight + "kg" : "")); captureDeliveryBaseline(payload); if (payload && payload.port && state.deviceStatus.ports[payload.port]) { var info = state.deviceStatus.ports[payload.port]; if (!isDeliveryWeight) { if (payload.afterWeight != null) info.weight = Number(payload.afterWeight); else if (payload.weight != null) info.weight = Number(payload.weight); if (isFinite(Number(info.weight))) info.weightKnown = true; } if (payload.range != null) applyRangeReading(payload.port, payload.range); syncSettingsDraftPortTelemetry(payload.port); } if (isDeliveryWeight) { completeHardwareDelivery(payload || {}); } else if (state.screen === "select" || state.screen === "settings") { render(); } }, onDeviceStatus: function (payload) { if (!payload || !payload.port || !state.deviceStatus.ports[payload.port]) return; state.deviceStatus.serialOnline = true; var info = state.deviceStatus.ports[payload.port]; if (payload.temperature != null) { var nextTemperature = normalizeTemperatureValue(payload.temperature, null); if (nextTemperature != null) { info.temperature = nextTemperature; } else { addLog("串口", "忽略无效温度", payload.port + "号投口 / " + payload.temperature + "°C"); } } var rangeAccepted = payload.range != null ? applyRangeReading(payload.port, payload.range) : false; syncSettingsDraftPortTelemetry(payload.port); addLog( "串口", "收到设备状态", payload.port + "号投口 / 高度 " + (payload.range != null ? rangeAccepted ? payload.range + "cm" : "无效" : "-") + " / 温度 " + (payload.temperature != null ? payload.temperature + "°C" : "-") ); if (state.screen === "select" || state.screen === "settings") { render(); } } }; window.XingyuanSerial = window.ZhitouSerial; function init() { loadPersisted(); loadNativeLocalBackups(); addLog("窗口", "应用启动", state.config.appVersion + " / " + state.config.server.tcpHost + ":" + state.config.server.tcpPort); bindEvents(); applyFaceMatchThreshold(false); startCacheTicker(); startNativeHeartbeat(); refreshFacePeople(false); startStatusReporter(); beginStartupCheck(); } init(); })();