(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 '" + label + " ";
})
.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
? '检测通过,3 秒后进入首页 '
: '重新检测 ') +
(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) +
'
' +
escapeHtml(t("admin")) +
" " +
"
" +
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 '' + escapeHtml(person.name || "未命名人员") +
' 工号:' + escapeHtml(person.employeeNo) + " ";
}).join("")
: '暂无人员,请先到“设置 → 人脸”新增人员。
';
return (
'' +
'
' +
'
工号登录 取消 ' +
'
请选择需要登录的人员,系统将使用该人员工号发起身份鉴权。
' +
(serialBlocked ? '
串口异常,当前不能选择工号。
' : "") +
'
' + choices + "
" +
(login.message ? '
' + escapeHtml(login.message) + "
" : "") +
'
确认登录
" +
"
"
);
}
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 || "请稍候,正在建立用户会话。") +
"
" +
'
' +
'' +
escapeHtml(t("backHome")) +
" " +
"
" +
"
" +
"
" +
" "
);
}
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 (
'"
);
}
function pickerInput(label, field, value, placeholder) {
return (
''
);
}
function workInput(label, field, value, options, placeholder) {
var listId = "work-" + field + "-options";
return (
'' +
escapeHtml(label) +
' ' +
'' +
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 (
'' +
'
' +
'
' +
escapeHtml(workPickerTitle(field)) +
' 关闭 ' +
'
' +
(rows.length
? rows.map(function (row) {
return (
"" +
row.map(function (value) {
return (
'' +
escapeHtml(value) +
" "
);
}).join("") +
(row.length < 3 ? ' '.repeat(3 - row.length) : "") +
" "
);
}).join("")
: '暂无可选数据
') +
"
" +
"
"
);
}
function renderSelect() {
var work = sessionWorkInfo();
var ports = enabledPorts();
return (
'' +
topbar("投口选择") +
'' +
'
' +
escapeHtml(t("choosePort")) +
" " +
'
请在 15 秒内选择投口。
' +
renderWorkSummary(work) +
'
' +
ports.map(renderPortCard).join("") +
"
" +
'
' +
'' +
escapeHtml(t("openAllPorts")) +
" " +
'' +
escapeHtml(t("backHome")) +
" " +
"
" +
"
" +
" "
);
}
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 (
'" +
'' +
'
' +
port +
' 号投口
类型码 ' +
garbageCode(typeKey, port) +
"
" +
'
' +
escapeHtml(garbageName(typeKey, port)) +
" " +
"
" +
" "
);
}
function renderDelivery() {
var d = state.delivery || {};
var phase = deliveryPhaseText();
return (
'' +
topbar("投递中") +
'' +
'
' +
'
' +
escapeHtml(phase) +
"
" +
'
' +
(d.secondsLeft == null ? 30 : d.secondsLeft) +
"
" +
'
' +
escapeHtml(deliverySafetyText()) +
"
" +
'
' +
'再次投放 " +
'" +
(d.delayClose ? "已延时关门" : "延时关门") +
" " +
'" +
escapeHtml(t("closeDoor")) +
" " +
"
" +
"
" +
"
" +
" "
);
}
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) +
'
' +
'' +
escapeHtml(t("backHome")) +
" " +
"
" +
"
" +
"
" +
" "
);
}
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")) +
'' +
'
' +
'
' + escapeHtml(t("admin")) + " " +
'
' + escapeHtml(t("adminLoginDesc")) + "
" +
'
' +
'
' + escapeHtml(t("password")) + "
" +
'
" +
"
" +
renderAdminKeypad() +
'
' + escapeHtml(t("remainingTime")) + ":" +
state.adminLogin.secondsLeft +
" " + escapeHtml(t("secondUnit")) + adminMetaSeparator + escapeHtml(t("errorCount")) + ":" +
state.adminLogin.tries +
" / 5
" +
(state.adminLogin.message ? '
' + escapeHtml(state.adminLogin.message) + "
" : "") +
'
' +
'' + escapeHtml(t("submitLogin")) + " " +
'' + escapeHtml(t("cancel")) + " " +
"
" +
"
" +
"
" +
" "
);
}
function renderAdminKeypad() {
var keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
return (
'' +
keys.map(function (key) {
return '' + key + " ";
}).join("") +
'' + escapeHtml(t("clear")) + " " +
'0 ' +
'' + escapeHtml(t("backspace")) + " " +
"
"
);
}
function renderSettings() {
return (
'' +
topbar(t("settingsTitle"), '' + escapeHtml(t("exitSettings")) + " ") +
'' +
'' +
tabButton("device", t("tabDevice")) +
tabButton("ports", t("tabPorts")) +
tabButton("cache", t("tabCache")) +
tabButton("face", t("tabFace")) +
tabButton("system", t("tabSystem")) +
" " +
'' +
renderSettingsTab() +
" " +
"
" +
renderPortSettingDialog() +
" "
);
}
function tabButton(tab, label) {
return '' + label + " ";
}
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")) + " " +
'
' +
'' + escapeHtml(t("networkTest")) + " " +
'' + escapeHtml(t("getLowerInfo")) + " " +
'' + escapeHtml(t("restartSelfCheck")) + " " +
"
" +
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 '' +
escapeHtml(item.path + "(" + permission + ")") + " ";
}).join("");
var hint = serialState.loading
? "正在扫描安卓设备串口…"
: serialState.message || "选择串口后,请点击“重新启动自检”验证连接。";
return (
'' + escapeHtml(t("serialPath")) + " " +
'
' + options + " " +
'刷新串口
' +
'
' + 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"))) +
"
" +
'
' +
'' +
escapeHtml(state.deviceStatus.maintenance ? t("closeMaintenance") : t("enableMaintenance")) +
" " +
"
" +
"
"
);
}
function renderDeviceDisabledSettings() {
return (
'' + escapeHtml(t("deviceDisabledTitle")) + " " +
'
' +
(state.deviceStatus.deviceDisabled ? escapeHtml(t("deviceDisabledOnDesc")) : escapeHtml(t("deviceDisabledOffDesc"))) +
"
" +
'
' +
'' +
escapeHtml(state.deviceStatus.deviceDisabled ? t("closeDeviceDisabled") : t("enableDeviceDisabled")) +
" " +
(state.deviceStatus.deviceDisabled ? '' + escapeHtml(t("openCollectionDoor")) + " " : "") +
"
" +
"
"
);
}
function renderPortSettings() {
var draft = ensureSettingsDraft();
var cfg = draft.config;
var options = allPhysicalPorts();
return (
'' +
'
' + escapeHtml(t("portCount")) + " " +
'
' + escapeHtml(t("enabledPorts")) + ' ' +
options.map(function (count) {
return '" + escapeHtml(portCountLabel(count)) + " ";
}).join("") +
"
" +
'
' + 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 (
'" +
'' +
escapeHtml(portDisplayName(port)) +
" " +
(enabled
? '' + escapeHtml(garbageName(typeKey, port)) + ' '
: '未启用 ') +
" "
);
}
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 (
'' +
'
' +
'
' +
escapeHtml(portDisplayName(port) + "设置") +
' 关闭 ' +
'
' +
'
' + escapeHtml(t("garbageType")) + ' ' +
typeOptions(typeKey) +
"
" +
(typeKey === "custom"
? draftField(t("customName"), "config.customPortTypes." + port + ".name", draftCustomType(port).name) +
draftField(t("customTypeId"), "config.customPortTypes." + port + ".code", draftCustomType(port).code, "number")
: "") +
"
" +
'
' +
readonlyField(t("currentWeightKg"), kg(liveInfo.weight)) +
readonlyField(t("sensorSensitivityCm"), sensorHeight(port)) +
readonlyField(t("temperatureC"), portTemperature(port)) +
"
" +
'
完成
' +
"
" +
"
"
);
}
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(state.config.localBackup.enabled ? t("localBackupClose") : t("localBackupOpen")) +
" " +
"
" +
'
' + 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("retryNow")) + " " +
'' + escapeHtml(t("cleanupExpired")) + " " +
"
" +
'
' + 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 ? "已录入" : "未录入") + ' ' +
'' +
'' + (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")) + " " +
'
' + escapeHtml(t("changePassword")) + "
" +
renderPasswordChangePanel() +
"
" +
'
' + escapeHtml(t("systemTools")) + " " +
'
' + escapeHtml(t("debugLog")) + ":" +
escapeHtml(logEnabled ? t("disabled") : t("enabled")) +
' ' + escapeHtml(t("exportLogs")) +
' ' + escapeHtml(t("openSystemSettings")) +
' ' + escapeHtml(t("openFileManager")) + "
" +
"
" +
"
"
);
}
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 '' + num + " ";
}).join("") +
'' + escapeHtml(t("clear")) + " " +
'0 ' +
'' + escapeHtml(t("backspace")) + " " +
"
" +
'
' +
'' +
escapeHtml(flow.step === "confirm" ? t("savePassword") : t("nextStep")) +
" " +
'' + escapeHtml(t("cancel")) + " " +
"
" +
"
"
);
}
function field(label, path, value, type) {
var inputType = type || "text";
return (
'' +
escapeHtml(label) +
'
'
);
}
function draftField(label, path, value, type) {
var inputType = type || "text";
return (
'' +
escapeHtml(label) +
'
'
);
}
function readonlyField(label, value) {
return (
'' +
escapeHtml(label) +
' ' +
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();
})();