diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5c40b9b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+app/src/main/assets/face_models/*.csta filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3bdf240
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+.DS_Store
+.idea/
+.gradle/
+*.iml
+local.properties
+build/
+sdk-demo/
+*.idsig
+*.apk
+*.ap_
+keystore/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..965b038
--- /dev/null
+++ b/README.md
@@ -0,0 +1,104 @@
+# 星元智灵
+
+星元智灵是基于智投终端流程改造的竖屏 Android 投放终端应用。项目保留串口投口控制、称重回包、本地备份和服务器上报框架,并集成本地 SeetaFace6 人脸识别与人员录入流程。
+本项目为沃尔核材专用 APP,定位为安卓上位机应用。
+
+## 主要功能
+
+- 首页显示摄像头实时画面并默认进行本地人脸识别,也可通过刷卡或工号进入投放流程。
+- 设置页新增“人脸”Tab,可新增人员、录入或更新人脸、查看人员列表并删除人员。
+- 第二页填写本次投放信息:机台号、人员、生产批次。
+- 机台号支持手动输入,也支持小窗口表格选择;选择后自动联动人员和生产批次。
+- 第三页支持 1/2/3/4 投口模式,可选择单个投口或打开全部投口,页面展示本次机台号、人员、生产批次。
+- 投递页支持关门倒计时、手动关门、一次性延时关门。
+- 结果页仅显示本次投放垃圾类型和重量,不显示奖励。
+- 投口垃圾类型支持镀锡铜、铝合金、废铜、废电线、废铝、废锡和自定义。
+- 投放结果通过预留接口上报服务器,接口未定时使用本地队列和备份兜底。
+- 保留下位机原始称重小数;串口或下位机不可用时不会生成虚假称重结果。
+
+## 工程结构
+
+```text
+app/src/main/
+ AndroidManifest.xml Android 应用配置
+ assets/www/ WebView 前端界面
+ assets/face_models/ SeetaFace6 人脸模型
+ assets/brand/ App 图标源图
+ java/ 原生桥、串口、TCP 上报逻辑
+ jniLibs/armeabi-v7a/ 串口 native 库
+ res/mipmap-*/ Android launcher 图标
+build.sh 无 Gradle 手动构建脚本
+```
+
+## 构建要求
+
+- macOS
+- Android SDK,包含:
+ - `platforms/android-28/android.jar`
+ - `build-tools/34.0.0`
+- JDK 8 或兼容 `javac --release 8` 的 JDK
+- Git LFS(仓库中的 SeetaFace6 `.csta` 模型通过 LFS 管理)
+- Android SDK platform-tools,用于 `adb install`
+
+## 构建 APK
+
+```bash
+bash build.sh
+```
+
+构建产物:
+
+```text
+build/out/xingyuan-zhiling-signed.apk
+```
+
+首次构建时,如果本地没有 debug keystore,脚本会自动生成:
+
+```text
+keystore/zhitou-debug.keystore
+```
+
+该 keystore 不提交到 Git。
+
+## 安装到终端
+
+```bash
+$HOME/Library/Android/sdk/platform-tools/adb install -r build/out/xingyuan-zhiling-signed.apk
+$HOME/Library/Android/sdk/platform-tools/adb shell monkey -p com.xingyuan.zhiling -c android.intent.category.LAUNCHER 1
+```
+
+## 串口说明
+
+默认串口配置:
+
+```text
+path: /dev/ttyS4
+baudRate: 9600
+```
+
+如果首页提示串口故障,请检查串口占用情况以及下位机连接。旧智投 APP 与星元智灵同时运行时,可能会竞争 `/dev/ttyS4`。
+
+## 接口说明
+
+当前机台号、人员、生产批次接口仍为占位实现。接口未接入时页面允许手动填写机台号,不会注入测试人员。后续接入正式接口时,需要替换:
+
+- `app/src/main/java/com/zhitou/terminal/ZhitouBridge.java` 中的 `fetchWorkContext`
+- 前端 `app/src/main/assets/www/app.js` 中的接口解析和兜底逻辑
+
+投放结果上报预留:
+
+- `reportXingyuanDelivery`
+- `reportDeliveryResult`
+- 本地备份:`/sdcard/smart/cache/xingyuan_zhiling_delivery_backup.csv`
+- 本地 JSON 备份:`/sdcard/smart/cache/xingyuan_zhiling_delivery_backup.json`
+- 日志导出:`/sdcard/smart/logs/`
+
+## 版本
+
+当前应用版本:
+
+```text
+versionName: 1.2.12-wal-review-fix
+versionCode: 101
+package: com.xingyuan.zhiling
+```
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5fbe58a
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/brand/xingyuan_launcher_source.png b/app/src/main/assets/brand/xingyuan_launcher_source.png
new file mode 100644
index 0000000..7d7cb6b
Binary files /dev/null and b/app/src/main/assets/brand/xingyuan_launcher_source.png differ
diff --git a/app/src/main/assets/face_models/SEETAFACE6-LICENSE.txt b/app/src/main/assets/face_models/SEETAFACE6-LICENSE.txt
new file mode 100644
index 0000000..892a565
--- /dev/null
+++ b/app/src/main/assets/face_models/SEETAFACE6-LICENSE.txt
@@ -0,0 +1,11 @@
+Copyright (c) 2019, SeetaTech,
+Institute of Computing Technology, Chinese Academy of Sciences, Beijing, China
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/app/src/main/assets/face_models/face_detector.csta b/app/src/main/assets/face_models/face_detector.csta
new file mode 100644
index 0000000..4072b8c
--- /dev/null
+++ b/app/src/main/assets/face_models/face_detector.csta
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b57171c55f76f343f6f579e1ea1e165d1d4fc0321b7395f6abde155d18fe0082
+size 4054932
diff --git a/app/src/main/assets/face_models/face_landmarker_pts5.csta b/app/src/main/assets/face_models/face_landmarker_pts5.csta
new file mode 100644
index 0000000..9ae3f3d
--- /dev/null
+++ b/app/src/main/assets/face_models/face_landmarker_pts5.csta
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3458dd3c52ae85c4132bcfdd864c86c3b5ba5bd2337a0d8dcb7cb25aa4bff5ac
+size 421988
diff --git a/app/src/main/assets/face_models/face_recognizer.csta b/app/src/main/assets/face_models/face_recognizer.csta
new file mode 100644
index 0000000..649f39a
--- /dev/null
+++ b/app/src/main/assets/face_models/face_recognizer.csta
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cefce29d2ceb6fd95153d67c11f24ce08746e1877478bacaa5ae5d333a0a670
+size 102551956
diff --git a/app/src/main/assets/www/app.js b/app/src/main/assets/www/app.js
new file mode 100644
index 0000000..8f9f691
--- /dev/null
+++ b/app/src/main/assets/www/app.js
@@ -0,0 +1,5185 @@
+(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, value: "", 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
+ };
+
+ function defaultConfig() {
+ return {
+ language: "zh-CN",
+ appVersion: "1.2.12-wal-review-fix",
+ adminPassword: "88888888",
+ portCount: 2,
+ debugLog: {
+ enabled: false
+ },
+ guest: {
+ enabled: false
+ },
+ localBackup: {
+ enabled: true,
+ path: ""
+ },
+ device: {
+ deviceNo: "",
+ terminalId: "",
+ serialPath: "/dev/ttyS4",
+ baudRate: 9600,
+ protocol: "680068xx",
+ board: "RK3288"
+ },
+ server: {
+ tcpHost: DEFAULT_REPORT_IP,
+ tcpPort: DEFAULT_REPORT_PORT,
+ httpBase: DEFAULT_HTTP_BASE,
+ upgradeEndpoint: "autoMachine/version.do",
+ defaultMemberId: DEFAULT_MEMBER_ID,
+ packageType: DEFAULT_PACKAGE_TYPE
+ },
+ portTypes: {
+ 1: "tinCopper",
+ 2: "aluminumAlloy",
+ 3: "tinCopper",
+ 4: "aluminumAlloy"
+ },
+ customPortTypes: {
+ 1: { name: "自定义1", code: "1" },
+ 2: { name: "自定义2", code: "2" },
+ 3: { name: "自定义3", code: "1" },
+ 4: { name: "自定义4", code: "2" }
+ },
+ cache: {
+ retainDays: 30,
+ retryIntervalHours: 1
+ },
+ face: {
+ autoSyncHours: 4,
+ matchThreshold: 0.75
+ }
+ };
+ }
+
+ function defaultDeviceStatus() {
+ return {
+ serialOnline: false,
+ networkOnline: true,
+ tcpConnected: true,
+ httpReachable: true,
+ maintenance: false,
+ deviceDisabled: false,
+ collectionDoorClosed: true,
+ ports: {
+ 1: { overflow: false, temperature: 26, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 },
+ 2: { overflow: false, temperature: 25, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 },
+ 3: { overflow: false, temperature: 27, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 },
+ 4: { overflow: false, temperature: 26, infraredBlocked: false, weight: 0, weightKnown: false, range: 150 }
+ }
+ };
+ }
+
+ function defaultCheck() {
+ return {
+ attempt: 0,
+ serial: false,
+ network: false,
+ completed: false,
+ available: false,
+ skipped: false,
+ running: false
+ };
+ }
+
+ function defaultWorkInfo() {
+ return {
+ loading: false,
+ loaded: false,
+ error: "",
+ machineNo: "",
+ person: "",
+ batch: "",
+ pickerField: "",
+ options: []
+ };
+ }
+
+ function deepMerge(base, incoming) {
+ if (!incoming || typeof incoming !== "object") return base;
+ Object.keys(incoming).forEach(function (key) {
+ if (
+ incoming[key] &&
+ typeof incoming[key] === "object" &&
+ !Array.isArray(incoming[key]) &&
+ base[key] &&
+ typeof base[key] === "object" &&
+ !Array.isArray(base[key])
+ ) {
+ deepMerge(base[key], incoming[key]);
+ } else {
+ base[key] = incoming[key];
+ }
+ });
+ return base;
+ }
+
+ function cloneData(value) {
+ return JSON.parse(JSON.stringify(value || {}));
+ }
+
+ function isPlainObject(value) {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+ }
+
+ function firstArrayValue(source, keys) {
+ var i;
+ if (Array.isArray(source)) return source;
+ if (!isPlainObject(source)) return [];
+ for (i = 0; i < keys.length; i += 1) {
+ if (Array.isArray(source[keys[i]])) return source[keys[i]];
+ }
+ return [];
+ }
+
+ function sanitizeQueueItem(item) {
+ var output;
+ var createdAt;
+ if (!isPlainObject(item)) return null;
+ output = cloneData(item);
+ createdAt = Number(output.createdAt || Date.now());
+ if (!isFinite(createdAt)) createdAt = Date.now();
+ output.id = String(output.id || "Q" + createdAt);
+ output.kind = String(output.kind || "delivery");
+ output.kindText = String(output.kindText || (output.kind === "delivery" ? "投递记录" : "缓存记录"));
+ output.createdAt = createdAt;
+ output.status = String(output.status || "queued");
+ output.summary = String(output.summary || output.kindText);
+ if (!isPlainObject(output.payload)) output.payload = {};
+ return output;
+ }
+
+ function sanitizeOfflineQueue(items) {
+ return (Array.isArray(items) ? items : [])
+ .map(sanitizeQueueItem)
+ .filter(function (item) {
+ return Boolean(item);
+ })
+ .slice(0, 500);
+ }
+
+ function defaultPortStatus(port) {
+ var defaults = defaultDeviceStatus().ports;
+ return cloneData(defaults[port] || defaults[1]);
+ }
+
+ function normalizeTemperatureValue(value, fallback) {
+ var number = Number(value);
+ if (!isFinite(number) || number <= 0 || number > 100) return fallback == null ? null : fallback;
+ return Number(number.toFixed(1));
+ }
+
+ function ensurePortStatus(port) {
+ var info;
+ var defaults = defaultPortStatus(port);
+ if (!state.deviceStatus.ports || typeof state.deviceStatus.ports !== "object") {
+ state.deviceStatus.ports = {};
+ }
+ info = state.deviceStatus.ports[port];
+ if (!info || typeof info !== "object") info = {};
+ info = deepMerge(defaults, info);
+ info.overflow = Boolean(info.overflow);
+ info.infraredBlocked = Boolean(info.infraredBlocked);
+ info.temperature = normalizeTemperatureValue(info.temperature, defaults.temperature);
+ info.weight = isFinite(Number(info.weight)) ? Math.max(0, Number(info.weight)) : defaults.weight;
+ info.weightKnown = Boolean(info.weightKnown);
+ if (!info.weightKnown) info.weight = 0;
+ info.range = normalizeRangeValue(info.range) == null ? defaults.range : normalizeRangeValue(info.range);
+ state.deviceStatus.ports[port] = info;
+ return info;
+ }
+
+ function createSettingsDraft() {
+ return {
+ config: {
+ portCount: state.config.portCount,
+ portTypes: cloneData(state.config.portTypes),
+ customPortTypes: cloneData(state.config.customPortTypes),
+ server: cloneData(state.config.server)
+ },
+ deviceStatus: {
+ ports: cloneData(state.deviceStatus.ports)
+ }
+ };
+ }
+
+ function ensureSettingsDraft() {
+ if (!state.settingsDraft) state.settingsDraft = createSettingsDraft();
+ return state.settingsDraft;
+ }
+
+ function syncSettingsDraftPortTelemetry(port) {
+ var draftPort;
+ var livePort;
+ if (!state.settingsDraft || !state.settingsDraft.deviceStatus || !state.settingsDraft.deviceStatus.ports) return;
+ livePort = state.deviceStatus.ports && state.deviceStatus.ports[port];
+ if (!livePort) return;
+ draftPort = state.settingsDraft.deviceStatus.ports[port] || defaultPortStatus(port);
+ draftPort.weight = livePort.weight;
+ draftPort.range = livePort.range;
+ draftPort.temperature = livePort.temperature;
+ state.settingsDraft.deviceStatus.ports[port] = draftPort;
+ }
+
+ function loadPersisted() {
+ try {
+ var raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return;
+ var saved = JSON.parse(raw);
+ state.config = deepMerge(defaultConfig(), saved.config || {});
+ state.config.appVersion = defaultConfig().appVersion;
+ disableRemovedFeatures();
+ migrateDefaultServer(saved.config || {});
+ migrateSerialPath(saved.config || {});
+ migrateTerminalImei(saved.config || {});
+ migratePortConfig();
+ state.deviceStatus = deepMerge(defaultDeviceStatus(), saved.deviceStatus || {});
+ migratePortStatus();
+ state.offlineQueue = sanitizeOfflineQueue(saved.offlineQueue);
+ state.localBackups = Array.isArray(saved.localBackups) ? saved.localBackups : [];
+ state.faceSync = deepMerge(state.faceSync, saved.faceSync || {});
+ state.faceSync.syncing = false;
+ state.faceDirectory = Array.isArray(saved.faceDirectory) ? saved.faceDirectory : [];
+ } catch (error) {
+ showToast("本地配置读取失败,已使用默认配置");
+ }
+ }
+
+ function migrateDefaultServer(savedConfig) {
+ var savedServer = savedConfig.server || {};
+ var savedHost = String(savedServer.tcpHost || "").trim();
+ var savedHttpBase = String(savedServer.httpBase || "").trim();
+ if (!savedHost || savedHost === "20.2.128.145" || savedHost === LEGACY_REPORT_IP) {
+ state.config.server.tcpHost = DEFAULT_REPORT_IP;
+ }
+ if (!savedServer.tcpPort || Number(savedServer.tcpPort) === 9878) {
+ state.config.server.tcpPort = DEFAULT_REPORT_PORT;
+ }
+ if (!savedHttpBase || /20\.2\.128\.145|112\.74\.41\.228/.test(savedHttpBase)) {
+ state.config.server.httpBase = DEFAULT_HTTP_BASE;
+ }
+ if (!savedServer.defaultMemberId) {
+ state.config.server.defaultMemberId = DEFAULT_MEMBER_ID;
+ }
+ if (!savedServer.packageType || savedServer.packageType === "Five_NEW_AI_SJZ" || savedServer.packageType === "Five_NEW_AI") {
+ state.config.server.packageType = DEFAULT_PACKAGE_TYPE;
+ }
+ }
+
+ function migrateSerialPath(savedConfig) {
+ var savedDevice = savedConfig.device || {};
+ var savedPath = String(savedDevice.serialPath || "").trim();
+ // 旧版本曾默认写入 S1;升级后自动迁回下位机实际连接的 S4。
+ if (!savedPath || savedPath === "/dev/ttyS1") {
+ state.config.device.serialPath = "/dev/ttyS4";
+ }
+ }
+
+ function migrateTerminalImei(savedConfig) {
+ var savedDevice = savedConfig.device || {};
+ var terminalId = String(savedDevice.terminalId || "").trim();
+ if (/^\d{10,20}$/.test(terminalId)) {
+ state.config.device.terminalId = terminalId;
+ state.lowerImei = terminalId;
+ return;
+ }
+ state.config.device.terminalId = "";
+ state.lowerImei = null;
+ }
+
+ function migratePortConfig() {
+ state.config.portCount = clampPortCount(state.config.portCount || 2);
+ if (!state.config.customPortTypes) state.config.customPortTypes = {};
+ [1, 2, 3, 4].forEach(function (port) {
+ if (!GARBAGE_TYPES[state.config.portTypes[port]]) {
+ state.config.portTypes[port] = port % 2 === 0 ? "aluminumAlloy" : "tinCopper";
+ }
+ customType(port);
+ });
+ }
+
+ function disableRemovedFeatures() {
+ if (!state.config.guest) state.config.guest = { enabled: false };
+ state.config.guest.enabled = false;
+ if (!state.config.face) state.config.face = {};
+ state.config.face.matchThreshold = Math.max(0.50, Math.min(0.95,
+ Number(state.config.face.matchThreshold || 0.75)));
+ // 清除旧版本持久化的模拟配置,升级后不能继续绕过真实串口和身份校验。
+ delete state.config.simulation;
+ delete state.config.capture;
+ }
+
+ function migratePortStatus() {
+ state.deviceStatus.deviceDisabled = Boolean(state.deviceStatus.deviceDisabled);
+ if (!state.deviceStatus.deviceDisabled) {
+ state.deviceStatus.collectionDoorClosed = true;
+ }
+ [1, 2, 3, 4].forEach(function (port) {
+ ensurePortStatus(port);
+ resetRangeStability(port);
+ });
+ }
+
+ function savePersisted() {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ config: state.config,
+ deviceStatus: state.deviceStatus,
+ offlineQueue: state.offlineQueue,
+ localBackups: state.localBackups,
+ faceSync: state.faceSync,
+ faceDirectory: state.faceDirectory
+ })
+ );
+ configureNativeReporter();
+ } catch (error) {
+ showToast("本地配置保存失败");
+ }
+ }
+
+ function isAdminPasswordValid(password) {
+ var value = String(password || "");
+ return value === String(state.config.adminPassword);
+ }
+
+ function t(key) {
+ var lang = state.config.language || "zh-CN";
+ return (I18N[lang] && I18N[lang][key]) || I18N["zh-CN"][key] || key;
+ }
+
+ function isEnglish() {
+ return (state.config.language || "zh-CN") === "en";
+ }
+
+ function portDisplayName(port) {
+ return isEnglish() ? t("portWord") + " " + port : port + " " + t("portSuffix");
+ }
+
+ function portCountLabel(count) {
+ return isEnglish() ? count + " " + (Number(count) === 1 ? t("portUnit") : t("portUnitPlural")) : count + " " + t("portUnit");
+ }
+
+ function recordCountLabel(count) {
+ return isEnglish() ? count + " " + (Number(count) === 1 ? t("recordUnit") : t("recordUnitPlural")) : count + " " + t("recordUnit");
+ }
+
+ function localizedReadStatus(value) {
+ var text = String(value == null ? "" : value);
+ if (text === "读取中..." || text === "讀取中..." || text === "Reading...") return t("reading");
+ if (text === "获取失败" || text === "取得失敗" || text === "Read failed") return t("readFailed");
+ return text;
+ }
+
+ function localizedList(items) {
+ return items.join(isEnglish() ? ", " : "、");
+ }
+
+ function escapeHtml(value) {
+ return String(value == null ? "" : value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function attr(value) {
+ return escapeHtml(value);
+ }
+
+ function checked(value) {
+ return value ? " checked" : "";
+ }
+
+ function selected(value, current) {
+ return String(value) === String(current) ? " selected" : "";
+ }
+
+ function money(value) {
+ return Number(value || 0).toFixed(2);
+ }
+
+ function numericMoney(value) {
+ var cleaned = String(value == null ? "" : value).replace(/[^0-9.\-]/g, "");
+ var parsed = Number(cleaned);
+ return isFinite(parsed) ? parsed : 0;
+ }
+
+ function plainMoney(value) {
+ return money(numericMoney(value)).replace(/^[^\d]+/, "");
+ }
+
+ function kg(value) {
+ return Number(value || 0).toFixed(2) + " kg";
+ }
+
+ function kgCompact(value) {
+ return Number(value || 0).toFixed(2) + "kg";
+ }
+
+ function cleanWeightNumber(value) {
+ var number = Number(value);
+ return isFinite(number) ? Number(number.toFixed(6)) : 0;
+ }
+
+ function nowText(timestamp) {
+ var d = new Date(timestamp || Date.now());
+ var pad = function (n) {
+ return n < 10 ? "0" + n : String(n);
+ };
+ return (
+ d.getFullYear() +
+ "-" +
+ pad(d.getMonth() + 1) +
+ "-" +
+ pad(d.getDate()) +
+ " " +
+ pad(d.getHours()) +
+ ":" +
+ pad(d.getMinutes())
+ );
+ }
+
+ function maskAccount(account) {
+ var text = String(account || "");
+ if (text.length <= 4) return text;
+ return text.slice(0, 2) + "*".repeat(Math.max(3, text.length - 4)) + text.slice(-2);
+ }
+
+ function maskPin(value) {
+ var text = String(value || "");
+ return text ? "*".repeat(text.length) : t("notEntered");
+ }
+
+ function isLogEnabled() {
+ return Boolean(state.config.debugLog && state.config.debugLog.enabled);
+ }
+
+ function logTimeText(timestamp) {
+ var d = new Date(timestamp || Date.now());
+ var pad = function (n) {
+ return n < 10 ? "0" + n : String(n);
+ };
+ return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
+ }
+
+ function addLog(kind, message, detail) {
+ message = String(message || "").trim();
+ if (!message) return;
+ state.logs.unshift({
+ at: Date.now(),
+ kind: String(kind || "窗口"),
+ message: message,
+ detail: detail == null ? "" : String(detail)
+ });
+ if (state.logs.length > 80) state.logs.length = 80;
+ if (isLogEnabled() && state.screen !== "home") render();
+ }
+
+ function renderLogWindow() {
+ if (!isLogEnabled()) return "";
+ var entries = state.logs.slice(0, 10);
+ return (
+ '
' +
+ '
窗口日志输出传输日志
' +
+ '
' +
+ (entries.length
+ ? entries.map(function (item) {
+ return (
+ '
' +
+ '' +
+ escapeHtml(logTimeText(item.at)) +
+ "" +
+ '' +
+ escapeHtml(item.kind) +
+ "" +
+ '' +
+ escapeHtml(item.message) +
+ (item.detail ? " / " + escapeHtml(item.detail) : "") +
+ "" +
+ "
"
+ );
+ }).join("")
+ : '
等待日志输出
') +
+ "
" +
+ "
"
+ );
+ }
+
+ function showToast(message) {
+ addLog("窗口", message);
+ window.clearTimeout(timers.toast);
+ toast.textContent = message;
+ toast.classList.add("show");
+ timers.toast = window.setTimeout(function () {
+ toast.classList.remove("show");
+ }, 2200);
+ }
+
+ function setPath(root, path, value) {
+ var parts = path.split(".");
+ var target = root;
+ for (var i = 0; i < parts.length - 1; i += 1) {
+ if (!target[parts[i]]) target[parts[i]] = {};
+ target = target[parts[i]];
+ }
+ target[parts[parts.length - 1]] = value;
+ }
+
+ function getPath(root, path) {
+ return path.split(".").reduce(function (obj, key) {
+ return obj && obj[key];
+ }, root);
+ }
+
+ function clampPortCount(value) {
+ var count = Number(value);
+ if (!isFinite(count)) count = 2;
+ return Math.min(MAX_PORT_COUNT, Math.max(1, Math.round(count)));
+ }
+
+ function allPhysicalPorts() {
+ var ports = [];
+ var i;
+ for (i = 1; i <= MAX_PORT_COUNT; i += 1) ports.push(i);
+ return ports;
+ }
+
+ function enabledPorts() {
+ var count = clampPortCount(state.config.portCount || 2);
+ var ports = [];
+ for (var i = 1; i <= count; i += 1) ports.push(i);
+ return ports;
+ }
+
+ function customType(port) {
+ if (!state.config.customPortTypes) state.config.customPortTypes = {};
+ if (!state.config.customPortTypes[port]) state.config.customPortTypes[port] = { name: "自定义" + port, code: String(port % 2 === 0 ? 2 : 1) };
+ if (String(state.config.customPortTypes[port].code || "") === "18") {
+ state.config.customPortTypes[port].code = String(port % 2 === 0 ? 2 : 1);
+ }
+ return state.config.customPortTypes[port];
+ }
+
+ function draftCustomType(port) {
+ var draft = ensureSettingsDraft();
+ if (!draft.config.customPortTypes) draft.config.customPortTypes = {};
+ if (!draft.config.customPortTypes[port]) draft.config.customPortTypes[port] = { name: "自定义" + port, code: String(port % 2 === 0 ? 2 : 1) };
+ if (String(draft.config.customPortTypes[port].code || "") === "18") {
+ draft.config.customPortTypes[port].code = String(port % 2 === 0 ? 2 : 1);
+ }
+ return draft.config.customPortTypes[port];
+ }
+
+ function garbageName(key, port) {
+ if (key === "custom") return customType(port).name || "自定义";
+ var type = GARBAGE_TYPES[key] || GARBAGE_TYPES.tinCopper;
+ var lang = state.config.language === "en" ? "en" : state.config.language === "zh-TW" ? "tw" : "zh";
+ return type[lang];
+ }
+
+ function garbageCode(key, port) {
+ if (key === "custom") return String(customType(port).code || "1");
+ return (GARBAGE_TYPES[key] || GARBAGE_TYPES.tinCopper).code;
+ }
+
+ function deliveryGarbageName(key, port) {
+ if (key === "allPorts") return t("allPorts");
+ return garbageName(key, port);
+ }
+
+ function deliveryGarbageCode(key, port) {
+ if (key === "allPorts") return "0";
+ return garbageCode(key, port);
+ }
+
+ function numericGarbageCode(key, port) {
+ return Number(garbageCode(key, port));
+ }
+
+ function typeOptions(current) {
+ return Object.keys(GARBAGE_TYPES)
+ .map(function (key) {
+ var label = key === "custom" ? t("customEditable") : garbageName(key) + " / " + garbageCode(key);
+ return '";
+ })
+ .join("");
+ }
+
+ function workOptions() {
+ var info = state.workInfo || {};
+ return Array.isArray(info.options) ? info.options : [];
+ }
+
+ function machineOptions() {
+ return workOptions().map(function (item) {
+ return item.machineNo;
+ });
+ }
+
+ function selectedMachine() {
+ var machineNo = String((state.workInfo && state.workInfo.machineNo) || "").trim();
+ var options = workOptions();
+ var i;
+ for (i = 0; i < options.length; i += 1) {
+ if (String(options[i].machineNo || "") === machineNo) return options[i];
+ }
+ return null;
+ }
+
+ function personOptions() {
+ var machine = selectedMachine();
+ if (machine && Array.isArray(machine.persons)) return machine.persons;
+ return uniqueWorkValues("persons");
+ }
+
+ function batchOptions() {
+ var machine = selectedMachine();
+ if (machine && Array.isArray(machine.batches)) return machine.batches;
+ return uniqueWorkValues("batches");
+ }
+
+ function uniqueWorkValues(key) {
+ var seen = {};
+ var output = [];
+ workOptions().forEach(function (item) {
+ (item[key] || []).forEach(function (value) {
+ value = String(value || "").trim();
+ if (value && !seen[value]) {
+ seen[value] = true;
+ output.push(value);
+ }
+ });
+ });
+ return output;
+ }
+
+ function uniqueTextList(values) {
+ var seen = {};
+ var output = [];
+ (Array.isArray(values) ? values : []).forEach(function (value) {
+ value = cleanWorkText(value, 48);
+ if (value && !seen[value]) {
+ seen[value] = true;
+ output.push(value);
+ }
+ });
+ return output;
+ }
+
+ function cleanWorkText(value, maxLength) {
+ var text = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
+ var limit = Math.max(1, Number(maxLength || 48));
+ return text.length > limit ? text.slice(0, limit) : text;
+ }
+
+ function syncWorkInfoForMachine(force) {
+ var info = state.workInfo || defaultWorkInfo();
+ var machine = selectedMachine();
+ var persons;
+ var batches;
+ if (!machine) return false;
+ persons = Array.isArray(machine.persons) ? machine.persons : [];
+ batches = Array.isArray(machine.batches) ? machine.batches : [];
+ if (persons.length && (force ? persons.indexOf(info.person) < 0 : !info.person)) info.person = persons[0];
+ if (batches.length && (force ? batches.indexOf(info.batch) < 0 : !info.batch)) info.batch = batches[0];
+ state.workInfo = info;
+ return true;
+ }
+
+ function normalizeWorkContext(raw) {
+ var list = firstArrayValue(raw, ["items", "data", "list", "rows", "machines", "records"]);
+ var byMachine = {};
+ var order = [];
+ list.forEach(function (item) {
+ var persons;
+ var batches;
+ var machineNo;
+ var target;
+ if (!isPlainObject(item)) return;
+ persons = item.persons || item.people || item.operators || item.operator || item.staff || item.person || item.personName || item.userName || [];
+ batches = item.batches || item.productionBatches || item.productionBatch || item.batchNo || item.batch || [];
+ if (!Array.isArray(persons)) persons = String(persons || "").split(/[,,]/);
+ if (!Array.isArray(batches)) batches = String(batches || "").split(/[,,]/);
+ machineNo = cleanWorkText(item.machineNo || item.machine || item.machineId || item.machineCode || item.deviceNo || item.no, 48);
+ if (!machineNo) return;
+ if (!byMachine[machineNo]) {
+ byMachine[machineNo] = { machineNo: machineNo, persons: [], batches: [] };
+ order.push(machineNo);
+ }
+ target = byMachine[machineNo];
+ target.persons = uniqueTextList(target.persons.concat(persons));
+ target.batches = uniqueTextList(target.batches.concat(batches));
+ });
+ var normalized = order.map(function (machineNo) {
+ return byMachine[machineNo];
+ });
+ return normalized;
+ }
+
+ function sessionWorkInfo() {
+ return (state.session && state.session.workInfo) || {
+ machineNo: "",
+ person: "",
+ employeeName: "",
+ employeeNo: "",
+ identifiedAt: 0,
+ batch: ""
+ };
+ }
+
+ function sensorHeight(port) {
+ var info = ensurePortStatus(port);
+ var value = normalizeRangeValue(info.range);
+ return value == null ? 150 : value;
+ }
+
+ function draftSensorHeight(port) {
+ var draft = ensureSettingsDraft();
+ var info = (draft.deviceStatus.ports && draft.deviceStatus.ports[port]) || {};
+ var value = normalizeRangeValue(info.range);
+ return value == null ? 150 : value;
+ }
+
+ function resetRangeStability(port) {
+ rangeStability[port] = { full: 0, normal: 0 };
+ }
+
+ function normalizeRangeValue(value) {
+ var number = Number(value);
+ if (!isFinite(number) || number <= 0) return null;
+ return Math.max(1, Math.min(255, Math.round(number)));
+ }
+
+ function applyRangeReading(port, value) {
+ var info = state.deviceStatus.ports[port];
+ var range = normalizeRangeValue(value);
+ var tracker;
+ if (!info || range == null) return false;
+ info.range = range;
+ tracker = rangeStability[port] || { full: 0, normal: 0 };
+ if (range <= RANGE_FULL_CONFIRM_CM) {
+ tracker.full += 1;
+ tracker.normal = 0;
+ if (tracker.full >= RANGE_FULL_CONFIRM_COUNT) info.overflow = true;
+ } else if (range >= RANGE_NORMAL_CONFIRM_CM) {
+ tracker.normal += 1;
+ tracker.full = 0;
+ if (tracker.normal >= RANGE_NORMAL_CONFIRM_COUNT) info.overflow = false;
+ } else {
+ tracker.full = 0;
+ tracker.normal = 0;
+ }
+ rangeStability[port] = tracker;
+ return true;
+ }
+
+ function portTemperature(port) {
+ var info = ensurePortStatus(port);
+ return normalizeTemperatureValue(info.temperature, defaultPortStatus(port).temperature);
+ }
+
+ function portBlockReason(port) {
+ if (state.deviceStatus.maintenance) return t("maintenanceMode");
+ if (state.deviceStatus.deviceDisabled) return t("maintenance");
+ if (ensurePortStatus(port).overflow) return t("full");
+ return "";
+ }
+
+ function portStatusText(port) {
+ var reason = portBlockReason(port);
+ return reason || t("normal");
+ }
+
+ function portStatusTextForInfo(info) {
+ return info && info.overflow ? t("full") : t("normal");
+ }
+
+ function isPortBlocked(port) {
+ return Boolean(portBlockReason(port));
+ }
+
+ function topbar(subtitle, rightExtra) {
+ var actions = rightExtra || "";
+ return (
+ '' +
+ '' +
+ '
星
' +
+ '
' +
+ escapeHtml(t("appName")) +
+ '
' +
+ escapeHtml(subtitle || t("subtitle")) +
+ "
" +
+ "
" +
+ (actions ? '' + actions + "
" : "") +
+ ""
+ );
+ }
+
+ function statusPill(label, ok, badLabel) {
+ return '' + escapeHtml(ok ? label : badLabel) + "";
+ }
+
+ function render() {
+ var html = "";
+ if (state.screen === "boot") html = renderBoot();
+ if (state.screen === "home") html = renderHome();
+ if (state.screen === "auth") html = renderAuth();
+ if (state.screen === "workInfo") html = renderWorkInfo();
+ if (state.screen === "select") html = renderSelect();
+ if (state.screen === "delivery") html = renderDelivery();
+ if (state.screen === "success") html = renderSuccess();
+ if (state.screen === "adminLogin") html = renderAdminLogin();
+ if (state.screen === "settings") html = renderSettings();
+ app.innerHTML = html + renderLogWindow();
+ var bridge = nativeBridge();
+ var facePreviewActive = isFacePreviewActive();
+ if (bridge && typeof bridge.setFaceRecognitionActive === "function") {
+ try {
+ bridge.setFaceRecognitionActive(JSON.stringify({
+ active: facePreviewActive
+ }));
+ } catch (error) {
+ addLog("人脸", "切换摄像头状态失败", error.message || error);
+ }
+ }
+ syncFacePreviewLayout(facePreviewActive);
+ }
+
+ function isFacePreviewActive() {
+ return state.screen === "home" || Boolean(
+ state.screen === "settings" &&
+ state.activeTab === "face" &&
+ state.faceEnrollment &&
+ state.faceEnrollment.capturing
+ );
+ }
+
+ function syncFacePreviewLayout(visible) {
+ var bridge = nativeBridge();
+ if (!bridge || typeof bridge.setFacePreviewLayout !== "function") return;
+ if (!visible) {
+ facePreviewLayoutQueued = false;
+ bridge.setFacePreviewLayout(JSON.stringify({ visible: false }));
+ return;
+ }
+ if (facePreviewLayoutQueued) return;
+ facePreviewLayoutQueued = true;
+ window.requestAnimationFrame(function () {
+ window.requestAnimationFrame(function () {
+ facePreviewLayoutQueued = false;
+ var host = document.getElementById("face-camera-preview");
+ if (!host) {
+ bridge.setFacePreviewLayout(JSON.stringify({ visible: false }));
+ return;
+ }
+ if (host.classList.contains("home-camera-preview")) {
+ host.style.height = Math.round(host.getBoundingClientRect().width * 0.75) + "px";
+ }
+ var rect = host.getBoundingClientRect();
+ var scale = Number(window.devicePixelRatio || 1);
+ var inset = 4;
+ var visibleLeft = Math.max(rect.left + inset, 0);
+ var visibleTop = Math.max(rect.top + inset, 0);
+ var visibleRight = Math.min(rect.right - inset, window.innerWidth);
+ var visibleBottom = Math.min(rect.bottom - inset, window.innerHeight);
+ if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
+ bridge.setFacePreviewLayout(JSON.stringify({ visible: false }));
+ return;
+ }
+ bridge.setFacePreviewLayout(JSON.stringify({
+ visible: true,
+ left: Math.round(visibleLeft * scale),
+ top: Math.round(visibleTop * scale),
+ width: Math.max(1, Math.round((visibleRight - visibleLeft) * scale)),
+ height: Math.max(1, Math.round((visibleBottom - visibleTop) * scale))
+ }));
+ });
+ });
+ }
+
+ function renderBoot() {
+ var serialState = state.check.serial ? "ok" : state.check.completed ? "bad" : "";
+ var networkState = state.check.network ? "ok" : state.check.completed ? "bad" : "";
+ var bootDesc = state.check.completed
+ ? state.check.available
+ ? "串口和网络检测均已通过,检测结果显示 3 秒后自动进入首页。"
+ : "启动检测未通过,可以重新检测或点击“进入首页”。"
+ : "正在检查串口和 TCP/HTTP 网络状态,串口和网络均通过后才会自动进入首页。";
+ return (
+ '' +
+ '' +
+ '
' +
+ '
星元智灵启动自检
' +
+ '
' +
+ escapeHtml(bootDesc) +
+ "
" +
+ '
' +
+ checkRow("串口通讯", "SerialPort " + state.config.device.serialPath + " / " + state.config.device.baudRate, serialState, state.check.serial ? "已连接" : state.check.completed ? "未连接" : "检测中") +
+ checkRow("网络服务", state.config.server.tcpHost + ":" + state.config.server.tcpPort, networkState, state.check.network ? "已连接" : state.check.completed ? "未连接" : "检测中") +
+ "
" +
+ '
连接次数:' +
+ state.check.attempt +
+ " / " + STARTUP_CHECK_MAX_ATTEMPTS + "
" +
+ '
' +
+ (state.check.completed && state.check.available
+ ? ''
+ : '') +
+ (state.check.completed && !state.check.available
+ ? ''
+ : "") +
+ "
" +
+ "
" +
+ "
" +
+ ""
+ );
+ }
+
+ function checkRow(name, meta, stateClass, text) {
+ var cls = stateClass ? " " + stateClass : "";
+ return (
+ '' +
+ '
' +
+ escapeHtml(name) +
+ '
' +
+ escapeHtml(meta) +
+ "
" +
+ '
' +
+ escapeHtml(text) +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderHome() {
+ var reason = homeBlockReason();
+ var faceFeedback = homeFaceFeedback();
+ return (
+ '' +
+ topbar(t("subtitle")) +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ escapeHtml("请正对摄像头进行人脸识别") +
+ "
" +
+ '
' +
+ escapeHtml("人脸识别失败可输入工号进行登陆投放") +
+ "
" +
+ '
正在连接 USB 摄像头画面…
' +
+ (faceFeedback ? '
' + escapeHtml(faceFeedback) + '
' : '') +
+ '
' +
+ escapeHtml(reason || t("available")) +
+ "
" +
+ "
" +
+ renderHomeActions(reason) +
+ '
" +
+ "
" +
+ renderEmployeeLoginDialog() +
+ "
" +
+ ""
+ );
+ }
+
+ function homeFaceFeedback() {
+ var face = state.faceRecognition || {};
+ var status = String(face.status || "");
+ if (["face_error", "no_match", "error", "camera_error", "permission", "searching"].indexOf(status) < 0) {
+ return "";
+ }
+ return String(face.message || "人脸识别失败");
+ }
+
+ function renderHomeActions(reason) {
+ var blocked = Boolean(reason);
+ return (
+ '' +
+ '" +
+ "
"
+ );
+ }
+
+ function renderEmployeeLoginDialog() {
+ var login = state.employeeLogin || { open: false, value: "", message: "" };
+ if (!login.open) return "";
+ return (
+ '' +
+ '
' +
+ '
工号登录
' +
+ '
请输入工号。系统将向门口终端机发起身份鉴权。
' +
+ '
' + escapeHtml(login.value || "请输入工号") + "
" +
+ (login.message ? '
' + escapeHtml(login.message) + "
" : "") +
+ '
' +
+ [1, 2, 3, 4, 5, 6, 7, 8, 9].map(function (key) {
+ return '";
+ }).join("") +
+ '' +
+ '' +
+ '' +
+ "
" +
+ '
' +
+ "
"
+ );
+ }
+
+ function homeBlockReason() {
+ if (state.deviceStatus.maintenance || state.deviceStatus.deviceDisabled) return t("maintenance");
+ if (!state.deviceStatus.collectionDoorClosed) return "收运门未关闭";
+ if (!state.deviceStatus.serialOnline) return "串口故障:请检查串口占用情况以及下位机连接";
+ if (!state.deviceStatus.networkOnline) return "网络故障";
+ if (!state.deviceStatus.tcpConnected) return "服务器断开";
+ if (state.check.completed && !state.check.available) return t("unavailable");
+ return "";
+ }
+
+ function renderAuth() {
+ return (
+ '' +
+ topbar("身份鉴权") +
+ '' +
+ '
读取用户身份
' +
+ '
二维码扫码器和 IC 卡读取结果会按协议上传。
' +
+ '
' +
+ '
' +
+ (state.auth.status === "reading" ? "正在请求服务器鉴权" : state.auth.status === "failed" ? "鉴权失败" : "准备鉴权") +
+ "
" +
+ '
' +
+ escapeHtml(state.auth.message || "请稍候,正在建立用户会话。") +
+ "
" +
+ '
' +
+ '" +
+ "
" +
+ "
" +
+ "
" +
+ ""
+ );
+ }
+
+ function renderWorkInfo() {
+ var info = state.workInfo || defaultWorkInfo();
+ var user = (state.session && state.session.user) || {};
+ return (
+ '' +
+ topbar("投放信息") +
+ '' +
+ '
信息核对
' +
+ '
请核对员工信息,并模糊输入或从列表选择机台号。15 秒未完成将返回首页。
' +
+ '
员工姓名' +
+ escapeHtml(user.name || "-") +
+ '
工号' +
+ escapeHtml(user.employeeNo || user.account || "-") +
+ '
识别时间' +
+ escapeHtml(user.identifiedAtText || "-") +
+ "
" +
+ (info.loading
+ ? '
'
+ : renderWorkInfoForm(info)) +
+ "
" +
+ (!info.loading ? renderWorkPicker(info) : "") +
+ ""
+ );
+ }
+
+ function renderWorkInfoForm(info) {
+ return (
+ '"
+ );
+ }
+
+ function pickerInput(label, field, value, placeholder) {
+ return (
+ ''
+ );
+ }
+
+ function workInput(label, field, value, options, placeholder) {
+ var listId = "work-" + field + "-options";
+ return (
+ '' +
+ '
"
+ );
+ }
+
+ 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 (
+ ' | "
+ );
+ }).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("") +
+ "
" +
+ '
' +
+ '" +
+ '" +
+ "
" +
+ "
" +
+ ""
+ );
+ }
+
+ function renderWorkSummary(work) {
+ work = work || {};
+ return (
+ '' +
+ '
机台号' +
+ escapeHtml(work.machineNo || "-") +
+ "
" +
+ '
员工姓名' +
+ escapeHtml(work.employeeName || work.person || "-") +
+ "
" +
+ '
工号' +
+ escapeHtml(work.employeeNo || "-") +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderPortCard(port) {
+ var typeKey = state.config.portTypes[port] || "other";
+ var blocked = isPortBlocked(port);
+ return (
+ '"
+ );
+ }
+
+ function renderDelivery() {
+ var d = state.delivery || {};
+ var phase = deliveryPhaseText();
+ return (
+ '' +
+ topbar("投递中") +
+ '' +
+ '
' +
+ '
' +
+ escapeHtml(phase) +
+ "
" +
+ '
' +
+ (d.secondsLeft == null ? 30 : d.secondsLeft) +
+ "
" +
+ '
' +
+ escapeHtml(deliverySafetyText()) +
+ "
" +
+ '
' +
+ '" +
+ '" +
+ '" +
+ "
" +
+ "
" +
+ "
" +
+ ""
+ );
+ }
+
+ function deliveryPhaseText() {
+ var d = state.delivery || {};
+ if (d.baselineWaiting) return d.allPorts ? "正在读取全部投口的投放前重量" : "正在读取投放前重量";
+ if (d.closing) return d.allPorts ? "正在关闭全部投口" : "正在关闭投放门";
+ if (!d.opened) return d.allPorts ? "正在打开全部投口" : "正在打开投放门";
+ if (d.secondsLeft <= 5) return "即将关闭投放门";
+ return t("delivery");
+ }
+
+ function deliverySafetyText() {
+ var d = state.delivery || {};
+ if (d.baselineWaiting) return "正在锁定称重基准,完成后将自动开门,请暂时不要投放物品。";
+ if (d.closing) return d.allPorts ? "请确认手部和物品已离开所有投口,正在等待下位机返回称重结果。" : "请确认手部和物品已离开投口,正在等待下位机返回称重结果。";
+ if (d.delayClose) return d.allPorts ? "已延时关门,倒计时结束后将自动关闭全部投口。" : "已延时关门,倒计时结束后将自动关闭投放门。";
+ if (d.secondsLeft <= 5) return "投放门即将关闭,请勿将手伸入投口。";
+ return "请在倒计时结束前完成投递,也可以手动关闭投放门。";
+ }
+
+ function renderSuccess() {
+ var s = state.session || {};
+ var r = s.result || {};
+ return (
+ '' +
+ topbar("本次投递") +
+ '' +
+ '
' +
+ '
' +
+ escapeHtml(t("success")) +
+ "
" +
+ '
10 秒后自动返回首页
' +
+ renderSuccessPortCards(r) +
+ '
' +
+ '" +
+ "
" +
+ "
" +
+ "
" +
+ ""
+ );
+ }
+
+ function successPortDetails(result) {
+ var details = result && Array.isArray(result.portDetails) && result.portDetails.length
+ ? result.portDetails
+ : [{
+ port: result && result.port ? result.port : 1,
+ garbageTypeName: deliveryGarbageName(result && result.typeKey, result && result.port),
+ weight: result && result.weight != null ? result.weight : 0
+ }];
+ if (result && Array.isArray(result.ports) && result.ports.length) {
+ var visiblePorts = result.ports.map(function (port) { return Number(port); });
+ details = details.filter(function (item) {
+ return visiblePorts.indexOf(Number(item.port)) >= 0;
+ });
+ }
+ return details;
+ }
+
+ function renderSuccessPortCards(result) {
+ var details = successPortDetails(result);
+ return (
+ '' +
+ details.map(function (item) {
+ return (
+ '
' +
+ '
' +
+ escapeHtml(item.port + "号投口") +
+ "
" +
+ '
垃圾类型' +
+ escapeHtml(item.garbageTypeName || "-") +
+ "
" +
+ '
重量' +
+ escapeHtml(kgCompact(item.weight)) +
+ "
" +
+ "
"
+ );
+ }).join("") +
+ "
"
+ );
+ }
+
+ function renderAdminLogin() {
+ var pin = state.adminLogin.value || "";
+ var showPassword = Boolean(state.adminLogin.showPassword);
+ var pinText = pin ? (showPassword ? pin : "*".repeat(pin.length)) : t("passwordPlaceholder");
+ var adminMetaSeparator = isEnglish() ? " / " : ";";
+ return (
+ '' +
+ topbar(t("admin")) +
+ '' +
+ '
' +
+ '
' + 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) + "
" : "") +
+ '
' +
+ '" +
+ '" +
+ "
" +
+ "
" +
+ "
" +
+ ""
+ );
+ }
+
+ function renderAdminKeypad() {
+ var keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
+ return (
+ '' +
+ keys.map(function (key) {
+ return '";
+ }).join("") +
+ '" +
+ '' +
+ '" +
+ "
"
+ );
+ }
+
+ function renderSettings() {
+ return (
+ '' +
+ topbar(t("settingsTitle"), '") +
+ '' +
+ '" +
+ '' +
+ renderSettingsTab() +
+ "" +
+ "
" +
+ renderPortSettingDialog() +
+ ""
+ );
+ }
+
+ function tabButton(tab, label) {
+ return '";
+ }
+
+ function renderSettingsTab() {
+ if (state.activeTab === "device") return renderDeviceSettings();
+ if (state.activeTab === "ports") return renderPortSettings();
+ if (state.activeTab === "cache") return renderCacheSettings();
+ if (state.activeTab === "face") return renderFaceSettings();
+ return renderSystemSettings();
+ }
+
+ function renderDeviceSettings() {
+ return (
+ '' +
+ '
' + escapeHtml(t("deviceParams")) + '
' +
+ field(t("deviceNo"), "config.device.deviceNo", state.config.device.deviceNo || "") +
+ field(t("terminalImei"), "config.device.terminalId", state.config.device.terminalId) +
+ field(t("serialPath"), "config.device.serialPath", state.config.device.serialPath) +
+ "
" +
+ renderMaintenanceSettings() +
+ renderDeviceDisabledSettings() +
+ '
' + escapeHtml(t("deviceCheck")) + "
" +
+ '
' +
+ '" +
+ '" +
+ '" +
+ "
" +
+ renderTestResults() +
+ (state.lowerVersion ? '
' + escapeHtml(t("firmwareVersion")) + ":" + escapeHtml(localizedReadStatus(state.lowerVersion)) + "
" : "") +
+ (state.lowerImei ? '
' + escapeHtml(t("lowerImei")) + ":" + escapeHtml(localizedReadStatus(state.lowerImei)) + "
" : "") +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderMaintenanceSettings() {
+ return (
+ '' + escapeHtml(t("maintenanceMode")) + "
" +
+ '
' +
+ (state.deviceStatus.maintenance ? escapeHtml(t("maintenanceOnDesc")) : escapeHtml(t("maintenanceOffDesc"))) +
+ "
" +
+ '
' +
+ '" +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderDeviceDisabledSettings() {
+ return (
+ '' + escapeHtml(t("deviceDisabledTitle")) + "
" +
+ '
' +
+ (state.deviceStatus.deviceDisabled ? escapeHtml(t("deviceDisabledOnDesc")) : escapeHtml(t("deviceDisabledOffDesc"))) +
+ "
" +
+ '
' +
+ '" +
+ (state.deviceStatus.deviceDisabled ? '" : "") +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderPortSettings() {
+ var draft = ensureSettingsDraft();
+ var cfg = draft.config;
+ var options = allPhysicalPorts();
+ return (
+ '' +
+ '
' + escapeHtml(t("portCount")) + "
" +
+ '
" +
+ '
' + escapeHtml(t("portTypeStatus")) + "
" +
+ '
' +
+ allPhysicalPorts().map(renderPortSettingButton).join("") +
+ "
" +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderPortSettingButton(port) {
+ var draft = ensureSettingsDraft();
+ var cfg = draft.config;
+ var enabled = port <= Number(cfg.portCount);
+ var typeKey = cfg.portTypes[port] || "other";
+ return (
+ '"
+ );
+ }
+
+ function renderPortSettingDialog() {
+ var draft = ensureSettingsDraft();
+ var cfg = draft.config;
+ var port = Number(state.portSettingsPort || 0);
+ var enabled = port && port <= Number(cfg.portCount);
+ if (state.activeTab !== "ports") return "";
+ if (!enabled) return "";
+ return renderPortSettingEditor(port);
+ }
+
+ function renderPortSettingEditor(port) {
+ var draft = ensureSettingsDraft();
+ var cfg = draft.config;
+ var liveInfo = ensurePortStatus(port);
+ var typeKey = cfg.portTypes[port] || "other";
+ return (
+ '' +
+ '
' +
+ '
' +
+ escapeHtml(portDisplayName(port) + "设置") +
+ '
' +
+ '
' +
+ '
" +
+ (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(t("localBackupStorageHint")) + "
" +
+ '
' + escapeHtml(t("backupPath")) + ":" +
+ escapeHtml(state.config.localBackup.path || t("generatedAfterEnable")) +
+ "
" +
+ '
' + escapeHtml(t("localRecord")) + ":" +
+ escapeHtml(recordCountLabel(state.localBackups.length)) +
+ "
" +
+ "
" +
+ '
' + escapeHtml(t("cacheRules")) + "
" +
+ '
' +
+ field(t("retainDays"), "config.cache.retainDays", state.config.cache.retainDays, "number") +
+ field(t("retryIntervalHours"), "config.cache.retryIntervalHours", state.config.cache.retryIntervalHours, "number") +
+ "
" +
+ '
' +
+ '" +
+ '" +
+ "
" +
+ '
' + escapeHtml(t("pendingQueue")) + "(" + state.offlineQueue.length + ")
" +
+ renderQueue() +
+ "" +
+ "
"
+ );
+ }
+
+ function renderFaceSettings() {
+ var faceEnrollment = state.faceEnrollment || { employeeNo: "", name: "", selectedFaceId: "", capturing: false, message: "" };
+ var people = Array.isArray(state.facePeople) ? state.facePeople : [];
+ var selectedPerson = people.filter(function (person) {
+ return String(person.faceId || "") === String(faceEnrollment.selectedFaceId || "");
+ })[0] || null;
+ var peopleRows = people.length
+ ? people.map(function (person) {
+ return (
+ '| ' + escapeHtml(person.name || "-") + ' | ' +
+ '' + escapeHtml(person.employeeNo || "-") + ' | ' +
+ '' + (person.registered ? "已录入" : "未录入") + ' | ' +
+ '' +
+ '' +
+ '' +
+ ' |
'
+ );
+ }).join("")
+ : '| 暂无人员,请先新增人员信息 |
';
+ return (
+ '' +
+ '
人脸匹配阈值
' +
+ '
数值越高越严格。允许范围 0.50~0.95,当前建议值为 0.75。
' +
+ '
' +
+ '
' +
+ '
第一步:新增人员信息
' +
+ '
填写工号和姓名并保存人员,再到人员列表中录入人脸。
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ (selectedPerson ? '
第二步:录入人脸
' +
+ '
当前人员:' + escapeHtml(selectedPerson.name) + '(' + escapeHtml(selectedPerson.employeeNo) + ')。请让本人正对设备摄像头,确保摄像头前只有一人,然后点击开始采集。
' +
+ (faceEnrollment.message ? '
' + escapeHtml(faceEnrollment.message) + '
' : '') +
+ (faceEnrollment.capturing
+ ? '
SeetaFace6 人脸采集
' +
+ '
请保持正脸、光线均匀,并确保画面中只有当前人员。
' +
+ '
正在调取摄像头画面…
' +
+ '
' + escapeHtml((state.faceRecognition && state.faceRecognition.message) || "请正对摄像头") + '
' +
+ '
' +
+ '
'
+ : '
' +
+ '
') +
+ '
' : '') +
+ '
人员管理
' +
+ '
当前共 ' + people.length + ' 人。未录入人员可点击“录入人脸”继续第二步。
' +
+ '
' +
+ '
| 姓名 | 工号 | 人脸状态 | 操作 |
' +
+ peopleRows + '
' +
+ "
"
+ );
+ }
+
+ function renderSystemSettings() {
+ var logEnabled = isLogEnabled();
+ return (
+ '' +
+ '
' + escapeHtml(t("languageSystem")) + '
" +
+ '
' + escapeHtml(t("changePassword")) + "
" +
+ '
" +
+ renderPasswordChangePanel() +
+ "
" +
+ '
' + escapeHtml(t("systemTools")) + "
" +
+ '
" +
+ "
" +
+ "
"
+ );
+ }
+
+ function renderPasswordChangePanel() {
+ var flow = state.passwordChange;
+ if (!flow || !flow.open) return "";
+ var labels = {
+ current: t("passwordCurrentPrompt"),
+ next: t("passwordNextPrompt"),
+ confirm: t("passwordConfirmPrompt")
+ };
+ var value = flow.step === "current" ? flow.current : flow.step === "next" ? flow.next : flow.confirm;
+ return (
+ '' +
+ '
' +
+ '
' +
+ escapeHtml(labels[flow.step] || labels.current) +
+ "
" +
+ '
' +
+ escapeHtml(maskPin(value)) +
+ "
" +
+ (flow.message ? '
' + escapeHtml(flow.message) + "
" : "") +
+ "
" +
+ '
' +
+ [1, 2, 3, 4, 5, 6, 7, 8, 9].map(function (num) {
+ return '";
+ }).join("") +
+ '" +
+ '' +
+ '" +
+ "
" +
+ '
' +
+ '" +
+ '" +
+ "
" +
+ "
"
+ );
+ }
+
+ function field(label, path, value, type) {
+ var inputType = type || "text";
+ return (
+ ''
+ );
+ }
+
+ function draftField(label, path, value, type) {
+ var inputType = type || "text";
+ return (
+ ''
+ );
+ }
+
+ function readonlyField(label, value) {
+ return (
+ '' +
+ escapeHtml(value == null ? "" : String(value)) +
+ "
"
+ );
+ }
+
+ function toggle(label, path, value, help) {
+ return (
+ '' +
+ '
' +
+ escapeHtml(label) +
+ "" +
+ (help ? "" + escapeHtml(help) + "" : "") +
+ "
" +
+ '
' +
+ "
"
+ );
+ }
+
+ function draftToggle(label, path, value, help) {
+ return (
+ '' +
+ '
' +
+ escapeHtml(label) +
+ "" +
+ (help ? "" + escapeHtml(help) + "" : "") +
+ "
" +
+ '
' +
+ "
"
+ );
+ }
+
+ function renderTestResults() {
+ if (!state.testResults) return "";
+ return (
+ '' +
+ state.testResults.map(function (item) {
+ return '
' + escapeHtml(item.label) + "" + escapeHtml(item.ok ? t("pass") : t("fail")) + "
";
+ }).join("") +
+ "
"
+ );
+ }
+
+ function renderQueue() {
+ var queue = sanitizeOfflineQueue(state.offlineQueue);
+ if (queue.length !== state.offlineQueue.length) state.offlineQueue = queue;
+ if (!queue.length) return '' + escapeHtml(t("noPendingData")) + "
";
+ return (
+ '' +
+ queue.map(function (item) {
+ return (
+ '
' +
+ '
' +
+ escapeHtml(item.kindText) +
+ "" +
+ '
' +
+ escapeHtml(nowText(item.createdAt)) +
+ " / " +
+ escapeHtml(item.summary) +
+ "
" +
+ '
' +
+ escapeHtml(item.status || "queued") +
+ "" +
+ "
"
+ );
+ }).join("") +
+ "
"
+ );
+ }
+
+ function beginStartupCheck() {
+ window.clearTimeout(timers.boot);
+ state.screen = "boot";
+ state.check = defaultCheck();
+ state.check.running = true;
+ render();
+ requestConnectionCheck("startup");
+ }
+
+ function finishStartupCheck(available, skipped) {
+ window.clearTimeout(timers.boot);
+ state.check.completed = true;
+ state.check.running = false;
+ state.check.available = Boolean(
+ available &&
+ state.check.serial &&
+ state.check.network
+ );
+ state.check.skipped = skipped;
+ savePersisted();
+ render();
+ if (state.check.available) {
+ timers.boot = window.setTimeout(function () {
+ if (state.screen === "boot" && state.check.completed && state.check.available) {
+ enterHomeAfterStartup(false);
+ }
+ }, STARTUP_RESULT_HOLD_MS);
+ }
+ }
+
+ function enterHomeAfterStartup(skipped) {
+ window.clearTimeout(timers.boot);
+ connectionCheckRequestId += 1;
+ state.check.skipped = Boolean(skipped);
+ state.screen = "home";
+ savePersisted();
+ render();
+ }
+
+ function hasValidTerminalId() {
+ return /^\d{10,20}$/.test(String(state.config.device.terminalId || "").trim());
+ }
+
+ function refreshNetworkState(networkState, logChange) {
+ var changed = false;
+ var networkOnline = Boolean(networkState && networkState.networkOnline);
+ var tcpConnected = Boolean(networkState && networkState.tcpConnected);
+ var httpReachable = Boolean(networkState && networkState.httpReachable);
+ if (
+ state.deviceStatus.networkOnline !== networkOnline ||
+ state.deviceStatus.tcpConnected !== tcpConnected ||
+ state.deviceStatus.httpReachable !== httpReachable
+ ) {
+ state.deviceStatus.networkOnline = networkOnline;
+ state.deviceStatus.tcpConnected = tcpConnected;
+ state.deviceStatus.httpReachable = httpReachable;
+ changed = true;
+ if (logChange) {
+ addLog(
+ "状态",
+ networkOnline ? "网络连接恢复" : "网络连接断开",
+ tcpConnected ? "业务服务器可连接" : "业务服务器不可连接"
+ );
+ }
+ }
+ return changed;
+ }
+
+ function requestConnectionCheck(mode) {
+ var bridge = nativeBridge();
+ var requestId = ++connectionCheckRequestId;
+ if (mode === "startup") state.check.attempt += 1;
+ if (!bridge || typeof bridge.requestConnectionCheck !== "function") {
+ handleConnectionCheck({ requestId: requestId, mode: mode, error: "原生检测接口不可用" });
+ return;
+ }
+ bridge.requestConnectionCheck(JSON.stringify({
+ requestId: requestId,
+ mode: mode,
+ path: state.config.device.serialPath,
+ baudRate: Number(state.config.device.baudRate || 9600),
+ host: state.config.server.tcpHost,
+ port: Number(state.config.server.tcpPort || DEFAULT_REPORT_PORT),
+ pingHost: "www.baidu.com",
+ timeoutSeconds: 2
+ }));
+ window.clearTimeout(timers.boot);
+ timers.boot = window.setTimeout(function () {
+ if (requestId !== connectionCheckRequestId) return;
+ handleConnectionCheck({ requestId: requestId, mode: mode, error: "检测超时" });
+ }, 4500);
+ }
+
+ function handleConnectionCheck(payload) {
+ var mode = payload && payload.mode || "startup";
+ if (!payload || Number(payload.requestId || 0) !== connectionCheckRequestId) return;
+ window.clearTimeout(timers.boot);
+ if (mode === "network") {
+ applyNetworkTestResult(payload);
+ return;
+ }
+ state.deviceStatus.serialOnline = Boolean(payload.serialOnline);
+ refreshNetworkState(payload, false);
+ state.check.serial = state.deviceStatus.serialOnline;
+ state.check.network = Boolean(
+ state.deviceStatus.networkOnline &&
+ state.deviceStatus.tcpConnected &&
+ state.deviceStatus.httpReachable
+ );
+ render();
+ if (state.check.serial && state.check.network) {
+ if (!hasValidTerminalId()) addLog("串口", "串口在线但尚未获取IMEI");
+ finishStartupCheck(true, false);
+ } else if (state.check.attempt < STARTUP_CHECK_MAX_ATTEMPTS) {
+ timers.boot = window.setTimeout(function () { requestConnectionCheck("startup"); }, 500);
+ } else {
+ finishStartupCheck(false, false);
+ }
+ }
+
+ function requestPortTelemetry(ports) {
+ var sent = false;
+ (Array.isArray(ports) && ports.length ? ports : allPhysicalPorts()).forEach(function (port) {
+ var payload = serialPayload({ port: port });
+ sent = nativeBooleanCallSilent("queryWeight", payload) || sent;
+ sent = nativeBooleanCallSilent("queryFill", payload) || sent;
+ sent = nativeBooleanCallSilent("queryTemperature", payload) || sent;
+ });
+ return sent;
+ }
+
+ function enterSelectScreen() {
+ state.screen = "select";
+ render();
+ startSelectTimeout();
+ }
+
+ function cancelSettingsPortTelemetry() {
+ window.clearTimeout(timers.settingsTelemetry);
+ }
+
+ function scheduleSettingsPortTelemetry(ports, delay) {
+ cancelSettingsPortTelemetry();
+ timers.settingsTelemetry = window.setTimeout(function () {
+ refreshSettingsPortTelemetry(ports);
+ }, delay == null ? 120 : delay);
+ }
+
+ function refreshSettingsPortTelemetry(ports) {
+ var targetPorts;
+ var index = 0;
+ var sent = false;
+ targetPorts = (Array.isArray(ports) && ports.length ? ports : enabledPorts())
+ .map(function (port) { return Number(port); })
+ .filter(function (port) { return port >= 1 && port <= MAX_PORT_COUNT; });
+ if (!targetPorts.length) return;
+ cancelSettingsPortTelemetry();
+ function queryNextPort() {
+ var port;
+ if (state.screen !== "settings" || state.activeTab !== "ports") return;
+ if (index >= targetPorts.length) {
+ if (sent) addLog("串口", "设置页读取投口实时数据", targetPorts.length + "个投口");
+ return;
+ }
+ port = targetPorts[index];
+ index += 1;
+ sent = requestPortTelemetry([port]) || sent;
+ timers.settingsTelemetry = window.setTimeout(queryNextPort, 90);
+ }
+ queryNextPort();
+ }
+
+ function failAuthAndReturn(message) {
+ state.auth = { status: "failed", message: message || "会员鉴权失败" };
+ addLog("窗口", "身份鉴权失败", state.auth.message);
+ render();
+ window.clearTimeout(timers.authFail);
+ timers.authFail = window.setTimeout(function () {
+ if (state.screen === "auth" && state.auth.status === "failed") {
+ goHome();
+ }
+ }, 1800);
+ }
+
+ function authPriority(source) {
+ if (source === "face") return 3;
+ if (source === "card") return 2;
+ return 1;
+ }
+
+ function authFailureMessage(source, fallback) {
+ if (source === "employee") return "工号错误";
+ if (source === "card") return "卡片识别错误";
+ if (source === "face") return "人脸识别失败";
+ return fallback || "身份鉴权失败";
+ }
+
+ function openEmployeeLogin() {
+ if (homeBlockReason()) {
+ showToast(homeBlockReason());
+ return;
+ }
+ state.employeeLogin = { open: true, value: "", message: "" };
+ render();
+ }
+
+ function closeEmployeeLogin() {
+ state.employeeLogin = { open: false, value: "", message: "" };
+ render();
+ }
+
+ function appendEmployeeDigit(digit) {
+ var login = state.employeeLogin;
+ if (!login || !login.open || login.value.length >= 20) return;
+ login.value += String(digit);
+ login.message = "";
+ render();
+ }
+
+ function backspaceEmployeeDigit() {
+ if (!state.employeeLogin || !state.employeeLogin.open) return;
+ state.employeeLogin.value = state.employeeLogin.value.slice(0, -1);
+ state.employeeLogin.message = "";
+ render();
+ }
+
+ function clearEmployeeDigits() {
+ if (!state.employeeLogin || !state.employeeLogin.open) return;
+ state.employeeLogin.value = "";
+ state.employeeLogin.message = "";
+ render();
+ }
+
+ function submitEmployeeLogin() {
+ var value = String((state.employeeLogin && state.employeeLogin.value) || "").trim();
+ if (!/^\d{1,20}$/.test(value)) {
+ state.employeeLogin.message = "请输入正确的工号";
+ 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, value: "", 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 === "employee-key") appendEmployeeDigit(target.getAttribute("data-key"));
+ if (action === "employee-backspace") backspaceEmployeeDigit();
+ if (action === "employee-clear") clearEmployeeDigits();
+ 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 === "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 && /^[0-9]$/.test(event.key)) appendEmployeeDigit(event.key);
+ if (state.screen === "home" && state.employeeLogin && state.employeeLogin.open && event.key === "Backspace") backspaceEmployeeDigit();
+ 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();
+})();
diff --git a/app/src/main/assets/www/assets/recycle-poster-1.png b/app/src/main/assets/www/assets/recycle-poster-1.png
new file mode 100644
index 0000000..07f1ef9
Binary files /dev/null and b/app/src/main/assets/www/assets/recycle-poster-1.png differ
diff --git a/app/src/main/assets/www/assets/recycle-poster-2.png b/app/src/main/assets/www/assets/recycle-poster-2.png
new file mode 100644
index 0000000..d3a976a
Binary files /dev/null and b/app/src/main/assets/www/assets/recycle-poster-2.png differ
diff --git a/app/src/main/assets/www/assets/recycle-poster-3.png b/app/src/main/assets/www/assets/recycle-poster-3.png
new file mode 100644
index 0000000..652a947
Binary files /dev/null and b/app/src/main/assets/www/assets/recycle-poster-3.png differ
diff --git a/app/src/main/assets/www/index.html b/app/src/main/assets/www/index.html
new file mode 100644
index 0000000..c478abe
--- /dev/null
+++ b/app/src/main/assets/www/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ 星元智灵
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/www/styles.css b/app/src/main/assets/www/styles.css
new file mode 100644
index 0000000..436c643
--- /dev/null
+++ b/app/src/main/assets/www/styles.css
@@ -0,0 +1,1870 @@
+:root {
+ --ink: #1d2729;
+ --muted: #637174;
+ --line: #d8e0de;
+ --paper: #f7faf8;
+ --panel: #ffffff;
+ --deep: #1b3134;
+ --teal: #23777f;
+ --green: #5d9f5e;
+ --amber: #e7a23a;
+ --red: #c9503f;
+ --blue: #447ca7;
+ --shadow: 0 14px 38px rgba(24, 40, 43, 0.12);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ min-height: 100%;
+}
+
+body {
+ margin: 0;
+ background: #e9eeec;
+ color: var(--ink);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
+ "Microsoft YaHei", Arial, sans-serif;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:disabled,
+input:disabled,
+select:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.app-shell {
+ width: min(100vw, 800px);
+ min-height: 100vh;
+ min-height: 100dvh;
+ margin: 0 auto;
+ background: var(--paper);
+ box-shadow: 0 0 0 1px rgba(27, 49, 52, 0.08);
+ overflow-x: hidden;
+}
+
+.screen {
+ min-height: 100vh;
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ min-height: 74px;
+ padding: 14px 22px;
+ background: var(--deep);
+ color: #fff;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.brand-mark {
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ border-radius: 8px;
+ background: #fff;
+ color: var(--deep);
+ font-weight: 800;
+}
+
+.brand-title {
+ margin: 0;
+ font-size: 22px;
+ line-height: 1.05;
+ letter-spacing: 0;
+}
+
+.brand-subtitle {
+ margin: 5px 0 0;
+ color: rgba(255, 255, 255, 0.72);
+ font-size: 12px;
+}
+
+.topbar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 30px;
+ padding: 5px 9px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.11);
+ color: #fff;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.pill::before {
+ content: "";
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green);
+}
+
+.pill.warn::before {
+ background: var(--amber);
+}
+
+.pill.bad::before {
+ background: var(--red);
+}
+
+.content {
+ flex: 1;
+ padding: 22px;
+}
+
+.boot-screen {
+ background:
+ linear-gradient(rgba(27, 49, 52, 0.86), rgba(27, 49, 52, 0.86)),
+ url("./assets/recycle-poster-1.png") center / cover;
+ color: #fff;
+}
+
+.boot-panel {
+ width: min(100%, 560px);
+ margin: auto;
+ padding: 28px;
+ background: rgba(255, 255, 255, 0.96);
+ color: var(--ink);
+ border-radius: 8px;
+ box-shadow: var(--shadow);
+}
+
+.boot-title {
+ margin: 0 0 8px;
+ font-size: 30px;
+ letter-spacing: 0;
+}
+
+.boot-desc {
+ margin: 0 0 20px;
+ color: var(--muted);
+ line-height: 1.7;
+}
+
+.check-list {
+ display: grid;
+ gap: 12px;
+ margin: 22px 0;
+}
+
+.check-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 13px 14px;
+ border: 1px solid var(--line);
+ background: #fbfdfc;
+ border-radius: 8px;
+}
+
+.check-name {
+ font-weight: 700;
+}
+
+.check-meta {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.check-state {
+ min-width: 76px;
+ text-align: center;
+ padding: 7px 10px;
+ border-radius: 8px;
+ color: #fff;
+ background: var(--amber);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.check-state.ok {
+ background: var(--green);
+}
+
+.check-state.bad {
+ background: var(--red);
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.btn {
+ min-height: 44px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 0 16px;
+ background: #fff;
+ color: var(--ink);
+ font-weight: 700;
+}
+
+.btn.primary {
+ border-color: var(--teal);
+ background: var(--teal);
+ color: #fff;
+}
+
+.btn.success {
+ border-color: var(--green);
+ background: var(--green);
+ color: #fff;
+}
+
+.btn.danger {
+ border-color: var(--red);
+ background: var(--red);
+ color: #fff;
+}
+
+.btn.dark {
+ border-color: var(--deep);
+ background: var(--deep);
+ color: #fff;
+}
+
+.btn.ghost {
+ background: transparent;
+}
+
+.icon-btn {
+ width: 44px;
+ min-width: 44px;
+ height: 44px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--ink);
+ font-weight: 800;
+}
+
+.home-main {
+ flex: 1;
+ display: block;
+ padding: 22px;
+}
+
+.app-log-window {
+ position: fixed;
+ top: 84px;
+ right: max(12px, calc((100vw - 800px) / 2 + 12px));
+ z-index: 80;
+ width: min(430px, calc(100vw - 24px));
+ max-height: 220px;
+ overflow: hidden;
+ padding: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 8px;
+ background: rgba(13, 22, 23, 0.9);
+ color: #fff;
+ box-shadow: 0 14px 30px rgba(0, 0, 0, 0.22);
+ pointer-events: none;
+}
+
+.log-title {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+ color: rgba(255, 255, 255, 0.78);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.log-list {
+ display: grid;
+ gap: 5px;
+}
+
+.log-line {
+ display: grid;
+ grid-template-columns: 56px 42px minmax(0, 1fr);
+ gap: 6px;
+ align-items: start;
+ font-size: 11px;
+ line-height: 1.35;
+}
+
+.log-time {
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.log-kind {
+ color: #9edfd6;
+ font-weight: 800;
+}
+
+.log-message {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.log-empty {
+ color: rgba(255, 255, 255, 0.68);
+ font-size: 12px;
+}
+
+.home-lower {
+ position: relative;
+ min-height: calc(100vh - 118px);
+ display: grid;
+ grid-template-rows: 1fr auto;
+ align-items: center;
+ padding: 34px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+}
+
+.home-prompt {
+ text-align: center;
+}
+
+.prompt-title {
+ margin: 0;
+ font-size: 42px;
+ font-size: clamp(30px, 6vw, 52px);
+ line-height: 1.18;
+ letter-spacing: 0;
+}
+
+.prompt-subtitle {
+ margin: 18px auto 0;
+ max-width: 520px;
+ color: var(--muted);
+ font-size: 18px;
+ line-height: 1.7;
+}
+
+.home-status {
+ margin-top: 26px;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ max-width: min(86vw, 760px);
+ min-height: 42px;
+ padding: 10px 14px;
+ border-radius: 8px;
+ background: #eef6f2;
+ color: #31543a;
+ font-weight: 800;
+ line-height: 1.45;
+}
+
+.home-status.bad {
+ background: #f8e6e2;
+ color: #833225;
+}
+
+.face-live-feedback {
+ width: min(100%, 560px);
+ margin: 12px auto 0;
+ padding: 10px 14px;
+ border-radius: 8px;
+ background: #fff3d8;
+ color: #79520c;
+ text-align: center;
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.face-live-feedback.bad {
+ background: #f8e6e2;
+ color: #833225;
+}
+
+.face-recognition-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ margin: 22px auto 0;
+ color: var(--teal);
+ font-size: 20px;
+}
+
+.camera-preview-host {
+ position: relative;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ border: 4px solid #d9e5e2;
+ border-radius: 12px;
+ background: #11191b;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 14px;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+
+.camera-preview-host::after {
+ content: "USB CAMERA";
+ position: absolute;
+ right: 10px;
+ bottom: 8px;
+ z-index: 2;
+ padding: 4px 7px;
+ border-radius: 5px;
+ background: rgba(0, 0, 0, 0.48);
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ pointer-events: none;
+}
+
+.home-camera-preview {
+ width: 100%;
+ max-width: 560px;
+ height: 420px;
+ margin: 20px auto 0;
+}
+
+.settings-camera-preview {
+ width: 100%;
+ max-width: 480px;
+ height: 360px;
+ margin: 0 auto;
+}
+
+.face-preview-status {
+ margin: 12px 0 0;
+ text-align: center;
+ font-weight: 700;
+}
+
+.face-enroll-button {
+ min-width: 180px;
+ min-height: 50px;
+}
+
+.face-enrollment-message {
+ color: var(--teal);
+ font-weight: 800;
+}
+
+.face-people-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 14px;
+}
+
+.face-people-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: #fff;
+}
+
+.face-people-table th,
+.face-people-table td {
+ padding: 13px 12px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ vertical-align: middle;
+}
+
+.face-people-table th {
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.face-registered-badge {
+ display: inline-flex;
+ padding: 5px 9px;
+ border-radius: 999px;
+ background: #dff2e8;
+ color: #256344;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.face-registered-badge.pending {
+ background: #fff0d4;
+ color: #8a5a11;
+}
+
+.face-person-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.face-person-actions .btn {
+ min-height: 38px;
+ padding: 7px 12px;
+}
+
+.face-capture-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 95;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 18px;
+ background: rgba(12, 26, 28, 0.76);
+}
+
+.face-capture-dialog {
+ width: min(94vw, 640px);
+ max-height: 94vh;
+ overflow-y: auto;
+ padding: 18px;
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
+}
+
+.face-capture-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.face-capture-preview {
+ width: 100%;
+ height: 360px;
+ margin-top: 12px;
+}
+
+.face-capture-inline {
+ margin-top: 14px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #f6f9f8;
+}
+
+.face-capture-inline h3 {
+ margin: 0;
+ font-size: 20px;
+}
+
+.face-capture-status {
+ margin: 12px 0 0;
+ color: var(--teal);
+ text-align: center;
+ font-weight: 800;
+}
+
+.face-capture-actions {
+ justify-content: center;
+}
+
+.face-empty-row {
+ padding: 28px 12px !important;
+ color: var(--muted);
+ text-align: center !important;
+}
+
+.face-frame {
+ display: grid;
+ place-items: center;
+ width: 54px;
+ height: 54px;
+ border: 3px solid var(--teal);
+ border-radius: 14px;
+ font-size: 30px;
+ animation: facePulse 1.6s ease-in-out infinite;
+}
+
+@keyframes facePulse {
+ 50% { box-shadow: 0 0 0 8px rgba(35, 130, 122, 0.12); }
+}
+
+.home-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+}
+
+.home-login-btn {
+ min-width: 180px;
+ min-height: 56px;
+ font-size: 20px;
+}
+
+.admin-entry {
+ position: absolute;
+ right: 18px;
+ bottom: 18px;
+}
+
+.flow-screen.white {
+ background: #fff;
+ color: #101517;
+}
+
+.flow-body {
+ flex: 1;
+ padding: 28px 26px;
+}
+
+.section-title {
+ margin: 0 0 8px;
+ font-size: 26px;
+ letter-spacing: 0;
+}
+
+.section-desc {
+ margin: 0 0 20px;
+ color: var(--muted);
+ line-height: 1.65;
+}
+
+.user-band {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 14px;
+ align-items: center;
+ margin-bottom: 20px;
+ padding: 18px 20px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdfc;
+}
+
+.work-info-body {
+ display: flex;
+ flex-direction: column;
+}
+
+.work-form {
+ width: min(100%, 620px);
+ display: grid;
+ gap: 16px;
+ margin-top: 10px;
+}
+
+.work-field {
+ margin-bottom: 0;
+}
+
+.work-field input {
+ min-height: 58px;
+ font-size: 20px;
+}
+
+.picker-input-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 112px;
+ gap: 10px;
+ align-items: stretch;
+}
+
+.picker-input-row input {
+ min-width: 0;
+}
+
+.picker-button {
+ min-height: 58px;
+ font-size: 18px;
+}
+
+.work-picker-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: rgba(10, 28, 30, 0.38);
+}
+
+.work-picker-dialog {
+ width: min(92vw, 620px);
+ max-height: min(72vh, 560px);
+ overflow: auto;
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: var(--shadow);
+}
+
+.employee-login-dialog {
+ width: min(92vw, 520px);
+}
+
+.employee-number-display {
+ min-height: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 14px;
+ padding: 10px 16px;
+ border: 2px solid var(--line);
+ border-radius: 8px;
+ background: #f8fbfa;
+ font-size: 28px;
+ font-weight: 900;
+ letter-spacing: 2px;
+}
+
+.employee-keypad {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ margin: 12px 0 18px;
+}
+
+.work-picker-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.work-picker-head h3 {
+ margin: 0;
+ font-size: 24px;
+}
+
+.work-picker-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+}
+
+.work-picker-table td {
+ padding: 6px;
+}
+
+.work-picker-cell {
+ width: 100%;
+ min-height: 56px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #f8fbfa;
+ color: var(--ink);
+ font-size: 19px;
+ font-weight: 900;
+}
+
+.work-picker-cell.selected,
+.work-picker-cell:active {
+ border-color: var(--teal);
+ background: #e5f4ef;
+ color: #174f51;
+}
+
+.empty-work-picker-list {
+ padding: 22px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.work-actions {
+ margin-top: 10px;
+}
+
+.work-actions .btn {
+ min-height: 58px;
+ padding: 0 24px;
+ font-size: 18px;
+}
+
+.loading-panel {
+ width: min(100%, 620px);
+}
+
+.work-summary {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.work-summary-item {
+ min-width: 0;
+}
+
+.work-summary-item span {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--muted);
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.work-summary-item strong {
+ display: block;
+ overflow-wrap: anywhere;
+ font-size: 24px;
+ line-height: 1.25;
+}
+
+.user-band strong {
+ font-size: 30px;
+ line-height: 1.25;
+}
+
+.work-summary .work-summary-item strong {
+ font-size: 24px;
+}
+
+.user-band .small {
+ margin-top: 6px;
+ font-size: 22px;
+}
+
+.balance {
+ font-size: 30px;
+ font-weight: 800;
+ color: var(--teal);
+}
+
+.ports-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.ports-grid.port-count-1 {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.ports-grid.port-count-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.ports-grid.port-count-4 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.port-card {
+ min-height: 150px;
+ padding: 18px;
+ border: 2px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ text-align: left;
+}
+
+.ports-grid.compact .port-card {
+ min-height: 136px;
+ padding: 16px;
+}
+
+.port-card.available {
+ border-color: rgba(35, 119, 127, 0.42);
+}
+
+.port-card.available:hover {
+ border-color: var(--teal);
+ box-shadow: 0 8px 18px rgba(35, 119, 127, 0.12);
+}
+
+.port-card.blocked {
+ background: #f4f5f3;
+ color: #7b8587;
+}
+
+.port-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+}
+
+.port-number {
+ font-size: 28px;
+ font-weight: 900;
+}
+
+.ports-grid.compact .port-number {
+ font-size: 24px;
+}
+
+.port-head .small {
+ margin-top: 6px;
+ font-size: 16px;
+}
+
+.ports-grid.compact .port-head .small {
+ font-size: 14px;
+}
+
+.type-chip {
+ padding: 9px 13px;
+ border-radius: 8px;
+ background: #e9f4f1;
+ color: #285c55;
+ font-size: 22px;
+ font-weight: 900;
+}
+
+.ports-grid.compact .type-chip {
+ padding: 8px 11px;
+ font-size: 19px;
+}
+
+.select-actions {
+ margin-top: 20px;
+}
+
+.delivery-stage {
+ min-height: 610px;
+ display: grid;
+ place-items: center;
+ text-align: center;
+}
+
+.countdown {
+ margin: 18px 0 8px;
+ font-size: 120px;
+ font-size: clamp(82px, 20vw, 170px);
+ font-weight: 900;
+ line-height: 0.95;
+ letter-spacing: 0;
+}
+
+.phase {
+ font-size: 44px;
+ font-size: clamp(28px, 6vw, 54px);
+ font-weight: 900;
+ letter-spacing: 0;
+}
+
+.safety {
+ min-height: 58px;
+ margin: 18px auto 28px;
+ max-width: 560px;
+ color: #5d3528;
+ font-size: 20px;
+ line-height: 1.55;
+}
+
+.delivery-actions {
+ justify-content: center;
+}
+
+.delivery-actions .btn {
+ min-width: 150px;
+ min-height: 54px;
+ font-size: 18px;
+}
+
+.success-port-result-grid {
+ width: min(100%, 760px);
+ margin: 0 auto;
+ display: grid;
+ gap: 14px;
+}
+
+.success-port-result-grid.port-count-1 {
+ max-width: 360px;
+ grid-template-columns: 1fr;
+}
+
+.success-port-result-grid.port-count-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.success-port-result-grid.port-count-3,
+.success-port-result-grid.port-count-4 {
+ width: min(100%, 920px);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.success-port-result-card {
+ min-height: 168px;
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ display: grid;
+ gap: 12px;
+ align-content: center;
+ text-align: left;
+ box-shadow: 0 10px 22px rgba(21, 55, 58, 0.08);
+}
+
+.success-port-result-title {
+ color: var(--teal-dark);
+ font-size: 22px;
+ font-weight: 900;
+}
+
+.success-port-result-metric {
+ display: grid;
+ grid-template-columns: 86px minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+ min-height: 42px;
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #f6faf8;
+}
+
+.success-port-result-metric span {
+ color: var(--muted);
+ font-size: 15px;
+ font-weight: 800;
+}
+
+.success-port-result-metric strong {
+ color: var(--ink);
+ font-size: 24px;
+}
+
+.login-box {
+ width: min(100%, 460px);
+ margin: 54px auto 0;
+ padding: 22px;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ box-shadow: var(--shadow);
+}
+
+.field {
+ display: grid;
+ gap: 7px;
+ margin-bottom: 14px;
+}
+
+.field label {
+ color: #334346;
+ font-weight: 800;
+ font-size: 13px;
+}
+
+.field input,
+.field select {
+ width: 100%;
+ min-height: 44px;
+ padding: 0 11px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--ink);
+}
+
+.readonly-value {
+ width: 100%;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ padding: 0 11px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #f6f9f7;
+ color: #526467;
+ font-weight: 800;
+}
+
+.pin-panel {
+ display: grid;
+ gap: 8px;
+ margin: 16px 0 14px;
+}
+
+.pin-label {
+ color: #334346;
+ font-weight: 800;
+ font-size: 13px;
+}
+
+.pin-input-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: stretch;
+}
+
+.pin-display {
+ width: 100%;
+ min-height: 58px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #f7faf8;
+ color: var(--ink);
+ font-size: 24px;
+ font-weight: 900;
+ letter-spacing: 0;
+}
+
+.pin-toggle-button {
+ min-width: 96px;
+ min-height: 58px;
+ padding: 0 14px;
+ white-space: nowrap;
+}
+
+.numeric-keypad {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 14px 0 16px;
+}
+
+.numeric-keypad .keypad-key {
+ width: calc((100% - 20px) / 3);
+ flex: 0 0 calc((100% - 20px) / 3);
+}
+
+.keypad-key {
+ min-height: 62px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--ink);
+ font-size: 24px;
+ font-weight: 900;
+}
+
+.keypad-key:active {
+ border-color: var(--teal);
+ background: #e9f4f1;
+}
+
+.keypad-key.utility {
+ font-size: 18px;
+ color: #3e5559;
+ background: #f4f7f6;
+}
+
+.password-panel {
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid #eef2f0;
+}
+
+.compact-keypad {
+ max-width: 420px;
+}
+
+.danger-text {
+ color: var(--red);
+ font-weight: 800;
+}
+
+.settings-screen {
+ background: #f1f5f3;
+}
+
+.settings-layout {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 178px minmax(0, 1fr);
+ min-height: calc(100vh - 74px);
+ min-height: calc(100dvh - 74px);
+}
+
+.settings-nav {
+ padding: 14px;
+ border-right: 1px solid var(--line);
+ background: #fff;
+}
+
+.tab-button {
+ width: 100%;
+ height: 44px;
+ min-height: 44px;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ margin-bottom: 7px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--ink);
+ text-align: left;
+ font-weight: 800;
+ line-height: 1.1;
+ padding: 0 12px;
+ white-space: nowrap;
+}
+
+.tab-button.active {
+ background: var(--deep);
+ color: #fff;
+}
+
+.settings-main {
+ padding: 18px;
+ overflow: auto;
+}
+
+.settings-section {
+ display: grid;
+ gap: 14px;
+}
+
+.panel {
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+}
+
+.panel-title {
+ margin: 0 0 12px;
+ font-size: 18px;
+ letter-spacing: 0;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.port-settings-panel {
+ padding: 12px;
+}
+
+.port-settings-panel .panel-title {
+ margin-bottom: 8px;
+}
+
+.port-setting-buttons {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.port-setting-button {
+ min-height: 96px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--ink);
+ display: grid;
+ align-content: center;
+ gap: 7px;
+ text-align: left;
+ box-shadow: 0 8px 18px rgba(18, 53, 55, 0.07);
+}
+
+.port-setting-button strong {
+ font-size: 22px;
+}
+
+.port-setting-button span {
+ color: var(--muted);
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.port-setting-button:active {
+ border-color: var(--teal);
+ background: #e9f6f2;
+}
+
+.port-setting-button.disabled,
+.port-setting-button:disabled {
+ background: #f2f4f3;
+ color: #98a2a3;
+ box-shadow: none;
+ cursor: default;
+}
+
+.port-setting-button.disabled span,
+.port-setting-button:disabled span {
+ color: #98a2a3;
+}
+
+.port-settings-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 55;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(10, 28, 30, 0.38);
+}
+
+.port-settings-dialog {
+ width: min(92vw, 760px);
+ max-height: min(78vh, 620px);
+ margin: auto;
+ overflow: auto;
+ padding: 18px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: var(--shadow);
+}
+
+.port-settings-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.port-settings-head h3 {
+ margin: 0;
+ font-size: 24px;
+}
+
+.port-settings-actions {
+ justify-content: flex-end;
+ margin-top: 14px;
+}
+
+.port-config-main,
+.port-config-readings {
+ display: grid;
+ gap: 8px;
+ align-items: end;
+}
+
+.port-config-main {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.port-config-main .port-type-field {
+ grid-column: span 2;
+}
+
+.port-config-readings {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin-top: 8px;
+}
+
+.port-config-main .field,
+.port-config-readings .field {
+ gap: 3px;
+ margin-bottom: 0;
+}
+
+.port-config-main .field label,
+.port-config-readings .field label {
+ font-size: 12px;
+}
+
+.port-config-main .field input,
+.port-config-main .field select,
+.port-config-main .readonly-value,
+.port-config-readings .field input,
+.port-config-readings .field select,
+.port-config-readings .readonly-value {
+ min-height: 38px;
+ padding: 0 9px;
+ font-size: 14px;
+}
+
+.port-status-cell {
+ min-height: 38px;
+ padding: 6px 9px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ display: grid;
+ align-content: center;
+ gap: 2px;
+}
+
+.port-status-cell span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.port-status-cell strong {
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.port-toggle-cell {
+ min-width: 0;
+}
+
+.port-toggle-cell .toggle-row {
+ min-height: 38px;
+ padding: 0;
+ border-bottom: 0;
+}
+
+.port-toggle-cell .toggle-row span {
+ font-size: 14px;
+}
+
+.port-toggle-cell .toggle-row small {
+ display: none;
+}
+
+.port-toggle-cell .small {
+ margin: 4px 0 0;
+ font-size: 11px;
+}
+
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 44px;
+ padding: 8px 0;
+ border-bottom: 1px solid #eef2f0;
+}
+
+.toggle-row:last-child {
+ border-bottom: 0;
+}
+
+.toggle-row span {
+ font-weight: 800;
+}
+
+.toggle-row small {
+ display: block;
+ margin-top: 3px;
+ color: var(--muted);
+}
+
+.switch {
+ position: relative;
+ width: 54px;
+ min-width: 54px;
+ height: 30px;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ inset: 0;
+ border-radius: 30px;
+ background: #b9c4c0;
+ transition: 0.18s;
+}
+
+.slider::before {
+ content: "";
+ position: absolute;
+ width: 24px;
+ height: 24px;
+ left: 3px;
+ top: 3px;
+ border-radius: 50%;
+ background: #fff;
+ transition: 0.18s;
+}
+
+.switch input:checked + .slider {
+ background: var(--teal);
+}
+
+.switch input:checked + .slider::before {
+ transform: translateX(24px);
+}
+
+.cache-list {
+ display: grid;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.cache-item {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 12px;
+ padding: 11px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fbfdfc;
+}
+
+.cache-item strong {
+ display: block;
+ margin-bottom: 4px;
+}
+
+.small {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.test-results {
+ display: grid;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.result-line {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 9px 11px;
+ border-radius: 8px;
+ background: #f4f7f6;
+}
+
+.result-line.ok strong {
+ color: var(--green);
+}
+
+.result-line.bad strong {
+ color: var(--red);
+}
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: 24px;
+ z-index: 20;
+ min-width: 220px;
+ max-width: min(92vw, 520px);
+ transform: translate(-50%, 140%);
+ padding: 12px 16px;
+ border-radius: 8px;
+ background: rgba(24, 40, 43, 0.94);
+ color: #fff;
+ text-align: center;
+ font-weight: 800;
+ transition: transform 0.2s ease;
+}
+
+.toast.show {
+ transform: translate(-50%, 0);
+}
+
+@media (max-width: 720px) {
+ .topbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .topbar-actions {
+ justify-content: flex-start;
+ }
+
+ .settings-screen .topbar {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .settings-screen .topbar-actions {
+ margin-left: auto;
+ justify-content: flex-end;
+ }
+
+ .home-main,
+ .content,
+ .flow-body {
+ padding: 14px;
+ }
+
+ .app-log-window {
+ top: 128px;
+ right: 8px;
+ width: calc(100% - 16px);
+ max-height: 150px;
+ padding: 8px;
+ }
+
+ .log-line {
+ grid-template-columns: 52px 40px minmax(0, 1fr);
+ }
+
+ .home-lower {
+ min-height: 420px;
+ padding: 22px 16px 76px;
+ }
+
+ .home-camera-preview {
+ width: calc(100% + 32px);
+ max-width: none;
+ height: 420px;
+ margin-left: -16px;
+ margin-right: -16px;
+ }
+
+ .settings-camera-preview {
+ max-width: 420px;
+ height: 315px;
+ }
+
+ .admin-entry {
+ right: 12px;
+ bottom: 12px;
+ }
+
+ .ports-grid,
+ .form-grid,
+ .work-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .ports-grid.port-count-2,
+ .ports-grid.port-count-4 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .ports-grid.port-count-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .success-port-result-grid.port-count-2,
+ .success-port-result-grid.port-count-3,
+ .success-port-result-grid.port-count-4 {
+ grid-template-columns: 1fr;
+ }
+
+ .port-setting-buttons {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .port-settings-dialog {
+ width: min(94vw, 620px);
+ }
+
+ .port-config-main,
+ .port-config-readings {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .port-config-main .port-type-field {
+ grid-column: span 2;
+ }
+
+ .settings-layout {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: start;
+ }
+
+ .settings-nav {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ align-items: center;
+ align-self: start;
+ gap: 8px;
+ height: auto;
+ min-height: 0;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .tab-button {
+ width: 100%;
+ min-width: 0;
+ margin-bottom: 0;
+ justify-content: center;
+ text-align: center;
+ font-size: 15px;
+ padding: 0 6px;
+ }
+
+ .user-band,
+ .cache-item {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (orientation: portrait) {
+ .app-shell {
+ width: 100vw;
+ }
+
+ .topbar,
+ .settings-screen .topbar {
+ min-height: 68px;
+ padding: 10px 14px;
+ flex-flow: row wrap;
+ align-items: center;
+ }
+
+ .brand-mark {
+ width: 38px;
+ height: 38px;
+ }
+
+ .brand-title {
+ font-size: 19px;
+ }
+
+ .topbar-actions {
+ margin-left: auto;
+ }
+
+ .content,
+ .home-main,
+ .flow-body {
+ padding: 12px;
+ }
+
+ .boot-panel,
+ .panel,
+ .login-box {
+ padding: 16px;
+ }
+
+ .home-lower {
+ min-height: calc(100dvh - 92px);
+ padding: 22px 14px 76px;
+ }
+
+ .prompt-title {
+ font-size: clamp(26px, 7vw, 38px);
+ }
+
+ .prompt-subtitle {
+ margin-top: 10px;
+ font-size: 15px;
+ }
+
+ .home-camera-preview {
+ width: 100%;
+ max-width: 620px;
+ margin: 14px auto 0;
+ }
+
+ .face-recognition-indicator {
+ margin-top: 14px;
+ font-size: 17px;
+ }
+
+ .home-actions {
+ justify-content: center;
+ }
+
+ .home-actions .btn {
+ flex: 1 1 130px;
+ }
+
+ .ports-grid.port-count-3,
+ .ports-grid.port-count-4 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .port-card,
+ .ports-grid.compact .port-card {
+ min-height: 126px;
+ padding: 13px;
+ }
+
+ .port-head {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .delivery-stage {
+ min-height: calc(100dvh - 92px);
+ }
+
+ .delivery-actions .btn {
+ flex: 1 1 135px;
+ }
+
+ .success-port-result-grid.port-count-2,
+ .success-port-result-grid.port-count-3,
+ .success-port-result-grid.port-count-4 {
+ grid-template-columns: 1fr;
+ }
+
+ .settings-layout {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .settings-nav {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ gap: 7px;
+ overflow-x: auto;
+ padding: 9px 10px;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ scrollbar-width: none;
+ }
+
+ .settings-nav::-webkit-scrollbar {
+ display: none;
+ }
+
+ .tab-button {
+ width: auto;
+ min-width: 92px;
+ margin: 0;
+ justify-content: center;
+ padding: 0 12px;
+ }
+
+ .settings-main {
+ padding: 12px;
+ }
+
+ .face-settings-section,
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .settings-camera-preview {
+ width: 100%;
+ max-width: 620px;
+ height: 420px;
+ }
+
+ .face-people-head {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .face-people-table {
+ min-width: 620px;
+ }
+}
diff --git a/app/src/main/cpp/face_engine_jni.cpp b/app/src/main/cpp/face_engine_jni.cpp
new file mode 100644
index 0000000..b60813a
--- /dev/null
+++ b/app/src/main/cpp/face_engine_jni.cpp
@@ -0,0 +1,315 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "seeta/FaceDetector.h"
+#include "seeta/FaceLandmarker.h"
+#include "seeta/FaceRecognizer.h"
+
+namespace {
+
+std::mutex g_mutex;
+std::unique_ptr g_detector;
+std::unique_ptr g_landmarker;
+std::unique_ptr g_recognizer;
+std::string g_last_error;
+
+constexpr double kCenterLeftRatio = 0.25;
+constexpr double kCenterRightRatio = 0.75;
+constexpr double kCenterTopRatio = 0.18;
+constexpr double kCenterBottomRatio = 0.82;
+constexpr double kMaxEyeTiltRatio = 0.18;
+constexpr double kMaxMouthTiltRatio = 0.22;
+constexpr double kMaxNoseOffsetRatio = 0.20;
+constexpr double kMinSymmetryRatio = 0.62;
+constexpr double kMinSharpnessVariance = 45.0;
+
+std::string to_string(JNIEnv *env, jstring value) {
+ if (value == nullptr) return std::string();
+ const char *characters = env->GetStringUTFChars(value, nullptr);
+ if (characters == nullptr) return std::string();
+ std::string result(characters);
+ env->ReleaseStringUTFChars(value, characters);
+ return result;
+}
+
+unsigned char clamp_color(int value) {
+ return static_cast(std::max(0, std::min(255, value)));
+}
+
+void nv21_to_bgr(const unsigned char *source,
+ int width,
+ int height,
+ int rotation,
+ bool mirror,
+ std::vector &output,
+ int &output_width,
+ int &output_height) {
+ rotation = ((rotation % 360) + 360) % 360;
+ if (rotation != 90 && rotation != 180 && rotation != 270) rotation = 0;
+ output_width = (rotation == 90 || rotation == 270) ? height : width;
+ output_height = (rotation == 90 || rotation == 270) ? width : height;
+ output.assign(static_cast(output_width) * output_height * 3, 0);
+ const int frame_size = width * height;
+
+ for (int sy = 0; sy < height; ++sy) {
+ const int uv_row = frame_size + (sy >> 1) * width;
+ for (int sx = 0; sx < width; ++sx) {
+ int y = static_cast(source[sy * width + sx]) & 0xff;
+ int uv_index = uv_row + (sx & ~1);
+ int v = (static_cast(source[uv_index]) & 0xff) - 128;
+ int u = (static_cast(source[uv_index + 1]) & 0xff) - 128;
+ int c = std::max(0, y - 16);
+ int red = (298 * c + 409 * v + 128) >> 8;
+ int green = (298 * c - 100 * u - 208 * v + 128) >> 8;
+ int blue = (298 * c + 516 * u + 128) >> 8;
+
+ int rx = sx;
+ int ry = sy;
+ if (rotation == 90) {
+ rx = height - 1 - sy;
+ ry = sx;
+ } else if (rotation == 180) {
+ rx = width - 1 - sx;
+ ry = height - 1 - sy;
+ } else if (rotation == 270) {
+ rx = sy;
+ ry = width - 1 - sx;
+ }
+ if (mirror) rx = output_width - 1 - rx;
+ size_t index = (static_cast(ry) * output_width + rx) * 3;
+ output[index] = clamp_color(blue);
+ output[index + 1] = clamp_color(green);
+ output[index + 2] = clamp_color(red);
+ }
+ }
+}
+
+bool face_center_is_in_capture_area(const SeetaRect &face, int image_width, int image_height) {
+ const double center_x = face.x + face.width * 0.5;
+ const double center_y = face.y + face.height * 0.5;
+ return center_x >= image_width * kCenterLeftRatio
+ && center_x <= image_width * kCenterRightRatio
+ && center_y >= image_height * kCenterTopRatio
+ && center_y <= image_height * kCenterBottomRatio;
+}
+
+double point_distance(const SeetaPointF &first, const SeetaPointF &second) {
+ const double dx = first.x - second.x;
+ const double dy = first.y - second.y;
+ return std::sqrt(dx * dx + dy * dy);
+}
+
+double symmetry_ratio(double first, double second) {
+ const double largest = std::max(first, second);
+ return largest <= 0.0001 ? 0.0 : std::min(first, second) / largest;
+}
+
+bool landmarks_are_frontal(const std::vector &points) {
+ if (points.size() < 5) return false;
+ const SeetaPointF &left_eye = points[0];
+ const SeetaPointF &right_eye = points[1];
+ const SeetaPointF &nose = points[2];
+ const SeetaPointF &left_mouth = points[3];
+ const SeetaPointF &right_mouth = points[4];
+ const double eye_width = std::fabs(right_eye.x - left_eye.x);
+ if (eye_width < 1.0) return false;
+ if (std::fabs(right_eye.y - left_eye.y) / eye_width > kMaxEyeTiltRatio) return false;
+ const double mouth_width = std::fabs(right_mouth.x - left_mouth.x);
+ if (mouth_width < 1.0
+ || std::fabs(right_mouth.y - left_mouth.y) / mouth_width > kMaxMouthTiltRatio) {
+ return false;
+ }
+ const double eye_center_x = (left_eye.x + right_eye.x) * 0.5;
+ const double eye_center_y = (left_eye.y + right_eye.y) * 0.5;
+ const double mouth_center_x = (left_mouth.x + right_mouth.x) * 0.5;
+ const double mouth_center_y = (left_mouth.y + right_mouth.y) * 0.5;
+ if (std::fabs(nose.x - eye_center_x) / eye_width > kMaxNoseOffsetRatio) return false;
+ if (std::fabs(mouth_center_x - nose.x) / eye_width > kMaxNoseOffsetRatio) return false;
+ if (nose.y <= eye_center_y || mouth_center_y <= nose.y) return false;
+ if (symmetry_ratio(point_distance(nose, left_eye), point_distance(nose, right_eye))
+ < kMinSymmetryRatio) {
+ return false;
+ }
+ return symmetry_ratio(point_distance(nose, left_mouth), point_distance(nose, right_mouth))
+ >= kMinSymmetryRatio;
+}
+
+int gray_at(const unsigned char *bgr, int width, int x, int y) {
+ const size_t index = (static_cast(y) * width + x) * 3;
+ return (29 * bgr[index] + 150 * bgr[index + 1] + 77 * bgr[index + 2]) >> 8;
+}
+
+double face_sharpness_variance(const unsigned char *bgr,
+ int image_width,
+ int image_height,
+ const SeetaRect &face) {
+ const int inset_x = std::max(2, face.width / 10);
+ const int inset_y = std::max(2, face.height / 10);
+ const int left = std::max(1, face.x + inset_x);
+ const int top = std::max(1, face.y + inset_y);
+ const int right = std::min(image_width - 2, face.x + face.width - inset_x);
+ const int bottom = std::min(image_height - 2, face.y + face.height - inset_y);
+ if (right <= left || bottom <= top) return 0.0;
+ double sum = 0.0;
+ double square_sum = 0.0;
+ long count = 0;
+ for (int y = top; y <= bottom; y += 2) {
+ for (int x = left; x <= right; x += 2) {
+ const int center = gray_at(bgr, image_width, x, y);
+ const int laplacian = 4 * center
+ - gray_at(bgr, image_width, x - 1, y)
+ - gray_at(bgr, image_width, x + 1, y)
+ - gray_at(bgr, image_width, x, y - 1)
+ - gray_at(bgr, image_width, x, y + 1);
+ sum += laplacian;
+ square_sum += static_cast(laplacian) * laplacian;
+ ++count;
+ }
+ }
+ if (count <= 0) return 0.0;
+ const double mean = sum / count;
+ return square_sum / count - mean * mean;
+}
+
+} // namespace
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_xingyuan_zhiling_SeetaFaceNative_initialize(
+ JNIEnv *env, jclass, jstring detector_model, jstring landmarker_model, jstring recognizer_model) {
+ std::lock_guard lock(g_mutex);
+ try {
+ g_recognizer.reset();
+ g_landmarker.reset();
+ g_detector.reset();
+ std::string detector_path = to_string(env, detector_model);
+ std::string landmarker_path = to_string(env, landmarker_model);
+ std::string recognizer_path = to_string(env, recognizer_model);
+ if (detector_path.empty() || landmarker_path.empty() || recognizer_path.empty()) {
+ g_last_error = "模型路径不能为空";
+ return JNI_FALSE;
+ }
+ g_detector.reset(new seeta::FaceDetector(seeta::ModelSetting(detector_path, seeta::ModelSetting::CPU)));
+ g_landmarker.reset(new seeta::FaceLandmarker(seeta::ModelSetting(landmarker_path, seeta::ModelSetting::CPU)));
+ g_recognizer.reset(new seeta::FaceRecognizer(seeta::ModelSetting(recognizer_path, seeta::ModelSetting::CPU)));
+ g_detector->set(seeta::FaceDetector::PROPERTY_MIN_FACE_SIZE, 80);
+ g_detector->set(seeta::FaceDetector::PROPERTY_NUMBER_THREADS, 2);
+ g_recognizer->set(seeta::FaceRecognizer::PROPERTY_NUMBER_THREADS, 2);
+ g_last_error.clear();
+ return JNI_TRUE;
+ } catch (const std::exception &error) {
+ g_last_error = error.what();
+ } catch (...) {
+ g_last_error = "SeetaFace6 初始化发生未知错误";
+ }
+ g_recognizer.reset();
+ g_landmarker.reset();
+ g_detector.reset();
+ return JNI_FALSE;
+}
+
+extern "C" JNIEXPORT jfloatArray JNICALL
+Java_com_xingyuan_zhiling_SeetaFaceNative_extractFeature(
+ JNIEnv *env, jclass, jbyteArray nv21, jint width, jint height, jint rotation, jboolean mirror) {
+ if (nv21 == nullptr || width <= 0 || height <= 0) return nullptr;
+ jsize length = env->GetArrayLength(nv21);
+ if (length < width * height * 3 / 2) return nullptr;
+ std::vector source(static_cast(length));
+ env->GetByteArrayRegion(nv21, 0, length, reinterpret_cast(source.data()));
+
+ std::vector bgr;
+ int image_width = 0;
+ int image_height = 0;
+ nv21_to_bgr(source.data(), width, height, rotation, mirror == JNI_TRUE, bgr, image_width, image_height);
+
+ std::lock_guard lock(g_mutex);
+ if (!g_detector || !g_landmarker || !g_recognizer) {
+ g_last_error = "识别引擎未就绪";
+ return nullptr;
+ }
+ try {
+ SeetaImageData image = {image_width, image_height, 3, bgr.data()};
+ SeetaFaceInfoArray faces = g_detector->detect(image);
+ if (faces.size <= 0 || faces.data == nullptr) {
+ g_last_error = "画面中未检测到正面人脸";
+ return nullptr;
+ }
+ int selected = -1;
+ long largest_area = 0;
+ for (int i = 0; i < faces.size; ++i) {
+ if (!face_center_is_in_capture_area(faces.data[i].pos, image_width, image_height)) {
+ continue;
+ }
+ long area = static_cast(faces.data[i].pos.width) * faces.data[i].pos.height;
+ if (area > largest_area) {
+ largest_area = area;
+ selected = i;
+ }
+ }
+ if (selected < 0) {
+ g_last_error = "请将人脸移至取景框中央";
+ return nullptr;
+ }
+ SeetaRect face = faces.data[selected].pos;
+ if (face.width < 80 || face.height < 80) {
+ g_last_error = "人脸距离较远,请靠近摄像头";
+ return nullptr;
+ }
+ int point_count = g_landmarker->number();
+ if (point_count < 5) {
+ g_last_error = "人脸关键点模型不可用";
+ return nullptr;
+ }
+ std::vector points(static_cast(point_count));
+ g_landmarker->mark(image, face, points.data());
+ if (!landmarks_are_frontal(points)) {
+ g_last_error = "请正对摄像头,侧脸不进行识别";
+ return nullptr;
+ }
+ if (face_sharpness_variance(bgr.data(), image_width, image_height, face)
+ < kMinSharpnessVariance) {
+ g_last_error = "画面模糊,请站稳并保持静止";
+ return nullptr;
+ }
+ int feature_size = g_recognizer->GetExtractFeatureSize();
+ if (feature_size <= 0) {
+ g_last_error = "人脸特征模型不可用";
+ return nullptr;
+ }
+ std::vector feature(static_cast(feature_size));
+ if (!g_recognizer->Extract(image, points.data(), feature.data())) {
+ g_last_error = "人脸特征提取失败,请保持正脸和充足光线";
+ return nullptr;
+ }
+ jfloatArray result = env->NewFloatArray(feature_size);
+ if (result == nullptr) return nullptr;
+ env->SetFloatArrayRegion(result, 0, feature_size, feature.data());
+ g_last_error.clear();
+ return result;
+ } catch (const std::exception &error) {
+ g_last_error = error.what();
+ } catch (...) {
+ g_last_error = "SeetaFace6 特征提取发生未知错误";
+ }
+ return nullptr;
+}
+
+extern "C" JNIEXPORT jstring JNICALL
+Java_com_xingyuan_zhiling_SeetaFaceNative_lastError(JNIEnv *env, jclass) {
+ std::lock_guard lock(g_mutex);
+ return env->NewStringUTF(g_last_error.c_str());
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_xingyuan_zhiling_SeetaFaceNative_release(JNIEnv *, jclass) {
+ std::lock_guard lock(g_mutex);
+ g_recognizer.reset();
+ g_landmarker.reset();
+ g_detector.reset();
+ g_last_error.clear();
+}
diff --git a/app/src/main/cpp/include/seeta/CFaceInfo.h b/app/src/main/cpp/include/seeta/CFaceInfo.h
new file mode 100644
index 0000000..3605696
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/CFaceInfo.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include "Common/CStruct.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct SeetaFaceInfo
+{
+ SeetaRect pos;
+ float score;
+};
+
+struct SeetaFaceInfoArray
+{
+ struct SeetaFaceInfo *data;
+ int size;
+};
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/app/src/main/cpp/include/seeta/Common/CStruct.h b/app/src/main/cpp/include/seeta/Common/CStruct.h
new file mode 100644
index 0000000..4c3c8ed
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/Common/CStruct.h
@@ -0,0 +1,97 @@
+#ifndef INC_SEETA_C_STRUCT_H
+#define INC_SEETA_C_STRUCT_H
+
+#ifdef _MSC_VER
+#ifdef SEETA_EXPORTS
+#define SEETA_API __declspec(dllexport)
+#else
+#define SEETA_API __declspec(dllimport)
+#endif
+#else
+#define SEETA_API __attribute__ ((visibility("default")))
+#endif
+
+#define SEETA_C_API extern "C" SEETA_API
+
+#define INCLUDED_SEETA_CSTRUCT
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include
+
+struct SeetaImageData
+{
+ int width;
+ int height;
+ int channels;
+ unsigned char *data;
+};
+
+struct SeetaPoint
+{
+ int x;
+ int y;
+};
+
+struct SeetaPointF
+{
+ double x;
+ double y;
+};
+
+struct SeetaSize
+{
+ int width;
+ int height;
+};
+
+struct SeetaRect
+{
+ int x;
+ int y;
+ int width;
+ int height;
+};
+
+struct SeetaRegion
+{
+ int top;
+ int bottom;
+ int left;
+ int right;
+};
+
+enum SeetaDevice
+{
+ SEETA_DEVICE_AUTO = 0,
+ SEETA_DEVICE_CPU = 1,
+ SEETA_DEVICE_GPU = 2,
+};
+
+struct SeetaModelSetting
+{
+ enum SeetaDevice device;
+ int id; // when device is GPU, id means GPU id
+ const char **model; // model string terminate with nullptr
+};
+
+struct SeetaBuffer
+{
+ void *buffer;
+ int64_t size;
+};
+
+struct SeetaModelBuffer
+{
+ enum SeetaDevice device;
+ int id; // when device is GPU, id means GPU id
+ const SeetaBuffer *buffer; // input buffers, terminate with empty buffer(buffer=nullptr, size=0)
+};
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // INC_SEETA_C_STRUCT_H
diff --git a/app/src/main/cpp/include/seeta/Common/Struct.h b/app/src/main/cpp/include/seeta/Common/Struct.h
new file mode 100644
index 0000000..7d776da
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/Common/Struct.h
@@ -0,0 +1,703 @@
+#ifndef INC_SEETA_STRUCT_H
+#define INC_SEETA_STRUCT_H
+
+#include "CStruct.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define INCLUDED_SEETA_STRUCT
+
+namespace seeta
+{
+ class ImageData : public SeetaImageData
+ {
+ public:
+ using self = ImageData;
+ using supper = SeetaImageData;
+
+ using byte = unsigned char;
+
+ ~ImageData() = default;
+
+ ImageData(const supper &image)
+ : ImageData(image.data, image.width, image.height, image.channels) {}
+
+ ImageData(int width, int height, int channels)
+ : supper({ width, height, channels, nullptr })
+ {
+ this->m_data.reset(new byte[this->count()], std::default_delete());
+ this->data = this->m_data.get();
+ }
+
+ ImageData() : ImageData(0, 0, 0) {}
+
+ ImageData(const byte *data, int width, int height, int channels)
+ : ImageData(width, height, channels)
+ {
+ this->copy_from(data);
+ }
+
+ ImageData(const self&) = default;
+
+ ImageData &operator=(const self &) = default;
+
+ ImageData &operator=(const supper &other)
+ {
+ this->operator=(self(other));
+ return *this;
+ }
+
+ ImageData(self &&other)
+ : supper({ other.width, other.height, other.channels, nullptr })
+ {
+ this->m_data = std::move(other.m_data);
+ this->data = this->m_data.get();
+ }
+
+ ImageData &operator=(self &&other)
+ {
+ this->width = other.width;
+ this->height = other.height;
+ this->channels = other.channels;
+ this->m_data = std::move(other.m_data);
+ this->data = this->m_data.get();
+ return *this;
+ }
+
+ void copy_from(const byte *data, int size = -1)
+ {
+ int copy_size = this->count();
+ copy_size = size < 0 ? copy_size : std::min(copy_size, size);
+ copy(this->data, data, copy_size);
+ }
+
+ void copy_to(byte *data, int size = -1) const
+ {
+ int copy_size = this->count();
+ copy_size = size < 0 ? copy_size : std::min(copy_size, size);
+ copy(data, this->data, copy_size);
+ }
+
+ static void copy(byte *dst, const byte *src, size_t size)
+ {
+ std::memcpy(dst, src, size);
+ }
+
+ int count() const { return this->width * this->height * this->channels; }
+
+ ImageData clone() const
+ {
+ return ImageData(this->data, this->width, this->height, this->channels);
+ }
+
+ private:
+ std::shared_ptr m_data;
+ };
+
+ class Point : public SeetaPoint
+ {
+ public:
+ using self = Point;
+ using supper = SeetaPoint;
+
+ Point(const supper &other) : supper(other) {}
+
+ Point(int x, int y) : supper({ x, y }) {}
+
+ Point() : Point(0, 0) {}
+ };
+
+ class PointF : public SeetaPointF
+ {
+ public:
+ using self = PointF;
+ using supper = SeetaPointF;
+
+ PointF(const supper &other) : supper(other) {}
+
+ PointF(double x, double y) : supper({ x, y }) {}
+
+ PointF() : PointF(0, 0) {}
+ };
+
+ class Size : public SeetaSize
+ {
+ public:
+ using self = Size;
+ using supper = SeetaSize;
+
+ Size(const supper &other) : supper(other) {}
+
+ Size(int width, int height) : supper({ width, height }) {}
+
+ Size() : Size(0, 0) {}
+ };
+
+ class Rect : public SeetaRect
+ {
+ public:
+ using self = Rect;
+ using supper = SeetaRect;
+
+ Rect(const supper &other) : supper(other) {}
+
+ Rect(int x, int y, int width, int height) : supper({ x, y, width, height }) {}
+
+ Rect() : Rect(0, 0, 0, 0) {}
+
+ Rect(int x, int y, const Size &size) : supper({ x, y, size.width, size.height }) {}
+
+ Rect(const Point &top_left, int width, int height) : supper({ top_left.x, top_left.y, width, height }) {}
+
+ Rect(const Point &top_left, const Size &size) : supper({ top_left.x, top_left.y, size.width, size.height }) {}
+
+ Rect(const Point &top_left, const Point &bottom_right) : supper({ top_left.x, top_left.y, bottom_right.x - top_left.x, bottom_right.y - top_left.y }) {}
+
+ operator Point() const { return{ this->x, this->y }; }
+
+ operator Size() const { return{ this->width, this->height }; }
+ };
+
+ class Region : public SeetaRegion
+ {
+ public:
+ using self = Region;
+ using supper = SeetaRegion;
+
+ Region(const supper &other) : supper(other) {}
+
+ Region(int top, int bottom, int left, int right) : supper({ top, bottom, left, right }) {}
+
+ Region() : Region(0, 0, 0, 0) {}
+
+ Region(const Rect &rect) : Region(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width) {}
+
+ operator Rect() const { return{ left, top, right - left, bottom - top }; }
+ };
+
+ class ModelSetting : public SeetaModelSetting
+ {
+ public:
+ using self = ModelSetting;
+ using supper = SeetaModelSetting;
+
+ enum Device
+ {
+ AUTO,
+ CPU,
+ GPU
+ };
+
+ ~ModelSetting() = default;
+
+ ModelSetting()
+ : supper({ SEETA_DEVICE_AUTO, 0, nullptr })
+ {
+ this->update();
+ }
+
+ ModelSetting(const supper &other)
+ : supper({ other.device, other.id, nullptr })
+ {
+ if (other.model) {
+ int i = 0;
+ while (other.model[i])
+ {
+ m_model_string.emplace_back(other.model[i]);
+ ++i;
+ }
+ }
+ this->update();
+ }
+
+ ModelSetting(const self &other)
+ : supper({ other.device, other.id, nullptr })
+ {
+ this->m_model_string = other.m_model_string;
+ this->update();
+ }
+
+ ModelSetting &operator=(const supper &other)
+ {
+ this->operator=(self(other));
+ return *this;
+ }
+
+ ModelSetting &operator=(const self &other)
+ {
+ this->device = other.device;
+ this->id = other.id;
+ this->m_model_string = other.m_model_string;
+ this->update();
+ return *this;
+ }
+
+ ModelSetting(self &&other)
+ : supper({ other.device, other.id, nullptr })
+ {
+ this->m_model_string = std::move(other.m_model_string);
+ this->update();
+ }
+
+ ModelSetting &operator=(self &&other)
+ {
+ this->device = other.device;
+ this->id = other.id;
+ this->m_model_string = std::move(other.m_model_string);
+ this->update();
+ return *this;
+ }
+
+ ModelSetting(const std::string &model, SeetaDevice device, int id)
+ : supper({ device, id, nullptr })
+ {
+ this->append(model);
+ }
+
+ ModelSetting(const std::string &model, SeetaDevice device) : self(model, device, 0) {}
+
+ ModelSetting(const std::string &model, Device device, int id) : self(model, SeetaDevice(device), id) {}
+
+ ModelSetting(const std::string &model, Device device) : self(model, SeetaDevice(device)) {}
+
+ ModelSetting(const std::string &model) : self(model, SEETA_DEVICE_AUTO) {}
+
+ ModelSetting(const std::vector &model, SeetaDevice device, int id)
+ : supper({ device, id, nullptr })
+ {
+ this->append(model);
+ }
+
+ ModelSetting(const std::vector &model, SeetaDevice device) : self(model, device, 0) {}
+
+ ModelSetting(const std::vector &model, Device device, int id) : self(model, SeetaDevice(device), id) {}
+
+ ModelSetting(const std::vector &model, Device device) : self(model, SeetaDevice(device)) {}
+
+ ModelSetting(const std::vector &model) : self(model, SEETA_DEVICE_AUTO) {}
+
+ ModelSetting(SeetaDevice device, int id)
+ : supper({ device, id, nullptr })
+ {
+ this->update();
+ }
+
+ ModelSetting(SeetaDevice device) : self(device, 0) {}
+
+ ModelSetting(Device device, int id) : self(SeetaDevice(device), id) {}
+
+ ModelSetting(Device device) : self(SeetaDevice(device)) {}
+
+ Device get_device() const { return Device(this->device); }
+ int get_id() const { return this->id; }
+
+ Device set_device(Device device)
+ {
+ return set_device(SeetaDevice(device));
+ }
+
+ Device set_device(SeetaDevice device)
+ {
+ auto old = this->device;
+ this->device = device;
+ return Device(old);
+ }
+
+ int set_id(int id)
+ {
+ const auto old = this->id;
+ this->id = id;
+ return old;
+ }
+
+ void clear()
+ {
+ this->m_model_string.clear();
+ this->update();
+ }
+
+ void append(const std::string &model)
+ {
+ this->m_model_string.push_back(model);
+ this->update();
+ }
+
+ void append(const std::vector &model)
+ {
+ this->m_model_string.insert(this->m_model_string.end(), model.begin(), model.end());
+ this->update();
+ }
+
+ const std::vector &get_model() const
+ {
+ return this->m_model_string;
+ }
+
+ const std::string &get_model(size_t i) const
+ {
+ return this->m_model_string[i];
+ }
+
+ size_t count() const
+ {
+ return this->m_model_string.size();
+ }
+
+ private:
+ std::vector m_model;
+ std::vector m_model_string;
+
+ /**
+ * \brief build buffer::model
+ */
+ void update()
+ {
+ m_model.clear();
+ m_model.reserve(m_model_string.size() + 1);
+ for (auto &model_string : m_model_string)
+ {
+ m_model.push_back(model_string.c_str());
+ }
+ m_model.push_back(nullptr);
+ this->model = m_model.data();
+ }
+ };
+
+ class Buffer : public SeetaBuffer
+ {
+ public:
+ using self = Buffer;
+ using supper = SeetaBuffer;
+
+ using byte = unsigned char;
+
+ ~Buffer() = default;
+
+ Buffer(const supper &other) : Buffer(other.buffer, other.size) {}
+
+ Buffer(const self &) = default;
+
+ Buffer &operator=(const self &) = default;
+
+ Buffer &operator=(const supper &other)
+ {
+ this->operator=(self(other));
+ return *this;
+ }
+
+ Buffer(self &&other)
+ : supper({ other.buffer, other.size }), m_buffer(std::move(other.m_buffer)), m_size(other.m_size) {}
+
+ Buffer &operator=(self &&other)
+ {
+ this->buffer = other.buffer;
+ this->size = other.size;
+ this->m_buffer = std::move(other.m_buffer);
+ this->m_size = other.m_size;
+ return *this;
+ }
+
+ /**
+ * \brief contruct cpp-style-buffer from c-style-buffer, optional borrow or copy
+ * \param other c-style-buffer
+ * \param borrow if borrow or copy buffer
+ */
+ Buffer(const supper &other, bool borrow)
+ : supper({ nullptr, 0 })
+ {
+ if (borrow) this->borrow(other.buffer, other.size);
+ else this->rebind(other.buffer, other.size);
+ }
+
+ Buffer(const void *buffer, int64_t size)
+ : supper({ nullptr, 0 })
+ {
+ this->m_size = size;
+ if (this->m_size)
+ {
+ this->m_buffer.reset(new byte[size_t(this->m_size)], std::default_delete());
+ }
+ this->buffer = this->m_buffer.get();
+ this->size = this->m_size;
+ if (buffer != nullptr)
+ {
+ this->copy_from(buffer, size);
+ }
+ }
+
+ explicit Buffer(int64_t size) : Buffer(nullptr, size) {}
+
+ Buffer() : Buffer(nullptr, 0) {}
+
+ Buffer(std::istream &in, int64_t size = -1)
+ : supper({ nullptr, 0 })
+ {
+ if (size < 0)
+ {
+ const auto cur = in.tellg();
+ in.seekg(0, std::ios::end);
+ const auto end = in.tellg();
+ size = int64_t(end - cur);
+ in.seekg(-size, std::ios::end);
+ }
+
+ this->m_size = size;
+ this->m_buffer.reset(new byte[size_t(this->m_size)], std::default_delete());
+ this->buffer = this->m_buffer.get();
+ this->size = this->m_size;
+
+ in.read(reinterpret_cast(this->buffer), this->size);
+ }
+
+ void copy_from(const void *data, int64_t size = -1)
+ {
+ auto copy_size = this->m_size;
+ copy_size = size < 0 ? copy_size : std::min(copy_size, size);
+ copy(this->buffer, data, size_t(copy_size));
+ }
+
+ void copy_to(byte *data, int64_t size = -1) const
+ {
+ auto copy_size = this->m_size;
+ copy_size = size < 0 ? copy_size : std::min(copy_size, size);
+ copy(data, this->buffer, size_t(copy_size));
+ }
+
+ static void copy(void *dst, const void *src, size_t size)
+ {
+ if (dst == nullptr || src == nullptr) return;
+ std::memcpy(dst, src, size);
+ }
+
+ Buffer clone() const
+ {
+ return self(this->buffer, this->size);
+ }
+
+ void rebind(const void *buffer, int64_t size)
+ {
+ if (size < 0) size = 0;
+ if (size > this->m_size)
+ {
+ this->m_buffer.reset(new byte[size_t(size)], std::default_delete());
+ }
+ this->m_size = size;
+ this->buffer = this->m_buffer.get();
+ this->size = this->m_size;
+ this->copy_from(buffer, size);
+ }
+
+ /**
+ * \brief borrow c-style buffer
+ * \param buffer pointert to buffer
+ * \param size size of buffer
+ */
+ void borrow(void *buffer, int64_t size)
+ {
+ this->m_size = 0;
+ this->m_buffer.reset();
+ this->buffer = buffer;
+ this->size = size;
+ }
+
+ private:
+ std::shared_ptr m_buffer;
+ int64_t m_size = 0;
+ };
+
+ class ModelBuffer : SeetaModelBuffer
+ {
+ public:
+ using self = ModelBuffer;
+ using supper = SeetaModelBuffer;
+
+ enum Device
+ {
+ AUTO = SEETA_DEVICE_AUTO,
+ CPU = SEETA_DEVICE_CPU,
+ GPU = SEETA_DEVICE_GPU,
+ };
+
+ ~ModelBuffer() = default;
+
+ ModelBuffer()
+ : supper({ SEETA_DEVICE_AUTO, 0, nullptr })
+ {
+ this->update();
+ }
+
+ ModelBuffer(const supper &other)
+ : supper({ other.device, other.id, nullptr })
+ {
+ if (other.buffer) {
+ int i = 0;
+ while (other.buffer[i].buffer && other.buffer[i].size)
+ {
+ m_model_buffer.emplace_back(other.buffer[i]);
+ ++i;
+ }
+ }
+ this->update();
+ }
+
+ ModelBuffer(const self &other)
+ : supper({ other.device, other.id, nullptr })
+ {
+ this->m_model_buffer = other.m_model_buffer;
+ this->update();
+ }
+
+ ModelBuffer &operator=(const supper &other)
+ {
+ this->operator=(self(other));
+ return *this;
+ }
+
+ ModelBuffer &operator=(const self &other)
+ {
+ this->device = other.device;
+ this->id = other.id;
+ this->m_model_buffer = other.m_model_buffer;
+ this->update();
+ return *this;
+ }
+
+ ModelBuffer(self &&other)
+ : supper({ other.device, other.id, nullptr })
+ {
+ this->m_model_buffer = std::move(other.m_model_buffer);
+ this->update();
+ }
+
+ ModelBuffer &operator=(self &&other)
+ {
+ this->device = other.device;
+ this->id = other.id;
+ this->m_model_buffer = std::move(other.m_model_buffer);
+ this->update();
+ return *this;
+ }
+
+
+ ModelBuffer(const seeta::Buffer &buffer, SeetaDevice device, int id)
+ : supper({ device, id, nullptr })
+ {
+ this->append(buffer);
+ }
+
+ ModelBuffer(const seeta::Buffer &buffer, SeetaDevice device) : self(buffer, device, 0) {}
+
+ ModelBuffer(const seeta::Buffer &buffer, Device device, int id) : self(buffer, SeetaDevice(device), id) {}
+
+ ModelBuffer(const seeta::Buffer &buffer, Device device) : self(buffer, SeetaDevice(device)) {}
+
+ ModelBuffer(const seeta::Buffer &buffer) : self(buffer, SEETA_DEVICE_AUTO) {}
+
+ ModelBuffer(const std::vector &buffer, SeetaDevice device, int id)
+ : supper({ device, id, nullptr })
+ {
+ this->append(buffer);
+ }
+
+ ModelBuffer(const std::vector &buffer, SeetaDevice device) : self(buffer, device, 0) {}
+
+ ModelBuffer(const std::vector &buffer, Device device, int id) : self(buffer, SeetaDevice(device), id) {}
+
+ ModelBuffer(const std::vector &buffer, Device device) : self(buffer, SeetaDevice(device)) {}
+
+ ModelBuffer(const std::vector &buffer) : self(buffer, SEETA_DEVICE_AUTO) {}
+
+ ModelBuffer(SeetaDevice device, int id)
+ : supper({ device, id, nullptr })
+ {
+ this->update();
+ }
+
+ ModelBuffer(SeetaDevice device) : self(device, 0) {}
+
+ ModelBuffer(Device device, int id) : self(SeetaDevice(device), id) {}
+
+ ModelBuffer(Device device) : self(SeetaDevice(device)) {}
+
+ Device get_device() const { return Device(this->device); }
+ int get_id() const { return this->id; }
+
+ Device set_device(Device device)
+ {
+ return set_device(SeetaDevice(device));
+ }
+
+ Device set_device(SeetaDevice device)
+ {
+ auto old = this->device;
+ this->device = device;
+ return Device(old);
+ }
+
+ int set_id(int id)
+ {
+ const auto old = this->id;
+ this->id = id;
+ return old;
+ }
+
+ void clear()
+ {
+ this->m_model_buffer.clear();
+ this->update();
+ }
+
+ void append(const seeta::Buffer &buffer)
+ {
+ this->m_model_buffer.push_back(buffer);
+ this->update();
+ }
+
+ void append(const std::vector &model)
+ {
+ this->m_model_buffer.insert(this->m_model_buffer.end(), model.begin(), model.end());
+ this->update();
+ }
+
+ const std::vector &get_buffer() const
+ {
+ return this->m_model_buffer;
+ }
+
+ const seeta::Buffer &get_buffer(size_t i) const
+ {
+ return this->m_model_buffer[i];
+ }
+
+ size_t count() const
+ {
+ return this->m_model_buffer.size();
+ }
+
+ private:
+ std::vector m_buffer;
+ std::vector m_model_buffer;
+
+ /**
+ * \brief build supper::buffer
+ */
+ void update()
+ {
+ this->m_buffer.clear();
+ this->m_buffer.reserve(m_model_buffer.size() + 1);
+ for (auto &model_buffer : m_model_buffer)
+ {
+ this->m_buffer.push_back(model_buffer);
+ }
+ this->m_buffer.push_back({ nullptr, 0 }); // terminate with empty buffer
+ this->buffer = this->m_buffer.data();
+ }
+ };
+}
+
+#endif
diff --git a/app/src/main/cpp/include/seeta/FaceDetector.h b/app/src/main/cpp/include/seeta/FaceDetector.h
new file mode 100644
index 0000000..d5d65e0
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/FaceDetector.h
@@ -0,0 +1,54 @@
+//
+// Created by kier on 19-4-24.
+//
+
+#ifndef INC_SEETA_FACEDETECTOR_H
+#define INC_SEETA_FACEDETECTOR_H
+
+#include "seeta/Common/Struct.h"
+#include "seeta/CFaceInfo.h"
+#include "seeta/SeetaFaceDetectorConfig.h"
+
+namespace seeta {
+ namespace SEETA_FACE_DETECTOR_NAMESPACE_VERSION {
+ class FaceDetector {
+ public:
+ using self = FaceDetector;
+
+ enum Property {
+ PROPERTY_MIN_FACE_SIZE,
+ PROPERTY_THRESHOLD,
+ PROPERTY_MAX_IMAGE_WIDTH,
+ PROPERTY_MAX_IMAGE_HEIGHT,
+ PROPERTY_NUMBER_THREADS,
+
+ PROPERTY_ARM_CPU_MODE = 0x101,
+ };
+
+ SEETA_API explicit FaceDetector(const SeetaModelSetting &setting);
+
+ SEETA_API ~FaceDetector();
+
+ SEETA_API explicit FaceDetector(const self *other);
+
+ SEETA_API SeetaFaceInfoArray detect(const SeetaImageData &image) const;
+
+ SEETA_API void set(Property property, double value);
+
+ SEETA_API double get(Property property) const;
+
+ private:
+ FaceDetector(const FaceDetector &) = delete;
+
+ const FaceDetector &operator=(const FaceDetector &) = delete;
+
+ private:
+ class Implement;
+
+ Implement *m_impl;
+ };
+ }
+ using namespace SEETA_FACE_DETECTOR_NAMESPACE_VERSION;
+}
+
+#endif //INC_SEETA_FACEDETECTOR_H
diff --git a/app/src/main/cpp/include/seeta/FaceLandmarker.h b/app/src/main/cpp/include/seeta/FaceLandmarker.h
new file mode 100644
index 0000000..437975a
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/FaceLandmarker.h
@@ -0,0 +1,64 @@
+//
+// Created by kier on 19-7-16.
+//
+
+#ifndef SEETA_FACELANDMARKER_FACELANDMARKER_H
+#define SEETA_FACELANDMARKER_FACELANDMARKER_H
+
+#include "Common/Struct.h"
+#include "seeta/SeetaFaceLandmarkerConfig.h"
+
+namespace seeta {
+ namespace SEETA_FACE_LANDMARKER_NAMESPACE_VERSION {
+ class FaceLandmarker {
+ public:
+ using self = FaceLandmarker;
+
+ SEETA_API explicit FaceLandmarker(const SeetaModelSetting &setting);
+ SEETA_API ~FaceLandmarker();
+
+ SEETA_API FaceLandmarker(const self *other);
+
+ SEETA_API int number() const;
+
+ SEETA_API void mark(const SeetaImageData &image, const SeetaRect &face, SeetaPointF *points) const;
+
+ SEETA_API void mark(const SeetaImageData &image, const SeetaRect &face, SeetaPointF *points, int32_t *mask) const;
+
+ std::vector mark(const SeetaImageData &image, const SeetaRect &face) const {
+ std::vector points(this->number());
+ mark(image, face, points.data());
+ return points;
+ }
+
+ class PointWithMask {
+ public:
+ SeetaPointF point;
+ bool mask;
+ };
+
+ std::vector mark_v2(const SeetaImageData &image, const SeetaRect &face) const {
+ std::vector points(this->number());
+ std::vector masks(this->number());
+ mark(image, face, points.data(), masks.data());
+ std::vector point_with_masks(this->number());
+ for (int i = 0; i < this->number(); ++i) {
+ point_with_masks[i].point = points[i];
+ point_with_masks[i].mask = masks[i];
+ }
+ return point_with_masks;
+ }
+
+ private:
+ FaceLandmarker(const FaceLandmarker &) = delete;
+ const FaceLandmarker &operator=(const FaceLandmarker&) = delete;
+
+ private:
+ class Implement;
+ Implement *m_impl;
+ };
+ }
+ using namespace SEETA_FACE_LANDMARKER_NAMESPACE_VERSION;
+}
+
+#endif //SEETA_FACELANDMARKER_FACELANDMARKER_H
diff --git a/app/src/main/cpp/include/seeta/FaceRecognizer.h b/app/src/main/cpp/include/seeta/FaceRecognizer.h
new file mode 100644
index 0000000..6afc310
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/FaceRecognizer.h
@@ -0,0 +1,81 @@
+//
+// Created by kier on 19-4-24.
+//
+
+#ifndef SEETA_FACERECOGNIZER_FACERECOGNIZER_H
+#define SEETA_FACERECOGNIZER_FACERECOGNIZER_H
+
+#include "Common/Struct.h"
+#include "seeta/SeetaFaceRecognizerConfig.h"
+
+// #define SEETA_FACE_RECOGNIZER_MAJOR_VERSION 6
+// #define SEETA_FACE_RECOGNIZER_MINOR_VERSION 0
+// #define SEETA_FACE_RECOGNIZER_SINOR_VERSION 0
+
+namespace seeta {
+ namespace SEETA_FACE_RECOGNIZE_NAMESPACE_VERSION {
+ class FaceRecognizer {
+ public:
+ using self = FaceRecognizer;
+ enum Property {
+ PROPERTY_NUMBER_THREADS = 4,
+ PROPERTY_ARM_CPU_MODE = 5
+ };
+
+ SEETA_API explicit FaceRecognizer(const SeetaModelSetting &setting);
+ SEETA_API ~FaceRecognizer();
+
+ SEETA_API FaceRecognizer(const self *other);
+
+ SEETA_API static int GetCropFaceWidth();
+ SEETA_API static int GetCropFaceHeight();
+ SEETA_API static int GetCropFaceChannels();
+
+ SEETA_API static bool CropFace(const SeetaImageData &image, const SeetaPointF *points, SeetaImageData &face);
+
+ SEETA_API int GetExtractFeatureSize() const;
+
+ SEETA_API bool ExtractCroppedFace(const SeetaImageData &image, float *features) const;
+
+ SEETA_API bool Extract(const SeetaImageData &image, const SeetaPointF *points, float *features) const;
+
+ SEETA_API float CalculateSimilarity(const float *features1, const float *features2) const;
+
+ static seeta::ImageData CropFace(const SeetaImageData &image, const SeetaPointF *points) {
+ seeta::ImageData face(GetCropFaceWidth(), GetCropFaceHeight(), GetCropFaceChannels());
+ CropFace(image, points, face);
+ return face;
+ }
+
+ SEETA_API int GetCropFaceWidthV2() const;
+
+ SEETA_API int GetCropFaceHeightV2() const;
+
+ SEETA_API int GetCropFaceChannelsV2() const;
+
+ SEETA_API bool CropFaceV2(const SeetaImageData &image, const SeetaPointF *points, SeetaImageData &face);
+
+ seeta::ImageData CropFaceV2(const SeetaImageData &image, const SeetaPointF *points) {
+ seeta::ImageData face(GetCropFaceWidthV2(), GetCropFaceHeightV2(), GetCropFaceChannelsV2());
+ CropFaceV2(image, points, face);
+ return face;
+ }
+
+ SEETA_API void set(Property property, double value);
+
+ SEETA_API double get(Property property) const;
+
+
+ private:
+ FaceRecognizer(const FaceRecognizer &) = delete;
+ const FaceRecognizer &operator=(const FaceRecognizer&) = delete;
+
+ private:
+ class Implement;
+ Implement *m_impl;
+ };
+ }
+ using namespace SEETA_FACE_RECOGNIZE_NAMESPACE_VERSION;
+}
+
+#endif //SEETA_FACERECOGNIZER_FACERECOGNIZER_H
diff --git a/app/src/main/cpp/include/seeta/SeetaFaceDetectorConfig.h b/app/src/main/cpp/include/seeta/SeetaFaceDetectorConfig.h
new file mode 100644
index 0000000..5f75dad
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/SeetaFaceDetectorConfig.h
@@ -0,0 +1,12 @@
+#ifndef _INC_SEETA_FACE_DETECTOR_V6_
+#define _INC_SEETA_FACE_DETECTOR_V6_
+
+#define SEETA_FACE_DETECTOR_MAJOR_VERSION 6
+#define SEETA_FACE_DETECTOR_MINOR_VERSION 0
+#define SEETA_FACE_DETECTOR_SINOR_VERSION 0
+
+#define SEETA_FACE_DETECTOR_LIBRARY_NAME "SeetaFaceDetector"
+
+#define SEETA_FACE_DETECTOR_NAMESPACE_VERSION v6
+
+#endif // _INC_SEETA_FACE_DETECTOR_V6_
diff --git a/app/src/main/cpp/include/seeta/SeetaFaceLandmarkerConfig.h b/app/src/main/cpp/include/seeta/SeetaFaceLandmarkerConfig.h
new file mode 100644
index 0000000..9c19c3d
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/SeetaFaceLandmarkerConfig.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#define SEETA_FACE_LANDMARKER_MAJOR_VERSION 6
+#define SEETA_FACE_LANDMARKER_MINOR_VERSION 0
+#define SEETA_FACE_LANDMARKER_SINOR_VERSION 0
+
+#define SEETA_FACE_LANDMARKER_LIBRARY_NAME "SeetaFaceLandmarker"
+
+#define SEETA_FACE_LANDMARKER_NAMESPACE_VERSION v6
diff --git a/app/src/main/cpp/include/seeta/SeetaFaceRecognizerConfig.h b/app/src/main/cpp/include/seeta/SeetaFaceRecognizerConfig.h
new file mode 100644
index 0000000..981436d
--- /dev/null
+++ b/app/src/main/cpp/include/seeta/SeetaFaceRecognizerConfig.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#define SEETA_FACE_RECOGNIZER_MAJOR_VERSION 6
+#define SEETA_FACE_RECOGNIZER_MINOR_VERSION 1
+#define SEETA_FACE_RECOGNIZER_SINOR_VERSION 0
+
+#define SEETA_FACE_RECOGNIZE_LIBRARY_NAME "SeetaFaceRecognizer"
+
+#define SEETA_FACE_RECOGNIZE_NAMESPACE_VERSION v6
diff --git a/app/src/main/java/android_serialport_api/SerialPort.java b/app/src/main/java/android_serialport_api/SerialPort.java
new file mode 100644
index 0000000..1658188
--- /dev/null
+++ b/app/src/main/java/android_serialport_api/SerialPort.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2009 Cedric Priscal
+ *
+ * Licensed under the Apache License, Version 2.0.
+ */
+
+package android_serialport_api;
+
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class SerialPort {
+ private static final String TAG = "SerialPort";
+
+ /*
+ * Do not remove or rename this field: it is used by native close().
+ */
+ private FileDescriptor mFd;
+ private FileInputStream mFileInputStream;
+ private FileOutputStream mFileOutputStream;
+
+ public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException {
+ if (!device.canRead() || !device.canWrite()) {
+ try {
+ Process su = Runtime.getRuntime().exec("/system/bin/su");
+ String cmd = "chmod 666 " + device.getAbsolutePath() + "\nexit\n";
+ su.getOutputStream().write(cmd.getBytes());
+ if ((su.waitFor() != 0) || !device.canRead() || !device.canWrite()) {
+ throw new SecurityException();
+ }
+ } catch (Exception error) {
+ throw new SecurityException(error);
+ }
+ }
+
+ mFd = open(device.getAbsolutePath(), baudrate, flags);
+ if (mFd == null) {
+ Log.e(TAG, "native open returns null");
+ throw new IOException("native open returns null");
+ }
+ mFileInputStream = new FileInputStream(mFd);
+ mFileOutputStream = new FileOutputStream(mFd);
+ }
+
+ public InputStream getInputStream() {
+ return mFileInputStream;
+ }
+
+ public OutputStream getOutputStream() {
+ return mFileOutputStream;
+ }
+
+ private native static FileDescriptor open(String path, int baudrate, int flags);
+
+ public native void close();
+
+ static {
+ System.loadLibrary("serial_port");
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/BootReceiver.java b/app/src/main/java/com/zhitou/terminal/BootReceiver.java
new file mode 100644
index 0000000..f5811b2
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/BootReceiver.java
@@ -0,0 +1,51 @@
+package com.xingyuan.zhiling;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/**
+ * 开机自启动接收器。
+ *
+ * 监听 android.intent.action.BOOT_COMPLETED 广播,
+ * 设备开机完成后自动拉起 MainActivity。
+ *
+ * 注意:该广播只有在以下条件同时满足时才会被接收:
+ * 1. 应用已安装(非刚安装后首次,需经历过一次重启或手动启动过 app);
+ * 2. 拥有 RECEIVE_BOOT_COMPLETED 权限。
+ *
+ * 对于嵌入式设备,建议同时将 MainActivity 设为默认桌面(HOME),
+ * 这样即使 BootReceiver 因系统策略延迟,也能在用户按 Home 键时回到 app。
+ */
+public class BootReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) {
+ return;
+ }
+
+ String action = intent.getAction();
+ if (action == null) {
+ return;
+ }
+
+ // 开机完成
+ if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
+ launchMainActivity(context);
+ return;
+ }
+
+ // 部分定制系统在替换系统包或更新后会发送这些广播
+ if ("android.intent.action.QUICKBOOT_POWERON".equals(action)
+ || "com.android.intent.action.QUICKBOOT_POWERON".equals(action)) {
+ launchMainActivity(context);
+ }
+ }
+
+ private void launchMainActivity(Context context) {
+ Intent launchIntent = new Intent(context, MainActivity.class);
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(launchIntent);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/CrashRecovery.java b/app/src/main/java/com/zhitou/terminal/CrashRecovery.java
new file mode 100644
index 0000000..b3c5c26
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/CrashRecovery.java
@@ -0,0 +1,202 @@
+package com.xingyuan.zhiling;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Build;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+final class CrashRecovery {
+ private static final String PREFS = "xingyuan_crash_recovery";
+ private static final String KEY_LAST_CRASH_AT = "last_crash_at";
+ private static final String KEY_CRASH_COUNT = "crash_count";
+ private static final String KEY_NOTICE = "notice";
+ private static final String KEY_STARTUP_CRASH = "startup_crash";
+ private static final String KEY_RETRY_GRANTED = "retry_granted";
+ private static final long CRASH_WINDOW_MS = 60_000L;
+ private static final int MAX_RESTARTS_IN_WINDOW = 3;
+ private static final long RESTART_DELAY_MS = 1_500L;
+
+ private CrashRecovery() {
+ }
+
+ static void recordAndSchedule(Context context, Throwable error) {
+ Context app = context.getApplicationContext();
+ SharedPreferences preferences = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ long now = System.currentTimeMillis();
+ long lastCrashAt = preferences.getLong(KEY_LAST_CRASH_AT, 0L);
+ int count = now - lastCrashAt <= CRASH_WINDOW_MS
+ ? preferences.getInt(KEY_CRASH_COUNT, 0) + 1
+ : 1;
+ String summary = summarize(error);
+ preferences.edit()
+ .putLong(KEY_LAST_CRASH_AT, now)
+ .putInt(KEY_CRASH_COUNT, count)
+ .putString(KEY_NOTICE, summary)
+ .putBoolean(KEY_STARTUP_CRASH, isStartupCrash(error))
+ .putBoolean(KEY_RETRY_GRANTED, false)
+ .commit();
+ writeCrashLog(app, now, error);
+ if (count <= MAX_RESTARTS_IN_WINDOW) {
+ scheduleRestart(app);
+ } else {
+ preferences.edit().putString(KEY_NOTICE,
+ summary + ";一分钟内已连续异常 " + count + " 次,已停止自动重启以避免循环崩溃").commit();
+ }
+ }
+
+ static void recordRendererCrashAndRestart(Context context, boolean didCrash) {
+ RuntimeException error = new RuntimeException(
+ didCrash ? "WebView 渲染进程崩溃" : "WebView 渲染进程被系统回收");
+ recordAndSchedule(context, error);
+ }
+
+ static String consumeRecoveryNotice(Context context) {
+ SharedPreferences preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ String notice = preferences.getString(KEY_NOTICE, "");
+ if (notice == null || notice.length() == 0) {
+ return "";
+ }
+ preferences.edit().remove(KEY_NOTICE).apply();
+ return "应用已从异常退出中自动恢复:" + notice;
+ }
+
+ static boolean shouldEnterStartupRecovery(Context context) {
+ SharedPreferences preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ if (preferences.getBoolean(KEY_RETRY_GRANTED, false)) {
+ preferences.edit().putBoolean(KEY_RETRY_GRANTED, false).commit();
+ return false;
+ }
+ boolean startupCrash = preferences.getBoolean(KEY_STARTUP_CRASH, false);
+ long lastCrashAt = preferences.getLong(KEY_LAST_CRASH_AT, 0L);
+ int count = preferences.getInt(KEY_CRASH_COUNT, 0);
+ boolean repeatedRecentCrash = count >= 2
+ && System.currentTimeMillis() - lastCrashAt <= CRASH_WINDOW_MS;
+ return startupCrash || repeatedRecentCrash;
+ }
+
+ static boolean canAutoRetryStartup(Context context) {
+ return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getInt(KEY_CRASH_COUNT, 0) <= MAX_RESTARTS_IN_WINDOW;
+ }
+
+ static String peekRecoveryNotice(Context context) {
+ String notice = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(KEY_NOTICE, "");
+ return notice == null || notice.length() == 0 ? "未知异常" : notice;
+ }
+
+ static void clearStartupRecoveryForRetry(Context context) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .putBoolean(KEY_STARTUP_CRASH, false)
+ .putBoolean(KEY_RETRY_GRANTED, true)
+ .commit();
+ }
+
+ static void markLaunchSuccessful(Context context) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .putLong(KEY_LAST_CRASH_AT, 0L)
+ .putInt(KEY_CRASH_COUNT, 0)
+ .putBoolean(KEY_STARTUP_CRASH, false)
+ .putBoolean(KEY_RETRY_GRANTED, false)
+ .apply();
+ }
+
+ private static void scheduleRestart(Context context) {
+ Intent intent = new Intent(context, MainActivity.class);
+ intent.setAction("com.xingyuan.zhiling.action.CRASH_RECOVERY");
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ int flags = PendingIntent.FLAG_CANCEL_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ flags |= PendingIntent.FLAG_IMMUTABLE;
+ }
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 7311, intent, flags);
+ AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ if (alarm == null) {
+ return;
+ }
+ long triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ alarm.setExact(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent);
+ } else {
+ alarm.set(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent);
+ }
+ }
+
+ private static String summarize(Throwable error) {
+ if (error == null) {
+ return "未知异常";
+ }
+ String type = error.getClass().getSimpleName();
+ String message = error.getMessage();
+ if (message == null || message.trim().length() == 0) {
+ return type;
+ }
+ String normalized = message.replace('\n', ' ').replace('\r', ' ').trim();
+ if (normalized.length() > 180) {
+ normalized = normalized.substring(0, 180);
+ }
+ return type + " - " + normalized;
+ }
+
+ private static boolean isStartupCrash(Throwable error) {
+ Throwable current = error;
+ while (current != null) {
+ StackTraceElement[] stack = current.getStackTrace();
+ if (stack != null) {
+ for (StackTraceElement element : stack) {
+ if (MainActivity.class.getName().equals(element.getClassName())
+ && "onCreate".equals(element.getMethodName())) {
+ return true;
+ }
+ }
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
+ private static void writeCrashLog(Context context, long timestamp, Throwable error) {
+ File directory = new File(context.getFilesDir(), "crash_logs");
+ if (!directory.exists() && !directory.mkdirs()) {
+ return;
+ }
+ File target = new File(directory, "last-crash.log");
+ FileWriter writer = null;
+ try {
+ writer = new FileWriter(target, false);
+ writer.write(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.CHINA)
+ .format(new Date(timestamp)));
+ writer.write('\n');
+ StringWriter stack = new StringWriter();
+ PrintWriter printer = new PrintWriter(stack);
+ if (error != null) {
+ error.printStackTrace(printer);
+ }
+ printer.flush();
+ writer.write(stack.toString());
+ writer.flush();
+ } catch (Throwable ignored) {
+ } finally {
+ if (writer != null) {
+ try {
+ writer.close();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/FaceFeatureStore.java b/app/src/main/java/com/zhitou/terminal/FaceFeatureStore.java
new file mode 100644
index 0000000..9007a7f
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/FaceFeatureStore.java
@@ -0,0 +1,256 @@
+package com.xingyuan.zhiling;
+
+import android.content.Context;
+import android.util.AtomicFile;
+import android.util.Base64;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+final class FaceFeatureStore {
+ static final class Match {
+ final String faceId;
+ final String employeeNo;
+ final String name;
+ final float similarity;
+
+ Match(String faceId, String employeeNo, String name, float similarity) {
+ this.faceId = faceId;
+ this.employeeNo = employeeNo;
+ this.name = name;
+ this.similarity = similarity;
+ }
+ }
+
+ private static final class Record {
+ final String faceId;
+ final String employeeNo;
+ final String name;
+ final float[] feature;
+
+ Record(String faceId, String employeeNo, String name, float[] feature) {
+ this.faceId = faceId;
+ this.employeeNo = employeeNo;
+ this.name = name;
+ this.feature = feature;
+ }
+ }
+
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+ private final AtomicFile atomicStore;
+ private final List records = new ArrayList();
+
+ FaceFeatureStore(Context context) {
+ atomicStore = new AtomicFile(new File(context.getFilesDir(), "xingyuan_face_features.json"));
+ load();
+ }
+
+ synchronized int size() {
+ return records.size();
+ }
+
+ synchronized JSONArray listPeople() {
+ JSONArray people = new JSONArray();
+ for (Record record : records) {
+ try {
+ JSONObject person = new JSONObject();
+ person.put("faceId", record.faceId);
+ person.put("employeeNo", record.employeeNo);
+ person.put("name", record.name);
+ person.put("registered", true);
+ people.put(person);
+ } catch (Exception ignored) {
+ }
+ }
+ return people;
+ }
+
+ synchronized boolean remove(String faceId) throws Exception {
+ String normalizedId = safe(faceId);
+ List previous = new ArrayList(records);
+ boolean removed = false;
+ for (int i = records.size() - 1; i >= 0; i -= 1) {
+ if (normalizedId.equals(records.get(i).faceId)) {
+ records.remove(i);
+ removed = true;
+ }
+ }
+ if (removed) {
+ try {
+ save();
+ } catch (Exception error) {
+ // 磁盘写入失败时恢复内存快照,避免运行中的名单与磁盘人脸库不一致。
+ records.clear();
+ records.addAll(previous);
+ throw error;
+ }
+ }
+ return removed;
+ }
+
+ synchronized void put(String faceId, String employeeNo, String name, float[] feature) throws Exception {
+ if (faceId == null || faceId.trim().length() == 0 || feature == null || feature.length == 0) {
+ throw new IllegalArgumentException("人脸编号和特征不能为空");
+ }
+ String normalizedId = faceId.trim();
+ List previous = new ArrayList(records);
+ for (int i = records.size() - 1; i >= 0; i -= 1) {
+ if (normalizedId.equals(records.get(i).faceId)) {
+ records.remove(i);
+ }
+ }
+ records.add(new Record(normalizedId, safe(employeeNo), safe(name), feature.clone()));
+ try {
+ save();
+ } catch (Exception error) {
+ records.clear();
+ records.addAll(previous);
+ throw error;
+ }
+ }
+
+ synchronized Match findBest(float[] probe, float threshold) {
+ if (probe == null || probe.length == 0) {
+ return null;
+ }
+ Record best = null;
+ float bestScore = -1f;
+ for (Record record : records) {
+ if (record.feature.length != probe.length) {
+ continue;
+ }
+ float score = cosine(record.feature, probe);
+ if (score > bestScore) {
+ bestScore = score;
+ best = record;
+ }
+ }
+ if (best == null || bestScore < threshold) {
+ return null;
+ }
+ return new Match(best.faceId, best.employeeNo, best.name, bestScore);
+ }
+
+ private void load() {
+ synchronized (this) {
+ records.clear();
+ if (!atomicStore.getBaseFile().exists()) {
+ return;
+ }
+ StringBuilder text = new StringBuilder();
+ try {
+ // AtomicFile 会在上次写入中断时自动回退到 .bak,避免人员库变空。
+ BufferedReader reader = new BufferedReader(
+ new InputStreamReader(atomicStore.openRead(), UTF8));
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ text.append(line);
+ }
+ } finally {
+ reader.close();
+ }
+ JSONArray array = new JSONArray(text.toString());
+ for (int i = 0; i < array.length(); i += 1) {
+ JSONObject item = array.optJSONObject(i);
+ if (item == null) {
+ continue;
+ }
+ float[] feature = decode(item.optString("feature", ""));
+ if (feature.length == 0) {
+ continue;
+ }
+ records.add(new Record(
+ item.optString("faceId", ""),
+ item.optString("employeeNo", ""),
+ item.optString("name", ""),
+ feature));
+ }
+ } catch (Exception ignored) {
+ records.clear();
+ }
+ }
+ }
+
+ private void save() throws Exception {
+ JSONArray array = new JSONArray();
+ for (Record record : records) {
+ JSONObject item = new JSONObject();
+ item.put("faceId", record.faceId);
+ item.put("employeeNo", record.employeeNo);
+ item.put("name", record.name);
+ item.put("feature", encode(record.feature));
+ array.put(item);
+ }
+ FileOutputStream output = null;
+ try {
+ output = atomicStore.startWrite();
+ BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, UTF8));
+ writer.write(array.toString());
+ writer.flush();
+ atomicStore.finishWrite(output);
+ } catch (Exception error) {
+ if (output != null) {
+ atomicStore.failWrite(output);
+ }
+ throw error;
+ }
+ }
+
+ private static String encode(float[] feature) {
+ ByteBuffer buffer = ByteBuffer.allocate(feature.length * 4).order(ByteOrder.LITTLE_ENDIAN);
+ for (float value : feature) {
+ buffer.putFloat(value);
+ }
+ return Base64.encodeToString(buffer.array(), Base64.NO_WRAP);
+ }
+
+ private static float[] decode(String value) {
+ try {
+ byte[] bytes = Base64.decode(value, Base64.DEFAULT);
+ if (bytes.length == 0 || bytes.length % 4 != 0) {
+ return new float[0];
+ }
+ ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+ float[] feature = new float[bytes.length / 4];
+ for (int i = 0; i < feature.length; i += 1) {
+ feature[i] = buffer.getFloat();
+ }
+ return feature;
+ } catch (Exception ignored) {
+ return new float[0];
+ }
+ }
+
+ private static float cosine(float[] first, float[] second) {
+ double dot = 0;
+ double firstNorm = 0;
+ double secondNorm = 0;
+ for (int i = 0; i < first.length; i += 1) {
+ dot += first[i] * second[i];
+ firstNorm += first[i] * first[i];
+ secondNorm += second[i] * second[i];
+ }
+ if (firstNorm <= 0 || secondNorm <= 0) {
+ return -1f;
+ }
+ return (float) (dot / Math.sqrt(firstNorm * secondNorm));
+ }
+
+ private static String safe(String value) {
+ return value == null ? "" : value.trim();
+ }
+
+}
diff --git a/app/src/main/java/com/zhitou/terminal/FaceRecognitionController.java b/app/src/main/java/com/zhitou/terminal/FaceRecognitionController.java
new file mode 100644
index 0000000..3525394
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/FaceRecognitionController.java
@@ -0,0 +1,543 @@
+package com.xingyuan.zhiling;
+
+import android.content.Context;
+import android.content.res.AssetManager;
+import android.hardware.Camera;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.SurfaceHolder;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+final class FaceRecognitionController implements Camera.PreviewCallback {
+ private static final String TAG = "XingyuanFace";
+ interface Listener {
+ void onFaceStatus(String status, String message, int enrolledCount);
+
+ void onFaceRecognized(FaceFeatureStore.Match match);
+ }
+
+ private static final String MODEL_DIRECTORY = "face_models";
+ private static final String DETECTOR_MODEL = "face_detector.csta";
+ private static final String LANDMARKER_MODEL = "face_landmarker_pts5.csta";
+ private static final String RECOGNIZER_MODEL = "face_recognizer.csta";
+ private static final float DEFAULT_MATCH_THRESHOLD = 0.75f;
+ private static final long FRAME_INTERVAL_MS = 700;
+ private static final long RECOGNITION_COOLDOWN_MS = 5000;
+ private static final long ENROLLMENT_FEATURE_MAX_AGE_MS = 8000;
+ private static final long FEEDBACK_INTERVAL_MS = 1200;
+ private static final long CAMERA_RETRY_INTERVAL_MS = 5000;
+
+ private final Context context;
+ private final Listener listener;
+ private final FaceFeatureStore featureStore;
+ private final ExecutorService worker = Executors.newSingleThreadExecutor();
+ private final AtomicBoolean processing = new AtomicBoolean(false);
+ private final AtomicBoolean cameraTaskQueued = new AtomicBoolean(false);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private volatile Camera camera;
+ private volatile SurfaceHolder previewHolder;
+ private volatile boolean cameraStartRequestedWhileQueued;
+ private volatile boolean restartCameraWhenSurfaceReady;
+ private volatile boolean requestedActive;
+ private volatile long recognitionSession;
+ private volatile long lastCameraStartAttemptAt;
+ private volatile boolean engineReady;
+ private volatile int previewWidth;
+ private volatile int previewHeight;
+ private long lastFrameAt;
+ private long lastRecognizedAt;
+ private long lastSimilarityLogAt;
+ private long lastExtractionLogAt;
+ private long lastFeedbackAt;
+ private String lastFeedbackMessage = "";
+ private volatile float[] latestFeature;
+ private volatile long latestFeatureAt;
+ private volatile float matchThreshold = DEFAULT_MATCH_THRESHOLD;
+ private boolean faceAvailableNotified;
+
+ FaceRecognitionController(Context context, Listener listener) {
+ this.context = context.getApplicationContext();
+ this.listener = listener;
+ this.featureStore = new FaceFeatureStore(context);
+ // 模型约 100MB,复制和 SeetaFace6 初始化必须离开 UI 线程,否则首次启动会 ANR。
+ worker.execute(new Runnable() {
+ @Override
+ public void run() {
+ initializeEngine();
+ }
+ });
+ }
+
+ int enrolledCount() {
+ return featureStore.size();
+ }
+
+ String peopleJson() {
+ return featureStore.listPeople().toString();
+ }
+
+ synchronized float setMatchThreshold(float value) {
+ if (Float.isNaN(value) || Float.isInfinite(value)) {
+ value = DEFAULT_MATCH_THRESHOLD;
+ }
+ matchThreshold = Math.max(0.50f, Math.min(0.95f, value));
+ lastFeedbackAt = 0;
+ lastFeedbackMessage = "";
+ Log.i(TAG, "match threshold updated=" + matchThreshold);
+ return matchThreshold;
+ }
+
+ float getMatchThreshold() {
+ return matchThreshold;
+ }
+
+ synchronized boolean removePerson(String faceId) throws Exception {
+ boolean removed = featureStore.remove(faceId);
+ if (removed) {
+ notifyStatus("ready", "人员已删除", featureStore.size());
+ }
+ return removed;
+ }
+
+ void setActive(boolean active) {
+ if (destroyed.get()) {
+ return;
+ }
+ if (requestedActive == active) {
+ if (active && camera == null) {
+ requestCameraStart(false);
+ }
+ return;
+ }
+ requestedActive = active;
+ recognitionSession++;
+ latestFeature = null;
+ latestFeatureAt = 0;
+ faceAvailableNotified = false;
+ if (active) {
+ lastCameraStartAttemptAt = 0;
+ lastFrameAt = 0;
+ lastRecognizedAt = 0;
+ lastSimilarityLogAt = 0;
+ lastExtractionLogAt = 0;
+ lastFeedbackAt = 0;
+ lastFeedbackMessage = "";
+ Log.i(TAG, "resume recognition session=" + recognitionSession
+ + " enrolled=" + featureStore.size() + " cameraOpen=" + (camera != null));
+ requestCameraStart(false);
+ } else {
+ // RK3399 的旧 Camera HAL 在页面切换时调用 stopPreview/release 可能阻塞数十秒。
+ // 普通页面切换只暂停帧处理,保留摄像头预览;Activity 销毁时才真正释放。
+ Log.i(TAG, "pause recognition session=" + recognitionSession);
+ }
+ }
+
+ boolean isRequestedActive() {
+ return requestedActive;
+ }
+
+ void retryAfterPermission() {
+ if (destroyed.get()) {
+ return;
+ }
+ if (requestedActive) {
+ lastCameraStartAttemptAt = 0;
+ requestCameraStart(false);
+ }
+ }
+
+ void setPreviewSurface(SurfaceHolder holder) {
+ if (holder == null || destroyed.get()) {
+ return;
+ }
+ boolean changed = previewHolder != holder;
+ previewHolder = holder;
+ if (requestedActive && (camera == null || (changed && restartCameraWhenSurfaceReady))) {
+ requestCameraStart(changed && restartCameraWhenSurfaceReady);
+ }
+ }
+
+ void clearPreviewSurface(SurfaceHolder holder) {
+ if (previewHolder != holder) {
+ return;
+ }
+ previewHolder = null;
+ if (camera != null) {
+ // 页面切换不会再销毁 Surface;若系统确实回收了 Surface,等新 Surface
+ // 创建后在后台线程重启摄像头,绝不在 UI 回调中调用 stopPreview。
+ restartCameraWhenSurfaceReady = true;
+ }
+ }
+
+ void destroy() {
+ if (!destroyed.compareAndSet(false, true)) {
+ return;
+ }
+ requestedActive = false;
+ recognitionSession++;
+ restartCameraWhenSurfaceReady = false;
+ previewHolder = null;
+ latestFeature = null;
+ latestFeatureAt = 0;
+ try {
+ worker.execute(new Runnable() {
+ @Override
+ public void run() {
+ stopCamera();
+ if (engineReady) {
+ try {
+ SeetaFaceNative.release();
+ } catch (Throwable ignored) {
+ }
+ engineReady = false;
+ }
+ }
+ });
+ } catch (Throwable ignored) {
+ }
+ worker.shutdown();
+ }
+
+ synchronized void enrollCurrent(String faceId, String employeeNo, String name) throws Exception {
+ float[] snapshot = latestFeature;
+ long featureAge = SystemClock.elapsedRealtime() - latestFeatureAt;
+ if (snapshot == null || snapshot.length == 0 || featureAge > ENROLLMENT_FEATURE_MAX_AGE_MS) {
+ String reason = safeNativeError();
+ if (reason.length() == 0 || "识别引擎初始化失败".equals(reason)) {
+ reason = "请正对摄像头,避免逆光并保持面部清晰";
+ }
+ throw new IllegalStateException("最近 8 秒内未检测到可录入人脸:" + reason);
+ }
+ featureStore.put(faceId, employeeNo, name, snapshot);
+ notifyStatus("ready", "人脸采集成功", featureStore.size());
+ }
+
+ synchronized void prepareEnrollment() {
+ latestFeature = null;
+ latestFeatureAt = 0;
+ faceAvailableNotified = false;
+ notifyStatus("searching", "请正对摄像头,正在采集新的 SeetaFace6 人脸特征", featureStore.size());
+ }
+
+ private void initializeEngine() {
+ try {
+ File modelDirectory = new File(context.getFilesDir(), MODEL_DIRECTORY);
+ copyAsset(DETECTOR_MODEL, modelDirectory);
+ copyAsset(LANDMARKER_MODEL, modelDirectory);
+ copyAsset(RECOGNIZER_MODEL, modelDirectory);
+ engineReady = SeetaFaceNative.initialize(
+ new File(modelDirectory, DETECTOR_MODEL).getAbsolutePath(),
+ new File(modelDirectory, LANDMARKER_MODEL).getAbsolutePath(),
+ new File(modelDirectory, RECOGNIZER_MODEL).getAbsolutePath());
+ notifyStatus(engineReady ? "ready" : "error",
+ engineReady ? "识别引擎已就绪" : safeNativeError(), featureStore.size());
+ } catch (Throwable error) {
+ engineReady = false;
+ notifyStatus("error", "识别引擎加载失败:" + message(error), featureStore.size());
+ }
+ }
+
+ private void requestCameraStart(final boolean restart) {
+ if (destroyed.get()) {
+ return;
+ }
+ if (restart) {
+ restartCameraWhenSurfaceReady = true;
+ }
+ SurfaceHolder holder = previewHolder;
+ if (!requestedActive || holder == null || holder.getSurface() == null
+ || !holder.getSurface().isValid()) {
+ return;
+ }
+ long now = SystemClock.elapsedRealtime();
+ if (!restart && camera == null && lastCameraStartAttemptAt > 0
+ && now - lastCameraStartAttemptAt < CAMERA_RETRY_INTERVAL_MS) {
+ return;
+ }
+ if (!cameraTaskQueued.compareAndSet(false, true)) {
+ cameraStartRequestedWhileQueued = true;
+ return;
+ }
+ lastCameraStartAttemptAt = now;
+ cameraStartRequestedWhileQueued = false;
+ try {
+ worker.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ boolean shouldRestart = restartCameraWhenSurfaceReady;
+ restartCameraWhenSurfaceReady = false;
+ if (shouldRestart) {
+ stopCamera();
+ }
+ startCamera();
+ } finally {
+ boolean retryRequested = cameraStartRequestedWhileQueued;
+ cameraStartRequestedWhileQueued = false;
+ cameraTaskQueued.set(false);
+ if (requestedActive && previewHolder != null
+ && (retryRequested || restartCameraWhenSurfaceReady)) {
+ requestCameraStart(false);
+ }
+ }
+ }
+ });
+ } catch (Throwable ignored) {
+ cameraTaskQueued.set(false);
+ lastCameraStartAttemptAt = 0;
+ }
+ }
+
+ private synchronized void startCamera() {
+ if (destroyed.get() || !requestedActive || camera != null) {
+ return;
+ }
+ SurfaceHolder holder = previewHolder;
+ if (holder == null || holder.getSurface() == null || !holder.getSurface().isValid()) {
+ Log.i(TAG, "wait for persistent preview surface session=" + recognitionSession);
+ return;
+ }
+ Log.i(TAG, "open camera session=" + recognitionSession);
+ try {
+ int count = Camera.getNumberOfCameras();
+ if (count <= 0) {
+ notifyStatus("camera_error", "未发现系统摄像头", featureStore.size());
+ return;
+ }
+ camera = Camera.open(0);
+ Camera.Parameters parameters = camera.getParameters();
+ Camera.Size selected = choosePreviewSize(parameters.getSupportedPreviewSizes());
+ if (selected != null) {
+ parameters.setPreviewSize(selected.width, selected.height);
+ }
+ parameters.setPreviewFormat(android.graphics.ImageFormat.NV21);
+ camera.setParameters(parameters);
+ Camera.Size actual = camera.getParameters().getPreviewSize();
+ previewWidth = actual.width;
+ previewHeight = actual.height;
+ camera.setPreviewDisplay(holder);
+ camera.setPreviewCallback(this);
+ camera.startPreview();
+ notifyStatus(engineReady ? "recognizing" : "camera_ready",
+ engineReady
+ ? "摄像头已连接 " + previewWidth + "×" + previewHeight + ",识别中"
+ : "摄像头已连接,但识别引擎未就绪",
+ featureStore.size());
+ } catch (SecurityException error) {
+ stopCamera();
+ notifyStatus("permission", "等待摄像头权限", featureStore.size());
+ } catch (Throwable error) {
+ stopCamera();
+ notifyStatus("camera_error", "摄像头启动失败:" + message(error), featureStore.size());
+ }
+ }
+
+ private synchronized void stopCamera() {
+ recognitionSession++;
+ Log.i(TAG, "stop recognition session=" + recognitionSession);
+ Camera current = camera;
+ camera = null;
+ if (current != null) {
+ try {
+ current.setPreviewCallback(null);
+ current.stopPreview();
+ } catch (Throwable ignored) {
+ }
+ try {
+ current.release();
+ } catch (Throwable ignored) {
+ }
+ }
+ latestFeature = null;
+ latestFeatureAt = 0;
+ faceAvailableNotified = false;
+ }
+
+ @Override
+ public void onPreviewFrame(final byte[] data, Camera source) {
+ if (destroyed.get() || !requestedActive || !engineReady
+ || data == null || previewWidth <= 0 || previewHeight <= 0) {
+ return;
+ }
+ long now = SystemClock.elapsedRealtime();
+ if (now - lastFrameAt < FRAME_INTERVAL_MS || !processing.compareAndSet(false, true)) {
+ return;
+ }
+ lastFrameAt = now;
+ final byte[] frame = data.clone();
+ final int width = previewWidth;
+ final int height = previewHeight;
+ final long session = recognitionSession;
+ try {
+ worker.execute(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ long extractionStartedAt = SystemClock.elapsedRealtime();
+ boolean shouldLogExtraction = extractionStartedAt - lastExtractionLogAt >= 3000;
+ if (shouldLogExtraction) {
+ lastExtractionLogAt = extractionStartedAt;
+ Log.i(TAG, "extract begin session=" + session + " size=" + width + "x" + height
+ + " enrolled=" + featureStore.size());
+ }
+ float[] feature = SeetaFaceNative.extractFeature(frame, width, height, 0, false);
+ if (shouldLogExtraction) {
+ Log.i(TAG, "extract end session=" + session
+ + " elapsedMs=" + (SystemClock.elapsedRealtime() - extractionStartedAt)
+ + " feature=" + (feature == null ? 0 : feature.length)
+ + " nativeError=" + safeNativeError());
+ }
+ if (!isRecognitionSessionCurrent(session)) {
+ Log.i(TAG, "discard stale frame session=" + session
+ + " current=" + recognitionSession);
+ return;
+ }
+ if (feature == null || feature.length == 0) {
+ notifyFaceFeedback("face_error", safeNativeError());
+ if (latestFeatureAt > 0
+ && SystemClock.elapsedRealtime() - latestFeatureAt > ENROLLMENT_FEATURE_MAX_AGE_MS) {
+ latestFeature = null;
+ latestFeatureAt = 0;
+ if (faceAvailableNotified) {
+ faceAvailableNotified = false;
+ notifyStatus("searching", "正在等待清晰正脸", featureStore.size());
+ }
+ }
+ return;
+ }
+ latestFeature = feature;
+ latestFeatureAt = SystemClock.elapsedRealtime();
+ if (!faceAvailableNotified) {
+ faceAvailableNotified = true;
+ notifyFaceFeedback("face_ready", "已检测到清晰正脸");
+ }
+ long recognizedNow = SystemClock.elapsedRealtime();
+ FaceFeatureStore.Match best = featureStore.findBest(feature, -1f);
+ float currentThreshold = matchThreshold;
+ if (best != null && recognizedNow - lastSimilarityLogAt >= 3000) {
+ lastSimilarityLogAt = recognizedNow;
+ Log.i(TAG, "best face=" + best.faceId + " similarity=" + best.similarity
+ + " threshold=" + currentThreshold);
+ }
+ FaceFeatureStore.Match match = best != null && best.similarity >= currentThreshold ? best : null;
+ if (match == null) {
+ if (best == null) {
+ notifyFaceFeedback("no_match", "本地没有可匹配的人脸信息");
+ } else {
+ int similarityPercent = Math.round(best.similarity * 100f);
+ int thresholdPercent = Math.round(currentThreshold * 100f);
+ notifyFaceFeedback("no_match", "人脸匹配失败:相似度 "
+ + similarityPercent + "% ,需要达到 " + thresholdPercent + "%");
+ }
+ }
+ if (match != null && recognizedNow - lastRecognizedAt >= RECOGNITION_COOLDOWN_MS) {
+ lastRecognizedAt = recognizedNow;
+ Log.i(TAG, "recognized face=" + match.faceId + " similarity=" + match.similarity);
+ if (isRecognitionSessionCurrent(session)) {
+ listener.onFaceRecognized(match);
+ }
+ }
+ } catch (Throwable error) {
+ notifyStatus("error", "人脸识别失败:" + message(error), featureStore.size());
+ } finally {
+ processing.set(false);
+ }
+ }
+ });
+ } catch (Throwable error) {
+ // Activity 销毁和 Camera 回调可能同时发生,线程池关闭后不能让拒绝异常逃出 Camera 线程。
+ processing.set(false);
+ if (!destroyed.get()) {
+ Log.w(TAG, "discard preview frame because worker is unavailable", error);
+ }
+ }
+ }
+
+ private boolean isRecognitionSessionCurrent(long session) {
+ return requestedActive && recognitionSession == session;
+ }
+
+ private void notifyFaceFeedback(String status, String message) {
+ String normalized = message == null || message.length() == 0 ? "人脸识别失败" : message;
+ long now = SystemClock.elapsedRealtime();
+ boolean sameMessage = normalized.equals(lastFeedbackMessage);
+ if (now - lastFeedbackAt < FEEDBACK_INTERVAL_MS
+ || (sameMessage && now - lastFeedbackAt < FEEDBACK_INTERVAL_MS * 3)) {
+ return;
+ }
+ lastFeedbackAt = now;
+ lastFeedbackMessage = normalized;
+ notifyStatus(status, normalized, featureStore.size());
+ }
+
+ private void copyAsset(String name, File directory) throws Exception {
+ if (!directory.exists() && !directory.mkdirs()) {
+ throw new IllegalStateException("无法创建模型目录");
+ }
+ File target = new File(directory, name);
+ AssetManager assets = context.getAssets();
+ InputStream input = assets.open(MODEL_DIRECTORY + "/" + name);
+ int assetSize = input.available();
+ if (target.isFile() && assetSize > 0 && target.length() == assetSize) {
+ input.close();
+ return;
+ }
+ FileOutputStream output = new FileOutputStream(target, false);
+ try {
+ byte[] buffer = new byte[16384];
+ int count;
+ while ((count = input.read(buffer)) >= 0) {
+ output.write(buffer, 0, count);
+ }
+ } finally {
+ try {
+ input.close();
+ } finally {
+ output.close();
+ }
+ }
+ }
+
+ private Camera.Size choosePreviewSize(List sizes) {
+ if (sizes == null || sizes.isEmpty()) {
+ return null;
+ }
+ Camera.Size best = sizes.get(0);
+ long bestDistance = Long.MAX_VALUE;
+ for (Camera.Size size : sizes) {
+ long distance = Math.abs(size.width - 640L) + Math.abs(size.height - 480L);
+ if (distance < bestDistance) {
+ best = size;
+ bestDistance = distance;
+ }
+ }
+ return best;
+ }
+
+ private void notifyStatus(String status, String message, int count) {
+ if (!destroyed.get()) {
+ listener.onFaceStatus(status, message, count);
+ }
+ }
+
+ private String safeNativeError() {
+ try {
+ String value = SeetaFaceNative.lastError();
+ return value == null || value.length() == 0 ? "识别引擎初始化失败" : value;
+ } catch (Throwable ignored) {
+ return "识别引擎初始化失败";
+ }
+ }
+
+ private static String message(Throwable error) {
+ String value = error == null ? "未知错误" : error.getMessage();
+ return value == null || value.length() == 0 ? error.getClass().getSimpleName() : value;
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/MainActivity.java b/app/src/main/java/com/zhitou/terminal/MainActivity.java
new file mode 100644
index 0000000..9d8f074
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/MainActivity.java
@@ -0,0 +1,663 @@
+package com.xingyuan.zhiling;
+
+import android.Manifest;
+import android.annotation.TargetApi;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.provider.Settings;
+import android.view.View;
+import android.view.Gravity;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.webkit.ValueCallback;
+import android.webkit.RenderProcessGoneDetail;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.FrameLayout;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.File;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.json.JSONObject;
+
+public class MainActivity extends Activity implements
+ ZhitouSerialPortManager.Listener,
+ ZhitouTcpReporter.Listener,
+ FaceRecognitionController.Listener {
+ static final int FILE_CHOOSER_REQUEST = 4101;
+ private static final int PERMISSION_REQUEST = 4102;
+ private FrameLayout rootLayout;
+ private WebView webView;
+ private SurfaceView facePreviewView;
+ private int facePreviewLeft = Integer.MIN_VALUE;
+ private int facePreviewTop = Integer.MIN_VALUE;
+ private int facePreviewWidth = Integer.MIN_VALUE;
+ private int facePreviewHeight = Integer.MIN_VALUE;
+ private volatile boolean requestedFacePreviewVisible;
+ private volatile int requestedFacePreviewLeft;
+ private volatile int requestedFacePreviewTop;
+ private volatile int requestedFacePreviewWidth;
+ private volatile int requestedFacePreviewHeight;
+ private final AtomicBoolean facePreviewLayoutQueued = new AtomicBoolean(false);
+ private final AtomicLong facePreviewLayoutVersion = new AtomicLong(0L);
+ private String pendingRecoveryNotice;
+ private boolean recoveryRetryStarted;
+ private final Runnable markStableLaunchTask = new Runnable() {
+ @Override
+ public void run() {
+ CrashRecovery.markLaunchSuccessful(MainActivity.this);
+ }
+ };
+ private ValueCallback filePathCallback;
+ private ZhitouTcpReporter tcpReporter;
+ private ZhitouSerialPortManager serialPortManager;
+ private ZhitouBridge bridge;
+ private FaceRecognitionController faceRecognitionController;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (CrashRecovery.shouldEnterStartupRecovery(this)) {
+ showStartupRecoveryScreen();
+ return;
+ }
+ pendingRecoveryNotice = CrashRecovery.consumeRecoveryNotice(this);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ rootLayout = new FrameLayout(this);
+ webView = new WebView(this);
+ tcpReporter = new ZhitouTcpReporter(this, this);
+ serialPortManager = new ZhitouSerialPortManager(this);
+ webView.setBackgroundColor(Color.rgb(247, 250, 248));
+ rootLayout.addView(webView, new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+ facePreviewView = new SurfaceView(this);
+ facePreviewView.setZOrderOnTop(true);
+ facePreviewView.getHolder().setFormat(PixelFormat.OPAQUE);
+ // Surface 始终保留。RK3399 的旧 Camera HAL 在页面切换时重新绑定
+ // Surface 会阻塞主线程并触发 ANR;隐藏时只把画面缩到 1×1。
+ facePreviewView.setVisibility(View.VISIBLE);
+ rootLayout.addView(facePreviewView, new FrameLayout.LayoutParams(1, 1));
+ setContentView(rootLayout);
+
+ configureWebView();
+ requestRuntimePermissions();
+ prepareSmartStorage();
+ webView.loadUrl("file:///android_asset/www/index.html");
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private void configureWebView() {
+ WebSettings settings = webView.getSettings();
+ settings.setJavaScriptEnabled(true);
+ settings.setDomStorageEnabled(true);
+ settings.setDatabaseEnabled(true);
+ settings.setAllowFileAccess(true);
+ settings.setAllowContentAccess(true);
+ settings.setLoadWithOverviewMode(true);
+ settings.setUseWideViewPort(true);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ // 页面只通过受控的原生桥通讯,不允许 file:// 页面任意读取文件或跨域访问。
+ settings.setAllowFileAccessFromFileURLs(false);
+ settings.setAllowUniversalAccessFromFileURLs(false);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
+ }
+
+ webView.setWebViewClient(new RecoveryWebViewClient());
+ webView.setWebChromeClient(new ZhitouWebChromeClient(this));
+ faceRecognitionController = new FaceRecognitionController(this, this);
+ facePreviewView.getHolder().addCallback(new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ if (faceRecognitionController != null) {
+ faceRecognitionController.setPreviewSurface(holder);
+ }
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ if (faceRecognitionController != null) {
+ faceRecognitionController.clearPreviewSurface(holder);
+ }
+ }
+ });
+ bridge = new ZhitouBridge(this, tcpReporter, serialPortManager, faceRecognitionController);
+ webView.addJavascriptInterface(bridge, "XingyuanNative");
+ webView.addJavascriptInterface(bridge, "ZhitouNative");
+ }
+
+ private void requestRuntimePermissions() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return;
+ }
+ String[] permissions = new String[] {
+ Manifest.permission.CAMERA,
+ Manifest.permission.READ_EXTERNAL_STORAGE,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ };
+ requestPermissions(permissions, PERMISSION_REQUEST);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == PERMISSION_REQUEST) {
+ prepareSmartStorage();
+ if (faceRecognitionController != null) {
+ faceRecognitionController.retryAfterPermission();
+ }
+ }
+ }
+
+ private void prepareSmartStorage() {
+ File smartDir = new File(Environment.getExternalStorageDirectory(), "smart");
+ File imageDir = new File(smartDir, "Image");
+ File logsDir = new File(smartDir, "logs");
+ // 首次启动时创建业务固定目录,避免用户手动新建 smart/Image、smart/logs。
+ ensureDirectory(imageDir);
+ ensureDirectory(logsDir);
+ }
+
+ private void ensureDirectory(File dir) {
+ if (dir == null || dir.exists()) {
+ return;
+ }
+ try {
+ dir.mkdirs();
+ } catch (Exception ignored) {
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode != FILE_CHOOSER_REQUEST || filePathCallback == null) {
+ return;
+ }
+ Uri[] results = null;
+ if (resultCode == RESULT_OK && data != null) {
+ if (data.getClipData() != null) {
+ int count = data.getClipData().getItemCount();
+ results = new Uri[count];
+ for (int i = 0; i < count; i += 1) {
+ results[i] = data.getClipData().getItemAt(i).getUri();
+ }
+ } else if (data.getData() != null) {
+ results = new Uri[] { data.getData() };
+ }
+ }
+ filePathCallback.onReceiveValue(results);
+ filePathCallback = null;
+ }
+
+ void setFilePathCallback(ValueCallback callback) {
+ if (filePathCallback != null) {
+ filePathCallback.onReceiveValue(null);
+ }
+ filePathCallback = callback;
+ }
+
+ void clearFilePathCallback() {
+ filePathCallback = null;
+ }
+
+ void launchFileChooser(Intent intent) throws ActivityNotFoundException {
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ startActivityForResult(intent, FILE_CHOOSER_REQUEST);
+ }
+
+ void openSystemSettings() {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ startActivity(intent);
+ }
+
+ void openFileManager() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException error) {
+ openFileManagerFallback();
+ }
+ }
+
+ @Override
+ public void onImei(String imei) {
+ dispatchSerialImei(imei);
+ }
+
+ @Override
+ public void onVersion(String version) {
+ dispatchSerialVersion(version);
+ }
+
+ @Override
+ public void onIdentity(int type, String identity) {
+ dispatchSerialIdentity(type, identity);
+ }
+
+ @Override
+ public void onThrowWeight(ZhitouSerialPortManager.ThrowWeightItem item) {
+ dispatchSerialThrowWeight(item);
+ }
+
+ @Override
+ public void onDeviceStatus(ZhitouSerialPortManager.DeviceStatusItem item) {
+ dispatchSerialDeviceStatus(item);
+ }
+
+ @Override
+ public void onReporterLog(String kind, String message) {
+ dispatchNativeLog(kind, message);
+ }
+
+ private void dispatchSerialIdentity(int type, String identity) {
+ if (webView == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouSerialIdentityTask(webView, type, identity));
+ }
+
+ private void dispatchSerialImei(String imei) {
+ if (webView == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouSerialImeiTask(webView, imei));
+ }
+
+ private void dispatchSerialVersion(String version) {
+ if (webView == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouSerialVersionTask(webView, version));
+ }
+
+ private void dispatchSerialThrowWeight(ZhitouSerialPortManager.ThrowWeightItem item) {
+ if (webView == null || item == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouSerialThrowWeightTask(webView, item));
+ }
+
+ private void dispatchSerialDeviceStatus(ZhitouSerialPortManager.DeviceStatusItem item) {
+ if (webView == null || item == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouSerialDeviceStatusTask(webView, item));
+ }
+
+ private void dispatchNativeLog(String kind, String message) {
+ if (webView == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouNativeLogTask(webView, kind, message));
+ }
+
+ @Override
+ public void onFaceStatus(String status, String message, int enrolledCount) {
+ if (webView == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouFaceStatusTask(webView, status, message, enrolledCount));
+ }
+
+ @Override
+ public void onFaceRecognized(FaceFeatureStore.Match match) {
+ if (webView == null || match == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouFaceRecognizedTask(webView, match));
+ }
+
+ void setFaceRecognitionActive(boolean active) {
+ if (faceRecognitionController != null) {
+ faceRecognitionController.setActive(active);
+ }
+ }
+
+ void setFacePreviewLayout(final boolean visible, final int left, final int top, final int width, final int height) {
+ requestedFacePreviewVisible = visible;
+ requestedFacePreviewLeft = left;
+ requestedFacePreviewTop = top;
+ requestedFacePreviewWidth = width;
+ requestedFacePreviewHeight = height;
+ facePreviewLayoutVersion.incrementAndGet();
+ queueFacePreviewLayoutApply();
+ }
+
+ private void queueFacePreviewLayoutApply() {
+ if (!facePreviewLayoutQueued.compareAndSet(false, true)) {
+ return;
+ }
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ long appliedVersion = facePreviewLayoutVersion.get();
+ applyFacePreviewLayout(
+ requestedFacePreviewVisible,
+ requestedFacePreviewLeft,
+ requestedFacePreviewTop,
+ requestedFacePreviewWidth,
+ requestedFacePreviewHeight);
+ facePreviewLayoutQueued.set(false);
+ if (facePreviewLayoutVersion.get() != appliedVersion) {
+ queueFacePreviewLayoutApply();
+ }
+ }
+ });
+ }
+
+ private void applyFacePreviewLayout(boolean visible, int left, int top, int width, int height) {
+ if (facePreviewView == null || rootLayout == null) {
+ return;
+ }
+ int availableWidth = rootLayout.getWidth();
+ int availableHeight = rootLayout.getHeight();
+ if (availableWidth <= 0 || availableHeight <= 0) {
+ availableWidth = getResources().getDisplayMetrics().widthPixels;
+ availableHeight = getResources().getDisplayMetrics().heightPixels;
+ }
+ availableWidth = Math.max(1, availableWidth);
+ availableHeight = Math.max(1, availableHeight);
+ boolean showPreview = visible && width > 0 && height > 0;
+ int targetLeft = showPreview ? clamp(left, 0, availableWidth - 1) : 0;
+ int targetTop = showPreview ? clamp(top, 0, availableHeight - 1) : 0;
+ int targetWidth = showPreview ? clamp(width, 1, availableWidth - targetLeft) : 1;
+ int targetHeight = showPreview ? clamp(height, 1, availableHeight - targetTop) : 1;
+ boolean layoutChanged = facePreviewLeft != targetLeft
+ || facePreviewTop != targetTop
+ || facePreviewWidth != targetWidth
+ || facePreviewHeight != targetHeight;
+ if (layoutChanged) {
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(targetWidth, targetHeight);
+ params.leftMargin = targetLeft;
+ params.topMargin = targetTop;
+ facePreviewView.setLayoutParams(params);
+ facePreviewLeft = targetLeft;
+ facePreviewTop = targetTop;
+ facePreviewWidth = targetWidth;
+ facePreviewHeight = targetHeight;
+ if (showPreview) {
+ facePreviewView.bringToFront();
+ }
+ }
+ if (facePreviewView.getVisibility() != View.VISIBLE) {
+ facePreviewView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private static int clamp(int value, int minimum, int maximum) {
+ return Math.max(minimum, Math.min(maximum, value));
+ }
+
+ int getEnrolledFaceCount() {
+ return faceRecognitionController == null ? 0 : faceRecognitionController.enrolledCount();
+ }
+
+ float setFaceMatchThreshold(float value) {
+ return faceRecognitionController == null ? value : faceRecognitionController.setMatchThreshold(value);
+ }
+
+ float getFaceMatchThreshold() {
+ return faceRecognitionController == null ? 0.75f : faceRecognitionController.getMatchThreshold();
+ }
+
+ String getFacePeopleJson() {
+ return faceRecognitionController == null ? "[]" : faceRecognitionController.peopleJson();
+ }
+
+ boolean removeFacePerson(String faceId) throws Exception {
+ if (faceRecognitionController == null) {
+ throw new IllegalStateException("人脸识别控制器不可用");
+ }
+ return faceRecognitionController.removePerson(faceId);
+ }
+
+ void enrollCurrentFace(String faceId, String employeeNo, String name) throws Exception {
+ if (faceRecognitionController == null) {
+ throw new IllegalStateException("人脸识别控制器不可用");
+ }
+ faceRecognitionController.enrollCurrent(faceId, employeeNo, name);
+ }
+
+ void prepareFaceEnrollment() {
+ if (faceRecognitionController != null) {
+ faceRecognitionController.prepareEnrollment();
+ }
+ }
+
+ void dispatchConnectionCheck(JSONObject payload) {
+ if (webView == null) {
+ return;
+ }
+ runOnUiThread(new ZhitouConnectionCheckTask(webView, payload));
+ }
+
+ private void openFileManagerFallback() {
+ Intent fallback = new Intent(Intent.ACTION_GET_CONTENT);
+ fallback.setType("*/*");
+ fallback.addCategory(Intent.CATEGORY_OPENABLE);
+ try {
+ startActivity(fallback);
+ } catch (ActivityNotFoundException error) {
+ Toast.makeText(this, "无法打开文件管理", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (webView != null && webView.canGoBack()) {
+ webView.goBack();
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ enterImmersiveMode();
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ super.onTrimMemory(level);
+ if (level >= TRIM_MEMORY_MODERATE && webView != null) {
+ webView.freeMemory();
+ }
+ }
+
+ @Override
+ public void onLowMemory() {
+ super.onLowMemory();
+ if (webView != null) {
+ webView.freeMemory();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (faceRecognitionController != null) {
+ faceRecognitionController.destroy();
+ faceRecognitionController = null;
+ }
+ if (facePreviewView != null) {
+ facePreviewView.setVisibility(View.GONE);
+ }
+ if (bridge != null) {
+ bridge.destroy();
+ bridge = null;
+ }
+ if (webView != null) {
+ webView.removeCallbacks(markStableLaunchTask);
+ webView.removeJavascriptInterface("XingyuanNative");
+ webView.removeJavascriptInterface("ZhitouNative");
+ webView.stopLoading();
+ webView.loadUrl("about:blank");
+ webView.setWebChromeClient(null);
+ webView.setWebViewClient(null);
+ if (rootLayout != null) {
+ rootLayout.removeView(webView);
+ }
+ webView.destroy();
+ webView = null;
+ }
+ if (tcpReporter != null) {
+ tcpReporter.stop();
+ }
+ if (serialPortManager != null) {
+ serialPortManager.destroy();
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus) {
+ enterImmersiveMode();
+ }
+ }
+
+ private void enterImmersiveMode() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ getWindow().getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
+ }
+ }
+
+ private void showStartupRecoveryScreen() {
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ LinearLayout panel = new LinearLayout(this);
+ panel.setOrientation(LinearLayout.VERTICAL);
+ panel.setGravity(Gravity.CENTER);
+ panel.setPadding(64, 64, 64, 64);
+ panel.setBackgroundColor(Color.rgb(247, 250, 248));
+
+ TextView title = new TextView(this);
+ title.setText("应用安全恢复");
+ title.setTextSize(28f);
+ title.setTextColor(Color.rgb(29, 68, 51));
+ title.setGravity(Gravity.CENTER);
+ panel.addView(title, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT));
+
+ TextView detail = new TextView(this);
+ detail.setText("上次启动期间发生异常,已阻止循环崩溃。\n\n"
+ + CrashRecovery.peekRecoveryNotice(this));
+ detail.setTextSize(18f);
+ detail.setTextColor(Color.DKGRAY);
+ detail.setGravity(Gravity.CENTER);
+ LinearLayout.LayoutParams detailParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT);
+ detailParams.setMargins(0, 40, 0, 40);
+ panel.addView(detail, detailParams);
+
+ Button retry = new Button(this);
+ retry.setText("重新启动应用");
+ retry.setTextSize(20f);
+ retry.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ retryAfterStartupCrash();
+ }
+ });
+ panel.addView(retry, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT));
+ setContentView(panel);
+ enterImmersiveMode();
+
+ if (CrashRecovery.canAutoRetryStartup(this)) {
+ panel.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ retryAfterStartupCrash();
+ }
+ }, 4_000L);
+ }
+ }
+
+ private void retryAfterStartupCrash() {
+ if (recoveryRetryStarted) {
+ return;
+ }
+ recoveryRetryStarted = true;
+ CrashRecovery.clearStartupRecoveryForRetry(this);
+ Intent retryIntent = new Intent(this, MainActivity.class);
+ retryIntent.setAction("com.xingyuan.zhiling.action.CRASH_RECOVERY");
+ retryIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ finish();
+ startActivity(retryIntent);
+ }
+
+ private final class RecoveryWebViewClient extends WebViewClient {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ view.removeCallbacks(markStableLaunchTask);
+ view.postDelayed(markStableLaunchTask, 30_000L);
+ if (pendingRecoveryNotice == null || pendingRecoveryNotice.length() == 0) {
+ return;
+ }
+ String notice = pendingRecoveryNotice;
+ pendingRecoveryNotice = "";
+ new ZhitouNativeLogTask(view, "恢复", notice).run();
+ Toast.makeText(MainActivity.this, notice, Toast.LENGTH_LONG).show();
+ }
+
+ @TargetApi(Build.VERSION_CODES.O)
+ @Override
+ public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) {
+ boolean didCrash = detail != null && detail.didCrash();
+ CrashRecovery.recordRendererCrashAndRestart(MainActivity.this, didCrash);
+ android.os.Process.killProcess(android.os.Process.myPid());
+ return true;
+ }
+ }
+
+ @SuppressWarnings("unused")
+ private void openApplicationSettings() {
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ startActivity(intent);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/SeetaFaceNative.java b/app/src/main/java/com/zhitou/terminal/SeetaFaceNative.java
new file mode 100644
index 0000000..2ab09b6
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/SeetaFaceNative.java
@@ -0,0 +1,23 @@
+package com.xingyuan.zhiling;
+
+final class SeetaFaceNative {
+ static {
+ System.loadLibrary("TenniS");
+ System.loadLibrary("SeetaAuthorize");
+ System.loadLibrary("SeetaFaceDetector600");
+ System.loadLibrary("SeetaFaceLandmarker600");
+ System.loadLibrary("SeetaFaceRecognizer600");
+ System.loadLibrary("xingyuan_face");
+ }
+
+ private SeetaFaceNative() {
+ }
+
+ static native boolean initialize(String detectorModel, String landmarkerModel, String recognizerModel);
+
+ static native float[] extractFeature(byte[] nv21, int width, int height, int rotationDegrees, boolean mirror);
+
+ static native String lastError();
+
+ static native void release();
+}
diff --git a/app/src/main/java/com/zhitou/terminal/XingyuanApplication.java b/app/src/main/java/com/zhitou/terminal/XingyuanApplication.java
new file mode 100644
index 0000000..fb7d9a0
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/XingyuanApplication.java
@@ -0,0 +1,47 @@
+package com.xingyuan.zhiling;
+
+import android.app.Application;
+import android.os.Process;
+import android.util.Log;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public final class XingyuanApplication extends Application {
+ private static final String TAG = "XingyuanCrash";
+ private final AtomicBoolean handlingCrash = new AtomicBoolean(false);
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+ @Override
+ public void uncaughtException(Thread thread, Throwable error) {
+ if (!handlingCrash.compareAndSet(false, true)) {
+ terminate(previous, thread, error);
+ return;
+ }
+ try {
+ Log.e(TAG, "Uncaught exception in " + thread.getName(), error);
+ CrashRecovery.recordAndSchedule(XingyuanApplication.this, error);
+ } catch (Throwable recoveryError) {
+ Log.e(TAG, "Unable to record or schedule crash recovery", recoveryError);
+ }
+ Process.killProcess(Process.myPid());
+ System.exit(10);
+ }
+ });
+ }
+
+ private static void terminate(
+ Thread.UncaughtExceptionHandler previous,
+ Thread thread,
+ Throwable error) {
+ if (previous != null) {
+ previous.uncaughtException(thread, error);
+ return;
+ }
+ Process.killProcess(Process.myPid());
+ System.exit(10);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouBridge.java b/app/src/main/java/com/zhitou/terminal/ZhitouBridge.java
new file mode 100644
index 0000000..710f50f
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouBridge.java
@@ -0,0 +1,988 @@
+package com.xingyuan.zhiling;
+
+import android.os.Environment;
+import android.webkit.JavascriptInterface;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.util.Locale;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+final class ZhitouBridge {
+ private static final class ConnectionCheckTask implements Runnable {
+ private final ZhitouBridge bridge;
+ private final String json;
+
+ ConnectionCheckTask(ZhitouBridge bridge, String json) {
+ this.bridge = bridge;
+ this.json = json;
+ }
+
+ @Override
+ public void run() {
+ bridge.runConnectionCheck(json);
+ }
+ }
+
+ private static final class SerialCheckTask implements Callable {
+ private final ZhitouSerialPortManager manager;
+ private final String path;
+ private final int baudRate;
+
+ SerialCheckTask(ZhitouSerialPortManager manager, String path, int baudRate) {
+ this.manager = manager;
+ this.path = path;
+ this.baudRate = baudRate;
+ }
+
+ @Override
+ public Boolean call() {
+ return manager.checkSerial(path, baudRate);
+ }
+ }
+
+ private static final class TcpCheckTask implements Callable {
+ private final ZhitouBridge bridge;
+ private final String host;
+ private final int port;
+ private final int timeoutSeconds;
+
+ TcpCheckTask(ZhitouBridge bridge, String host, int port, int timeoutSeconds) {
+ this.bridge = bridge;
+ this.host = host;
+ this.port = port;
+ this.timeoutSeconds = timeoutSeconds;
+ }
+
+ @Override
+ public Boolean call() {
+ return host.length() > 0 && port > 0 && bridge.connectTcp(host, port, timeoutSeconds);
+ }
+ }
+
+ private static final class PingCheckTask implements Callable {
+ private final ZhitouBridge bridge;
+ private final String host;
+ private final int timeoutSeconds;
+
+ PingCheckTask(ZhitouBridge bridge, String host, int timeoutSeconds) {
+ this.bridge = bridge;
+ this.host = host;
+ this.timeoutSeconds = timeoutSeconds;
+ }
+
+ @Override
+ public Boolean call() {
+ return bridge.ping(host.length() > 0 ? host : "www.baidu.com", timeoutSeconds);
+ }
+ }
+
+ private final MainActivity activity;
+ private final ZhitouTcpReporter reporter;
+ private final ZhitouSerialPortManager serialPortManager;
+ private final FaceRecognitionController faceRecognitionController;
+ private final ExecutorService backgroundExecutor = Executors.newFixedThreadPool(4);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+
+ ZhitouBridge(
+ MainActivity activity,
+ ZhitouTcpReporter reporter,
+ ZhitouSerialPortManager serialPortManager,
+ FaceRecognitionController faceRecognitionController) {
+ this.activity = activity;
+ this.reporter = reporter;
+ this.serialPortManager = serialPortManager;
+ this.faceRecognitionController = faceRecognitionController;
+ }
+
+ void destroy() {
+ if (!destroyed.compareAndSet(false, true)) {
+ return;
+ }
+ backgroundExecutor.shutdownNow();
+ }
+
+ private boolean executeBackground(Runnable task) {
+ if (destroyed.get()) {
+ return false;
+ }
+ try {
+ backgroundExecutor.execute(task);
+ return true;
+ } catch (RejectedExecutionException ignored) {
+ // WebView 回调可能与 Activity 销毁同时到达,此时直接丢弃过期任务。
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public void configure(String json) {
+ try {
+ JSONObject data = obj(json);
+ reporter.configure(
+ data.optString("host", "112.74.41.228"),
+ data.optInt("port", 9956),
+ data.optString("terminalId", ""));
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public void startHeartbeat(String json) {
+ reporter.startHeartbeat();
+ }
+
+ @JavascriptInterface
+ public boolean pingHost(String json) {
+ try {
+ JSONObject data = obj(json);
+ String host = data.optString("host", "www.baidu.com").trim();
+ int timeoutSeconds = data.optInt("timeoutSeconds", 3);
+ if (host.length() == 0) {
+ host = "www.baidu.com";
+ }
+ return ping(host, timeoutSeconds);
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean checkTcpServer(String json) {
+ try {
+ JSONObject data = obj(json);
+ String host = data.optString("host", "112.74.41.228").trim();
+ int port = data.optInt("port", 9956);
+ int timeoutSeconds = data.optInt("timeoutSeconds", 3);
+ if (host.length() == 0 || port <= 0) {
+ return false;
+ }
+ return connectTcp(host, port, timeoutSeconds);
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public String getLowerImei(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.requestImei(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600));
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ @JavascriptInterface
+ public void requestLowerImei(String json) {
+ executeBackground(new ZhitouSerialImeiRequestTask(serialPortManager, json));
+ }
+
+ @JavascriptInterface
+ public boolean checkSerial(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.checkSerial(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public String getLowerVersion(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.requestVersion(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600));
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ @JavascriptInterface
+ public void requestLowerVersion(String json) {
+ executeBackground(new ZhitouSerialVersionRequestTask(serialPortManager, json));
+ }
+
+ @JavascriptInterface
+ public void requestConnectionCheck(final String json) {
+ executeBackground(new ConnectionCheckTask(this, json));
+ }
+
+ private void runConnectionCheck(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ final JSONObject data = obj(json);
+ final String path = data.optString("path", "/dev/ttyS4");
+ final int baudRate = data.optInt("baudRate", 9600);
+ final String host = data.optString("host", "112.74.41.228").trim();
+ final int port = data.optInt("port", 9956);
+ final int timeoutSeconds = Math.max(1, Math.min(data.optInt("timeoutSeconds", 2), 5));
+ final String pingHost = data.optString("pingHost", "www.baidu.com").trim();
+ final String mode = data.optString("mode", "startup");
+ Future serialFuture = null;
+ if (!"network".equals(mode)) {
+ serialFuture = backgroundExecutor.submit(new SerialCheckTask(serialPortManager, path, baudRate));
+ }
+ Future tcpFuture = backgroundExecutor.submit(new TcpCheckTask(this, host, port, timeoutSeconds));
+ Future pingFuture = backgroundExecutor.submit(new PingCheckTask(this, pingHost, timeoutSeconds));
+ boolean serialOnline = serialFuture == null || futureBoolean(serialFuture, timeoutSeconds + 1);
+ boolean tcpConnected = futureBoolean(tcpFuture, timeoutSeconds + 1);
+ boolean pingOk = futureBoolean(pingFuture, timeoutSeconds + 1);
+ result.put("requestId", data.optInt("requestId", 0));
+ result.put("mode", mode);
+ result.put("serialOnline", serialOnline);
+ result.put("tcpConnected", tcpConnected);
+ result.put("pingOk", pingOk);
+ result.put("networkOnline", tcpConnected || pingOk);
+ result.put("httpReachable", tcpConnected || pingOk);
+ } catch (Exception error) {
+ try {
+ result.put("mode", "startup");
+ result.put("error", error.getMessage() == null ? "connection check failed" : error.getMessage());
+ } catch (Exception ignored) {
+ }
+ }
+ if (!destroyed.get()) {
+ activity.dispatchConnectionCheck(result);
+ }
+ }
+
+ private boolean futureBoolean(Future future, int timeoutSeconds) {
+ try {
+ return Boolean.TRUE.equals(future.get(timeoutSeconds, TimeUnit.SECONDS));
+ } catch (Exception ignored) {
+ future.cancel(true);
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean openThrowDoor(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.openThrowDoor(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600),
+ data.optInt("port", 0));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean closeThrowDoor(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.closeThrowDoor(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean openReceiveDoor(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.openReceiveDoor(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600),
+ data.optInt("port", 1));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean queryWeight(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.queryWeight(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600),
+ data.optInt("port", 0));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean queryFill(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.queryFill(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600),
+ data.optInt("port", 0));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean queryTemperature(String json) {
+ try {
+ JSONObject data = obj(json);
+ return serialPortManager.queryTemperature(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600),
+ data.optInt("port", 0));
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public void openSystemSettings(String json) {
+ activity.runOnUiThread(new ZhitouOpenSystemSettingsTask(activity));
+ }
+
+ @JavascriptInterface
+ public void openFileManager(String json) {
+ activity.runOnUiThread(new ZhitouOpenFileManagerTask(activity));
+ }
+
+ @JavascriptInterface
+ public void reportMemberVerify(String json) {
+ try {
+ JSONObject data = obj(json);
+ reporter.reportMemberVerify(data.optInt("idType", 2), data.optString("idContent", ""));
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public String verifyMember(String json) {
+ try {
+ JSONObject data = obj(json);
+ return reporter.requestMemberVerify(
+ data.optInt("idType", 2),
+ data.optString("idContent", "")).toJson();
+ } catch (Exception ignored) {
+ return ZhitouMemberInfo.failed("会员鉴权异常").toJson();
+ }
+ }
+
+ @JavascriptInterface
+ public String fetchWorkContext(String json) {
+ // 正式机台接口未接入前返回空数组,前端仍允许手动填写,不能注入测试人员或批次。
+ return new JSONArray().toString();
+ }
+
+ /** 人脸平台接口尚未接入;明确返回失败,避免把测试人员写入真实人脸库。 */
+ @JavascriptInterface
+ public String syncFaceData(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ result.put("ok", false);
+ result.put("count", activity.getEnrolledFaceCount());
+ result.put("error", "人员同步接口暂未接入");
+ } catch (Exception ignored) {
+ }
+ return result.toString();
+ }
+
+ @JavascriptInterface
+ public String verifyFace(String json) {
+ try {
+ String faceId = obj(json).optString("faceId", "").trim();
+ // 识别结果只允许在真实 1:N 特征库中核验,不能使用独立的测试名单缓存。
+ JSONArray faces = new JSONArray(activity.getFacePeopleJson());
+ for (int i = 0; i < faces.length(); i++) {
+ JSONObject face = faces.optJSONObject(i);
+ if (face != null && faceId.equals(face.optString("faceId"))) {
+ JSONObject result = new JSONObject();
+ result.put("ok", true);
+ result.put("employeeNo", face.optString("employeeNo"));
+ result.put("memberCode", face.optString("employeeNo"));
+ result.put("memberId", face.optString("employeeNo"));
+ result.put("name", face.optString("name"));
+ result.put("balance", 0);
+ return result.toString();
+ }
+ }
+ return ZhitouMemberInfo.failed("人脸识别失败").toJson();
+ } catch (Exception ignored) {
+ return ZhitouMemberInfo.failed("人脸识别失败").toJson();
+ }
+ }
+
+ @JavascriptInterface
+ public void setFaceRecognitionActive(String json) {
+ try {
+ activity.setFaceRecognitionActive(obj(json).optBoolean("active", false));
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public void setFacePreviewLayout(String json) {
+ try {
+ JSONObject data = obj(json);
+ activity.setFacePreviewLayout(
+ data.optBoolean("visible", false),
+ data.optInt("left", 0),
+ data.optInt("top", 0),
+ data.optInt("width", 0),
+ data.optInt("height", 0));
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public String getFaceRecognitionStatus(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ result.put("ok", faceRecognitionController != null);
+ result.put("enrolledCount", activity.getEnrolledFaceCount());
+ result.put("matchThreshold", activity.getFaceMatchThreshold());
+ } catch (Exception ignored) {
+ }
+ return result.toString();
+ }
+
+ @JavascriptInterface
+ public String setFaceMatchThreshold(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ float requested = (float) obj(json).optDouble("threshold", 0.75);
+ float applied = activity.setFaceMatchThreshold(requested);
+ result.put("ok", true);
+ result.put("threshold", applied);
+ } catch (Exception error) {
+ try {
+ result.put("ok", false);
+ result.put("error", "人脸匹配阈值设置失败");
+ } catch (Exception ignored) {
+ }
+ }
+ return result.toString();
+ }
+
+ @JavascriptInterface
+ public String listFacePeople(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ JSONArray people = new JSONArray(activity.getFacePeopleJson());
+ result.put("ok", true);
+ result.put("people", people);
+ result.put("count", people.length());
+ } catch (Exception error) {
+ try {
+ result.put("ok", false);
+ result.put("people", new JSONArray());
+ result.put("error", "读取人员列表失败");
+ } catch (Exception ignored) {
+ }
+ }
+ return result.toString();
+ }
+
+ @JavascriptInterface
+ public String deleteFacePerson(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ String faceId = obj(json).optString("faceId", "").trim();
+ if (faceId.length() == 0) {
+ throw new IllegalArgumentException("人员编号不能为空");
+ }
+ boolean removed = activity.removeFacePerson(faceId);
+ result.put("ok", removed);
+ result.put("count", activity.getEnrolledFaceCount());
+ if (!removed) {
+ result.put("error", "未找到该人员");
+ }
+ } catch (Exception error) {
+ try {
+ result.put("ok", false);
+ result.put("error", error.getMessage() == null ? "删除人员失败" : error.getMessage());
+ } catch (Exception ignored) {
+ }
+ }
+ return result.toString();
+ }
+
+ @JavascriptInterface
+ public String enrollCurrentFace(String json) {
+ JSONObject result = new JSONObject();
+ try {
+ JSONObject request = obj(json);
+ String employeeNo = request.optString("employeeNo", "").trim();
+ String name = request.optString("name", "").trim();
+ String faceId = request.optString("faceId", "").trim();
+ if (employeeNo.length() == 0 || name.length() == 0) {
+ throw new IllegalArgumentException("请输入工号和姓名");
+ }
+ if (faceId.length() == 0) {
+ faceId = "FACE-" + employeeNo;
+ }
+ activity.enrollCurrentFace(faceId, employeeNo, name);
+ result.put("ok", true);
+ result.put("faceId", faceId);
+ result.put("employeeNo", employeeNo);
+ result.put("name", name);
+ result.put("count", activity.getEnrolledFaceCount());
+ } catch (Exception error) {
+ try {
+ result.put("ok", false);
+ result.put("error", error.getMessage() == null ? "人脸采集失败" : error.getMessage());
+ } catch (Exception ignored) {
+ }
+ }
+ return result.toString();
+ }
+
+ @JavascriptInterface
+ public boolean prepareFaceEnrollment(String json) {
+ try {
+ activity.prepareFaceEnrollment();
+ return true;
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public boolean reportXingyuanDelivery(String json) {
+ try {
+ JSONObject data = obj(json);
+ reporter.reportDelivery(
+ data.optInt("idType", 0),
+ data.optString("idContent", data.optString("employeeNo", "")),
+ data.optInt("port", 0),
+ data.optInt("typeCode", 0),
+ data.optInt("range", 0),
+ data.optDouble("weight", 0),
+ data.optDouble("beforeWeight", 0),
+ data.optDouble("afterWeight", 0),
+ data.optLong("createdAt", System.currentTimeMillis()));
+ return true;
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public void reportDelivery(String json) {
+ try {
+ JSONObject data = obj(json);
+ reporter.reportDelivery(
+ data.optInt("idType", 2),
+ data.optString("idContent", ""),
+ data.optInt("port", 0),
+ data.optInt("garbageType", 18),
+ data.optInt("range", 0),
+ data.optDouble("throwWeight", 0),
+ data.optDouble("beforeWeight", 0),
+ data.optDouble("afterWeight", 0),
+ data.optLong("createdAt", 0));
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public void reportDeviceStatus(String json) {
+ try {
+ JSONObject data = obj(json);
+ reporter.reportDeviceStatus(data);
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public void reportAlarm(String json) {
+ try {
+ JSONObject data = obj(json);
+ JSONArray alarms = data.optJSONArray("alarms");
+ if (alarms != null) {
+ reporter.reportAlarm(alarms);
+ }
+ } catch (Exception ignored) {
+ }
+ }
+
+ @JavascriptInterface
+ public String getLocalBackupPath(String json) {
+ File dir = firstWritableCacheDir();
+ if (dir == null) {
+ return "";
+ }
+ return new File(dir, "xingyuan_zhiling_delivery_backup.csv").getAbsolutePath();
+ }
+
+ @JavascriptInterface
+ public String saveLocalBackup(String json) {
+ try {
+ JSONObject data = obj(json);
+ JSONObject record = data.optJSONObject("record");
+ if (record == null) {
+ return "";
+ }
+ File dir = firstWritableCacheDir();
+ if (dir == null) {
+ return "";
+ }
+ File jsonFile = new File(dir, "xingyuan_zhiling_delivery_backup.json");
+ File jsonlFile = new File(dir, "xingyuan_zhiling_delivery_backup.jsonl");
+ File csvFile = new File(dir, "xingyuan_zhiling_delivery_backup.csv");
+ appendJsonRecord(jsonFile, record);
+ appendLine(jsonlFile, record.toString());
+ boolean needHeader = !csvFile.exists() || csvFile.length() == 0;
+ BufferedWriter writer = new BufferedWriter(new FileWriter(csvFile, true));
+ try {
+ if (needHeader) {
+ writer.write("设备编号,设备唯一识别码IMEI,设备名称,投放时间,员工姓名,员工工号,机台号,废料名,废料名ID,投放前重量,投放后重量,本次投放重量,投口,上传状态");
+ writer.newLine();
+ }
+ writer.write(csv(record.optString("deviceNo"))
+ + "," + csv(record.optString("deviceUniqueId"))
+ + "," + csv(record.optString("deviceName"))
+ + "," + csv(record.optString("deliveryTime", record.optString("time")))
+ + "," + csv(record.optString("person"))
+ + "," + csv(record.optString("employeeNo"))
+ + "," + csv(record.optString("machineNo"))
+ + "," + csv(record.optString("garbageTypeName"))
+ + "," + csv(record.optString("typeId"))
+ + "," + csv(record.optString("beforeWeight"))
+ + "," + csv(record.optString("afterWeight"))
+ + "," + csv(record.optString("weight"))
+ + "," + csv(record.optString("port"))
+ + "," + csv(record.optString("uploadStatus")));
+ writer.newLine();
+ } finally {
+ writer.close();
+ }
+ return csvFile.getAbsolutePath();
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ @JavascriptInterface
+ public String exportLogs(String json) {
+ try {
+ JSONObject data = obj(json);
+ JSONArray logs = data.optJSONArray("logs");
+ File dir = firstWritableLogDir();
+ if (dir == null) {
+ return "";
+ }
+ String suffix = String.valueOf(data.optLong("exportedAt", System.currentTimeMillis()));
+ File jsonFile = new File(dir, "xingyuan_zhiling_logs_" + suffix + ".json");
+ File textFile = new File(dir, "xingyuan_zhiling_logs_" + suffix + ".txt");
+ BufferedWriter jsonWriter = new BufferedWriter(new FileWriter(jsonFile, false));
+ try {
+ jsonWriter.write(data.toString());
+ jsonWriter.newLine();
+ } finally {
+ jsonWriter.close();
+ }
+ BufferedWriter writer = new BufferedWriter(new FileWriter(textFile, false));
+ try {
+ writer.write("导出时间: " + data.optString("exportedAtText"));
+ writer.newLine();
+ writer.write("应用版本: " + data.optString("appVersion"));
+ writer.newLine();
+ writer.write("设备编号: " + data.optString("deviceNo"));
+ writer.newLine();
+ writer.write("终端号/IMEI: " + data.optString("terminalId"));
+ writer.newLine();
+ writer.write("--------------------------------------------------");
+ writer.newLine();
+ if (logs != null) {
+ for (int i = 0; i < logs.length(); i += 1) {
+ JSONObject item = logs.optJSONObject(i);
+ if (item == null) {
+ continue;
+ }
+ writer.write(item.optLong("at") + " [" + item.optString("kind") + "] " + item.optString("message"));
+ String detail = item.optString("detail");
+ if (detail != null && detail.length() > 0) {
+ writer.write(" - " + detail);
+ }
+ writer.newLine();
+ }
+ }
+ } finally {
+ writer.close();
+ }
+ return textFile.getAbsolutePath();
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ @JavascriptInterface
+ public String readLocalBackups(String json) {
+ for (File file : backupFileCandidates()) {
+ if (file == null || !file.exists() || !file.isFile()) {
+ continue;
+ }
+ JSONArray records = readBackupFile(file);
+ if (records != null) {
+ return records.toString();
+ }
+ }
+ return new JSONArray().toString();
+ }
+
+ @JavascriptInterface
+ public String getCaptureImageDir(String json) {
+ File dir = firstWritableCaptureImageDir();
+ if (dir == null) {
+ return "";
+ }
+ return dir.getAbsolutePath();
+ }
+
+ @JavascriptInterface
+ public String prepareCaptureImage(String json) {
+ try {
+ JSONObject data = obj(json);
+ File dir = firstWritableCaptureImageDir();
+ if (dir == null) {
+ return "";
+ }
+ String fileName = captureFileName(data);
+ JSONObject result = new JSONObject();
+ result.put("directory", dir.getAbsolutePath());
+ result.put("fileName", fileName);
+ result.put("path", new File(dir, fileName).getAbsolutePath());
+ return result.toString();
+ } catch (Exception ignored) {
+ return "";
+ }
+ }
+
+ private JSONObject obj(String json) throws Exception {
+ if (json == null || json.length() == 0) {
+ return new JSONObject();
+ }
+ return new JSONObject(json);
+ }
+
+ private File firstWritableCacheDir() {
+ return firstWritableDir(new File[] {
+ new File(Environment.getExternalStorageDirectory(), "smart/cache"),
+ appExternalDirectory("smart/cache")
+ });
+ }
+
+ private File firstWritableLogDir() {
+ return firstWritableDir(new File[] {
+ new File(Environment.getExternalStorageDirectory(), "smart/logs"),
+ appExternalDirectory("smart/logs")
+ });
+ }
+
+ private File firstWritableCaptureImageDir() {
+ return firstWritableDir(new File[] {
+ new File(Environment.getExternalStorageDirectory(), "smart/Image"),
+ appExternalDirectory("smart/Image")
+ });
+ }
+
+ private File appExternalDirectory(String child) {
+ File base = activity.getExternalFilesDir(null);
+ return base == null ? null : new File(base, child);
+ }
+
+ private File firstWritableDir(File[] candidates) {
+ for (File dir : candidates) {
+ if (dir == null) {
+ continue;
+ }
+ try {
+ if (!dir.exists() && !dir.mkdirs()) {
+ continue;
+ }
+ if (dir.isDirectory() && dir.canWrite()) {
+ return dir;
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ return null;
+ }
+
+ private String captureFileName(JSONObject data) {
+ String fileName = data.optString("fileName", "").trim();
+ long createdAt = data.optLong("createdAt", System.currentTimeMillis());
+ int port = data.optInt("port", 0);
+ int camera = data.optInt("camera", 0);
+ if (fileName.length() == 0) {
+ fileName = "IMG_" + createdAt + "_P" + port + "_C" + camera + ".jpg";
+ }
+ fileName = fileName.replaceAll("[^A-Za-z0-9._-]", "_");
+ if (fileName.length() == 0) {
+ fileName = "IMG_" + System.currentTimeMillis() + ".jpg";
+ }
+ String lower = fileName.toLowerCase(Locale.ROOT);
+ if (!lower.endsWith(".jpg") && !lower.endsWith(".jpeg") && !lower.endsWith(".png")) {
+ fileName += ".jpg";
+ }
+ return fileName;
+ }
+
+ private File[] backupFileCandidates() {
+ return new File[] {
+ new File(Environment.getExternalStorageDirectory(), "smart/cache/xingyuan_zhiling_delivery_backup.json"),
+ appExternalDirectory("smart/cache/xingyuan_zhiling_delivery_backup.json"),
+ new File(Environment.getExternalStorageDirectory(), "smart/cache/xingyuan_zhiling_delivery_backup.jsonl"),
+ appExternalDirectory("smart/cache/xingyuan_zhiling_delivery_backup.jsonl"),
+ new File(Environment.getExternalStorageDirectory(), "smart/xingyuan_zhiling_delivery_backup.jsonl"),
+ appExternalDirectory("smart/xingyuan_zhiling_delivery_backup.jsonl")
+ };
+ }
+
+ private JSONArray readBackupFile(File file) {
+ JSONArray records = new JSONArray();
+ BufferedReader reader = null;
+ try {
+ if (file.getName().toLowerCase(Locale.ROOT).endsWith(".json")) {
+ JSONArray saved = new JSONArray(readText(file));
+ for (int i = 0; i < saved.length(); i += 1) {
+ JSONObject item = saved.optJSONObject(i);
+ if (item != null) {
+ records.put(item);
+ }
+ }
+ return records;
+ }
+ reader = new BufferedReader(new FileReader(file));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ line = line.trim();
+ if (line.length() == 0) {
+ continue;
+ }
+ records.put(new JSONObject(line));
+ }
+ return records;
+ } catch (Exception ignored) {
+ return null;
+ } finally {
+ if (reader != null) {
+ try {
+ reader.close();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ }
+
+ private void appendJsonRecord(File file, JSONObject record) throws Exception {
+ JSONArray records = new JSONArray();
+ if (file.exists() && file.length() > 0) {
+ String text = readText(file).trim();
+ if (text.length() > 0) {
+ try {
+ records = new JSONArray(text);
+ } catch (Exception ignored) {
+ records = new JSONArray();
+ }
+ }
+ }
+ records.put(record);
+ BufferedWriter writer = new BufferedWriter(new FileWriter(file, false));
+ try {
+ writer.write(records.toString());
+ writer.newLine();
+ } finally {
+ writer.close();
+ }
+ }
+
+ private String readText(File file) throws Exception {
+ StringBuilder builder = new StringBuilder();
+ BufferedReader reader = new BufferedReader(new FileReader(file));
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ builder.append(line);
+ }
+ } finally {
+ reader.close();
+ }
+ return builder.toString();
+ }
+
+ private void appendLine(File file, String line) throws Exception {
+ BufferedWriter writer = new BufferedWriter(new FileWriter(file, true));
+ try {
+ writer.write(line);
+ writer.newLine();
+ } finally {
+ writer.close();
+ }
+ }
+
+ private String csv(String value) {
+ String text = value == null ? "" : value;
+ return "\"" + text.replace("\"", "\"\"") + "\"";
+ }
+
+ private boolean ping(String host, int timeoutSeconds) {
+ int timeout = Math.max(1, Math.min(timeoutSeconds, 10));
+ String seconds = String.valueOf(timeout);
+ if (runPing(new String[] { "/system/bin/ping", "-c", "1", "-W", seconds, host })) {
+ return true;
+ }
+ return runPing(new String[] { "ping", "-c", "1", "-W", seconds, host });
+ }
+
+ private boolean connectTcp(String host, int port, int timeoutSeconds) {
+ Socket socket = null;
+ int timeoutMs = Math.max(1, Math.min(timeoutSeconds, 10)) * 1000;
+ try {
+ // 网络可用性以业务 TCP 可连为准,避免 ICMP ping 被系统或网络策略拦截后误报。
+ socket = new Socket();
+ socket.connect(new InetSocketAddress(host, port), timeoutMs);
+ return true;
+ } catch (Exception ignored) {
+ return false;
+ } finally {
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ }
+
+ private boolean runPing(String[] command) {
+ Process process = null;
+ try {
+ process = Runtime.getRuntime().exec(command);
+ return process.waitFor() == 0;
+ } catch (Exception ignored) {
+ return false;
+ } finally {
+ if (process != null) {
+ process.destroy();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouConnectionCheckTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouConnectionCheckTask.java
new file mode 100644
index 0000000..1a170e9
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouConnectionCheckTask.java
@@ -0,0 +1,22 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouConnectionCheckTask implements Runnable {
+ private final WebView webView;
+ private final JSONObject payload;
+
+ ZhitouConnectionCheckTask(WebView webView, JSONObject payload) {
+ this.webView = webView;
+ this.payload = payload;
+ }
+
+ @Override
+ public void run() {
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onConnectionCheck("
+ + (payload == null ? "{}" : payload.toString()) + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouDeviceStatusTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouDeviceStatusTask.java
new file mode 100644
index 0000000..e280807
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouDeviceStatusTask.java
@@ -0,0 +1,14 @@
+package com.xingyuan.zhiling;
+
+final class ZhitouDeviceStatusTask implements Runnable {
+ private final ZhitouTcpReporter reporter;
+
+ ZhitouDeviceStatusTask(ZhitouTcpReporter reporter) {
+ this.reporter = reporter;
+ }
+
+ @Override
+ public void run() {
+ reporter.sendDeviceStatusNow();
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouFaceRecognizedTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouFaceRecognizedTask.java
new file mode 100644
index 0000000..0dc9a47
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouFaceRecognizedTask.java
@@ -0,0 +1,33 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouFaceRecognizedTask implements Runnable {
+ private final WebView webView;
+ private final FaceFeatureStore.Match match;
+
+ ZhitouFaceRecognizedTask(WebView webView, FaceFeatureStore.Match match) {
+ this.webView = webView;
+ this.match = match;
+ }
+
+ @Override
+ public void run() {
+ if (webView == null || match == null) {
+ return;
+ }
+ try {
+ JSONObject payload = new JSONObject();
+ payload.put("faceId", match.faceId);
+ payload.put("employeeNo", match.employeeNo);
+ payload.put("name", match.name);
+ payload.put("similarity", match.similarity);
+ webView.evaluateJavascript(
+ "window.XingyuanSerial&&window.XingyuanSerial.onFaceRecognized(" + payload.toString() + ");",
+ null);
+ } catch (Exception ignored) {
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouFaceStatusTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouFaceStatusTask.java
new file mode 100644
index 0000000..55bbd79
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouFaceStatusTask.java
@@ -0,0 +1,36 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouFaceStatusTask implements Runnable {
+ private final WebView webView;
+ private final String status;
+ private final String message;
+ private final int enrolledCount;
+
+ ZhitouFaceStatusTask(WebView webView, String status, String message, int enrolledCount) {
+ this.webView = webView;
+ this.status = status;
+ this.message = message;
+ this.enrolledCount = enrolledCount;
+ }
+
+ @Override
+ public void run() {
+ if (webView == null) {
+ return;
+ }
+ try {
+ JSONObject payload = new JSONObject();
+ payload.put("status", status);
+ payload.put("message", message);
+ payload.put("enrolledCount", enrolledCount);
+ webView.evaluateJavascript(
+ "window.XingyuanSerial&&window.XingyuanSerial.onFaceStatus(" + payload.toString() + ");",
+ null);
+ } catch (Exception ignored) {
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouHeartbeatTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouHeartbeatTask.java
new file mode 100644
index 0000000..be18678
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouHeartbeatTask.java
@@ -0,0 +1,14 @@
+package com.xingyuan.zhiling;
+
+final class ZhitouHeartbeatTask implements Runnable {
+ private final ZhitouTcpReporter reporter;
+
+ ZhitouHeartbeatTask(ZhitouTcpReporter reporter) {
+ this.reporter = reporter;
+ }
+
+ @Override
+ public void run() {
+ reporter.sendHeartbeatNow();
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouMemberInfo.java b/app/src/main/java/com/zhitou/terminal/ZhitouMemberInfo.java
new file mode 100644
index 0000000..d4fc644
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouMemberInfo.java
@@ -0,0 +1,40 @@
+package com.xingyuan.zhiling;
+
+import org.json.JSONObject;
+
+final class ZhitouMemberInfo {
+ int verifyStatus;
+ String memberId = "";
+ String memberCode = "";
+ String name = "";
+ String balance = "";
+ int resiCount;
+ String error = "";
+
+ boolean isVerified() {
+ return verifyStatus == 1;
+ }
+
+ String toJson() {
+ JSONObject data = new JSONObject();
+ try {
+ data.put("ok", isVerified());
+ data.put("verifyStatus", verifyStatus);
+ data.put("memberId", memberId == null ? "" : memberId);
+ data.put("memberCode", memberCode == null ? "" : memberCode);
+ data.put("name", name == null ? "" : name);
+ data.put("balance", balance == null ? "" : balance);
+ data.put("resiCount", resiCount);
+ data.put("error", error == null ? "" : error);
+ } catch (Exception ignored) {
+ }
+ return data.toString();
+ }
+
+ static ZhitouMemberInfo failed(String error) {
+ ZhitouMemberInfo info = new ZhitouMemberInfo();
+ info.verifyStatus = 0;
+ info.error = error == null ? "" : error;
+ return info;
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouNativeLogTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouNativeLogTask.java
new file mode 100644
index 0000000..7bd7a58
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouNativeLogTask.java
@@ -0,0 +1,24 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouNativeLogTask implements Runnable {
+ private final WebView webView;
+ private final String kind;
+ private final String message;
+
+ ZhitouNativeLogTask(WebView webView, String kind, String message) {
+ this.webView = webView;
+ this.kind = kind == null ? "传输" : kind;
+ this.message = message == null ? "" : message;
+ }
+
+ @Override
+ public void run() {
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onNativeLog("
+ + JSONObject.quote(kind) + "," + JSONObject.quote(message) + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouOpenFileManagerTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouOpenFileManagerTask.java
new file mode 100644
index 0000000..14c784a
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouOpenFileManagerTask.java
@@ -0,0 +1,14 @@
+package com.xingyuan.zhiling;
+
+final class ZhitouOpenFileManagerTask implements Runnable {
+ private final MainActivity activity;
+
+ ZhitouOpenFileManagerTask(MainActivity activity) {
+ this.activity = activity;
+ }
+
+ @Override
+ public void run() {
+ activity.openFileManager();
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouOpenSystemSettingsTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouOpenSystemSettingsTask.java
new file mode 100644
index 0000000..94d4374
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouOpenSystemSettingsTask.java
@@ -0,0 +1,14 @@
+package com.xingyuan.zhiling;
+
+final class ZhitouOpenSystemSettingsTask implements Runnable {
+ private final MainActivity activity;
+
+ ZhitouOpenSystemSettingsTask(MainActivity activity) {
+ this.activity = activity;
+ }
+
+ @Override
+ public void run() {
+ activity.openSystemSettings();
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSendFrameTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSendFrameTask.java
new file mode 100644
index 0000000..bbcd1cc
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSendFrameTask.java
@@ -0,0 +1,18 @@
+package com.xingyuan.zhiling;
+
+final class ZhitouSendFrameTask implements Runnable {
+ private final ZhitouTcpReporter reporter;
+ private final int orderCode;
+ private final byte[] body;
+
+ ZhitouSendFrameTask(ZhitouTcpReporter reporter, int orderCode, byte[] body) {
+ this.reporter = reporter;
+ this.orderCode = orderCode;
+ this.body = body.clone();
+ }
+
+ @Override
+ public void run() {
+ reporter.sendFrameNow(orderCode, body);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialDeviceStatusTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialDeviceStatusTask.java
new file mode 100644
index 0000000..0d22588
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialDeviceStatusTask.java
@@ -0,0 +1,32 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialDeviceStatusTask implements Runnable {
+ private final WebView webView;
+ private final ZhitouSerialPortManager.DeviceStatusItem item;
+
+ ZhitouSerialDeviceStatusTask(WebView webView, ZhitouSerialPortManager.DeviceStatusItem item) {
+ this.webView = webView;
+ this.item = item;
+ }
+
+ @Override
+ public void run() {
+ JSONObject payload = new JSONObject();
+ try {
+ payload.put("port", item.port);
+ if (!Double.isNaN(item.temperature)) {
+ payload.put("temperature", item.temperature);
+ }
+ if (item.range >= 0) {
+ payload.put("range", item.range);
+ }
+ } catch (Exception ignored) {
+ }
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onDeviceStatus(" + payload.toString() + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialIdentityTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialIdentityTask.java
new file mode 100644
index 0000000..d2116a9
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialIdentityTask.java
@@ -0,0 +1,24 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialIdentityTask implements Runnable {
+ private final WebView webView;
+ private final int type;
+ private final String identity;
+
+ ZhitouSerialIdentityTask(WebView webView, int type, String identity) {
+ this.webView = webView;
+ this.type = type;
+ this.identity = identity == null ? "" : identity;
+ }
+
+ @Override
+ public void run() {
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onIdentity("
+ + type + "," + JSONObject.quote(identity) + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialImeiRequestTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialImeiRequestTask.java
new file mode 100644
index 0000000..d157555
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialImeiRequestTask.java
@@ -0,0 +1,24 @@
+package com.xingyuan.zhiling;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialImeiRequestTask implements Runnable {
+ private final ZhitouSerialPortManager serialPortManager;
+ private final String json;
+
+ ZhitouSerialImeiRequestTask(ZhitouSerialPortManager serialPortManager, String json) {
+ this.serialPortManager = serialPortManager;
+ this.json = json;
+ }
+
+ @Override
+ public void run() {
+ try {
+ JSONObject data = json == null || json.length() == 0 ? new JSONObject() : new JSONObject(json);
+ serialPortManager.requestImei(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600));
+ } catch (Exception ignored) {
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialImeiTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialImeiTask.java
new file mode 100644
index 0000000..16c6805
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialImeiTask.java
@@ -0,0 +1,22 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialImeiTask implements Runnable {
+ private final WebView webView;
+ private final String imei;
+
+ ZhitouSerialImeiTask(WebView webView, String imei) {
+ this.webView = webView;
+ this.imei = imei == null ? "" : imei;
+ }
+
+ @Override
+ public void run() {
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onImei("
+ + JSONObject.quote(imei) + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialPortManager.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialPortManager.java
new file mode 100644
index 0000000..e9ba98d
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialPortManager.java
@@ -0,0 +1,743 @@
+package com.xingyuan.zhiling;
+
+import android.util.Log;
+
+import java.io.File;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+
+import android_serialport_api.SerialPort;
+
+final class ZhitouSerialPortManager {
+ private static final class SerialWriteTask implements Runnable {
+ private final ZhitouSerialPortManager manager;
+ private final byte[] frame;
+ private final long generation;
+
+ SerialWriteTask(ZhitouSerialPortManager manager, byte[] frame, long generation) {
+ this.manager = manager;
+ this.frame = frame;
+ this.generation = generation;
+ }
+
+ @Override
+ public void run() {
+ manager.writeFrame(frame, generation);
+ }
+ }
+
+ interface Listener {
+ void onImei(String imei);
+
+ void onVersion(String version);
+
+ void onIdentity(int type, String identity);
+
+ void onThrowWeight(ThrowWeightItem item);
+
+ void onDeviceStatus(DeviceStatusItem item);
+ }
+
+ static final class ThrowWeightItem {
+ final int port;
+ final double weight;
+ final double beforeWeight;
+ final double afterWeight;
+ final int range;
+ final boolean queryResponse;
+
+ ThrowWeightItem(int port, double weight, double beforeWeight, double afterWeight, int range,
+ boolean queryResponse) {
+ this.port = port;
+ this.weight = weight;
+ this.beforeWeight = beforeWeight;
+ this.afterWeight = afterWeight;
+ this.range = range;
+ this.queryResponse = queryResponse;
+ }
+ }
+
+ static final class DeviceStatusItem {
+ final int port;
+ final double temperature;
+ final int range;
+
+ DeviceStatusItem(int port, double temperature, int range) {
+ this.port = port;
+ this.temperature = temperature;
+ this.range = range;
+ }
+ }
+
+ private static final String TAG = "ZhitouSerial";
+ private static final String DEFAULT_PATH = "/dev/ttyS4";
+ private static final int DEFAULT_BAUD_RATE = 9600;
+ private static final long SERIAL_CHECK_WAIT_MS = 1800;
+ private static final long WRITE_GAP_MS = 40;
+ private static final Charset GBK = Charset.forName("GBK");
+ private static final byte ORDER_MEMBER_VERIFY = 0x01;
+ private static final byte ORDER_OPEN_THROW_DOOR = 0x02;
+ private static final byte ORDER_CLOSE_THROW_DOOR = 0x03;
+ private static final byte ORDER_OPEN_RECEIVE_DOOR = 0x04;
+ private static final byte ORDER_QUERY_WEIGHT = 0x20;
+ private static final byte ORDER_QUERY_FILL = 0x21;
+ private static final byte ORDER_QUERY_TEMPERATURE = 0x22;
+ private static final byte ORDER_DEVICE_VERSION = 0x30;
+ private static final byte ORDER_DEVICE_IMEI = 0x31;
+ private static final byte ORDER_CLOSE_TIME = 0x60;
+ private static final byte ORDER_THROW_WEIGHT = 0x61;
+ private static final byte ORDER_DEVICE_STATUS = 0x62;
+ private static final byte ORDER_GPS = 0x67;
+ private static final byte ORDER_POWER_ON = (byte) 0x90;
+
+ private final Listener listener;
+ private final Object ioLock = new Object();
+ private final Object imeiLock = new Object();
+ private final Object versionLock = new Object();
+ private final byte[] receiveBuffer = new byte[4096];
+ private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor();
+
+ private SerialPort serialPort;
+ private InputStream inputStream;
+ private OutputStream outputStream;
+ private Thread readThread;
+ private String currentPath = DEFAULT_PATH;
+ private int currentBaudRate = DEFAULT_BAUD_RATE;
+ private int receiveLength;
+ private volatile boolean running;
+ private volatile String lastImei = "";
+ private volatile String lastVersion = "";
+ private volatile long lastValidFrameAt;
+ private volatile long connectionGeneration;
+ private volatile boolean destroyed;
+
+ ZhitouSerialPortManager(Listener listener) {
+ this.listener = listener;
+ }
+
+ boolean checkSerial(String path, int baudRate) {
+ long baseline;
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ if (hasRecentValidFrame()) {
+ return true;
+ }
+ baseline = lastValidFrameAt;
+ // 启动自检必须等到下位机真实回包,不能只用“串口能打开”判断在线。
+ if (!sendFrame(ORDER_DEVICE_IMEI, new byte[0])) {
+ return false;
+ }
+ return waitForValidFrameAfter(baseline, SERIAL_CHECK_WAIT_MS);
+ }
+
+ String requestImei(String path, int baudRate) {
+ if (!ensureOpen(path, baudRate)) {
+ return "";
+ }
+ synchronized (imeiLock) {
+ lastImei = "";
+ }
+ sendFrame(ORDER_DEVICE_IMEI, new byte[0]);
+ return waitForValue(imeiLock, true);
+ }
+
+ String requestVersion(String path, int baudRate) {
+ if (!ensureOpen(path, baudRate)) {
+ return "";
+ }
+ synchronized (versionLock) {
+ lastVersion = "";
+ }
+ sendFrame(ORDER_DEVICE_VERSION, new byte[0]);
+ return waitForValue(versionLock, false);
+ }
+
+ boolean openThrowDoor(String path, int baudRate, int port) {
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ return sendFrame(ORDER_OPEN_THROW_DOOR, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
+ }
+
+ boolean closeThrowDoor(String path, int baudRate) {
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ return sendFrame(ORDER_CLOSE_THROW_DOOR, new byte[] { 0x01 });
+ }
+
+ boolean openReceiveDoor(String path, int baudRate, int doorIndex) {
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ return sendFrame(ORDER_OPEN_RECEIVE_DOOR, new byte[] { 0x01, (byte) clamp(doorIndex, 0, 255) });
+ }
+
+ boolean queryWeight(String path, int baudRate, int port) {
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ return sendFrame(ORDER_QUERY_WEIGHT, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
+ }
+
+ boolean queryFill(String path, int baudRate, int port) {
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ return sendFrame(ORDER_QUERY_FILL, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
+ }
+
+ boolean queryTemperature(String path, int baudRate, int port) {
+ if (!ensureOpen(path, baudRate)) {
+ return false;
+ }
+ return sendFrame(ORDER_QUERY_TEMPERATURE, new byte[] { 0x01, (byte) clamp(port, 0, 255) });
+ }
+
+ void close() {
+ synchronized (ioLock) {
+ connectionGeneration += 1;
+ running = false;
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (Exception ignored) {
+ }
+ }
+ inputStream = null;
+ if (outputStream != null) {
+ try {
+ outputStream.close();
+ } catch (Exception ignored) {
+ }
+ }
+ outputStream = null;
+ if (serialPort != null) {
+ try {
+ serialPort.close();
+ } catch (Exception ignored) {
+ }
+ }
+ serialPort = null;
+ readThread = null;
+ receiveLength = 0;
+ lastValidFrameAt = 0;
+ }
+ }
+
+ void destroy() {
+ destroyed = true;
+ close();
+ writeExecutor.shutdownNow();
+ }
+
+ private boolean ensureOpen(String path, int baudRate) {
+ if (destroyed) {
+ return false;
+ }
+ String nextPath = normalizePath(path);
+ int nextBaudRate = baudRate > 0 ? baudRate : DEFAULT_BAUD_RATE;
+ synchronized (ioLock) {
+ if (serialPort != null && outputStream != null && inputStream != null
+ && nextPath.equals(currentPath) && nextBaudRate == currentBaudRate) {
+ return true;
+ }
+ close();
+ currentPath = nextPath;
+ currentBaudRate = nextBaudRate;
+ try {
+ serialPort = new SerialPort(new File(currentPath), currentBaudRate, 0);
+ inputStream = serialPort.getInputStream();
+ outputStream = serialPort.getOutputStream();
+ running = true;
+ long readGeneration = connectionGeneration;
+ readThread = new Thread(
+ new ZhitouSerialReadLoop(this, readGeneration),
+ "zhitou-serial-read");
+ readThread.setDaemon(true);
+ readThread.start();
+ Log.i(TAG, "opened " + currentPath + " baud=" + currentBaudRate);
+ return true;
+ } catch (Throwable error) {
+ Log.e(TAG, "open failed path=" + currentPath + " baud=" + currentBaudRate, error);
+ close();
+ return false;
+ }
+ }
+ }
+
+ private boolean sendFrame(byte orderCode, byte[] body) {
+ final byte[] frame = buildFrame(orderCode, body == null ? new byte[0] : body);
+ final long generation;
+ synchronized (ioLock) {
+ if (destroyed || outputStream == null) {
+ return false;
+ }
+ generation = connectionGeneration;
+ }
+ try {
+ writeExecutor.execute(new SerialWriteTask(this, frame, generation));
+ return true;
+ } catch (RejectedExecutionException error) {
+ Log.w(TAG, "send rejected because serial manager is shutting down");
+ return false;
+ }
+ }
+
+ private void writeFrame(byte[] frame, long generation) {
+ synchronized (ioLock) {
+ if (destroyed || generation != connectionGeneration || outputStream == null) {
+ return;
+ }
+ try {
+ outputStream.write(frame);
+ outputStream.flush();
+ Log.i(TAG, "send " + toHex(frame));
+ } catch (Exception error) {
+ Log.e(TAG, "send failed " + toHex(frame), error);
+ close();
+ return;
+ }
+ }
+ try {
+ Thread.sleep(WRITE_GAP_MS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private byte[] buildFrame(byte orderCode, byte[] body) {
+ byte[] result = new byte[body.length + 10];
+ int index = 0;
+ result[index++] = (byte) 0xfe;
+ result[index++] = (byte) 0xfe;
+ result[index++] = 0x68;
+ result[index++] = 0x00;
+ result[index++] = 0x68;
+ result[index++] = orderCode;
+ result[index++] = (byte) ((body.length >> 8) & 0xff);
+ result[index++] = (byte) (body.length & 0xff);
+ System.arraycopy(body, 0, result, index, body.length);
+ index += body.length;
+ result[index++] = checksum(orderCode, body);
+ result[index] = 0x16;
+ return result;
+ }
+
+ private byte checksum(byte orderCode, byte[] body) {
+ int sum = 0x68 + 0x00 + 0x68 + unsigned(orderCode);
+ sum += (body.length >> 8) & 0xff;
+ sum += body.length & 0xff;
+ for (byte b : body) {
+ sum += unsigned(b);
+ }
+ return (byte) sum;
+ }
+
+ private String waitForValue(Object lock, boolean imei) {
+ long deadline = System.currentTimeMillis() + 2200;
+ synchronized (lock) {
+ while ((imei ? lastImei : lastVersion).length() == 0) {
+ long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ break;
+ }
+ try {
+ lock.wait(remaining);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ return imei ? lastImei : lastVersion;
+ }
+ }
+
+ boolean isRunning(long generation) {
+ return running && generation == connectionGeneration;
+ }
+
+ void readSerialOnce(long generation) {
+ if (!isRunning(generation)) {
+ return;
+ }
+ InputStream stream = inputStream;
+ if (stream == null) {
+ return;
+ }
+ try {
+ byte[] chunk = new byte[128];
+ int size = stream.read(chunk);
+ if (size > 0 && isRunning(generation)) {
+ appendReceived(chunk, size);
+ } else if (size < 0) {
+ // EOF 表示本次连接已经失效,必须退出读循环,避免空转占满 CPU。
+ closeIfCurrent(generation);
+ }
+ } catch (Exception error) {
+ if (isRunning(generation)) {
+ Log.e(TAG, "read failed", error);
+ closeIfCurrent(generation);
+ }
+ }
+ }
+
+ private void closeIfCurrent(long generation) {
+ synchronized (ioLock) {
+ if (generation != connectionGeneration) {
+ return;
+ }
+ // 连接代次用于隔离旧读线程,避免快速重连后两个线程同时读取新串口。
+ close();
+ }
+ }
+
+ private void appendReceived(byte[] chunk, int size) {
+ synchronized (receiveBuffer) {
+ if (size > receiveBuffer.length) {
+ size = receiveBuffer.length;
+ }
+ if (receiveLength + size > receiveBuffer.length) {
+ receiveLength = 0;
+ }
+ System.arraycopy(chunk, 0, receiveBuffer, receiveLength, size);
+ receiveLength += size;
+ Log.i(TAG, "recv " + toHex(chunk, size));
+ parseFrames();
+ }
+ }
+
+ private void parseFrames() {
+ int offset = 0;
+ while (true) {
+ int header = findHeader(offset);
+ if (header < 0) {
+ retainPartialHeader();
+ return;
+ }
+ if (header > 0) {
+ shiftLeft(header);
+ header = 0;
+ }
+ if (receiveLength < 8) {
+ return;
+ }
+ int length = (unsigned(receiveBuffer[4]) << 8) | unsigned(receiveBuffer[5]);
+ int total = 8 + length;
+ if (total > receiveBuffer.length) {
+ shiftLeft(1);
+ continue;
+ }
+ if (receiveLength < total) {
+ return;
+ }
+ if (receiveBuffer[total - 1] != 0x16) {
+ shiftLeft(1);
+ continue;
+ }
+ byte order = receiveBuffer[3];
+ byte[] body = new byte[length];
+ System.arraycopy(receiveBuffer, 6, body, 0, length);
+ byte actualChecksum = receiveBuffer[6 + length];
+ byte expectedChecksum = checksum(order, body);
+ if (actualChecksum == expectedChecksum) {
+ lastValidFrameAt = System.currentTimeMillis();
+ handleFrame(order, body);
+ } else {
+ Log.w(TAG, "checksum mismatch order=" + unsigned(order)
+ + " expected=" + unsigned(expectedChecksum)
+ + " actual=" + unsigned(actualChecksum));
+ }
+ shiftLeft(total);
+ }
+ }
+
+ private int findHeader(int offset) {
+ for (int i = offset; i + 2 < receiveLength; i += 1) {
+ if (receiveBuffer[i] == 0x68 && receiveBuffer[i + 1] == 0x00 && receiveBuffer[i + 2] == 0x68) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void retainPartialHeader() {
+ int keep = 0;
+ if (receiveLength >= 2
+ && receiveBuffer[receiveLength - 2] == 0x68
+ && receiveBuffer[receiveLength - 1] == 0x00) {
+ keep = 2;
+ } else if (receiveLength >= 1 && receiveBuffer[receiveLength - 1] == 0x68) {
+ keep = 1;
+ }
+ if (keep > 0) {
+ System.arraycopy(receiveBuffer, receiveLength - keep, receiveBuffer, 0, keep);
+ }
+ receiveLength = keep;
+ }
+
+ private void shiftLeft(int count) {
+ if (count <= 0) {
+ return;
+ }
+ if (count >= receiveLength) {
+ receiveLength = 0;
+ return;
+ }
+ System.arraycopy(receiveBuffer, count, receiveBuffer, 0, receiveLength - count);
+ receiveLength -= count;
+ }
+
+ private void handleFrame(byte order, byte[] body) {
+ int code = unsigned(order);
+ if (code == unsigned(ORDER_MEMBER_VERIFY)) {
+ handleIdentity(body);
+ sendFrame(ORDER_MEMBER_VERIFY, new byte[0]);
+ } else if (code == unsigned(ORDER_DEVICE_IMEI)) {
+ handleImei(body);
+ } else if (code == unsigned(ORDER_DEVICE_VERSION)) {
+ handleVersion(body);
+ } else if (code == unsigned(ORDER_OPEN_RECEIVE_DOOR)) {
+ Log.i(TAG, "receive door command result");
+ } else if (code == unsigned(ORDER_QUERY_WEIGHT)) {
+ handleQueryWeight(body);
+ } else if (code == unsigned(ORDER_QUERY_FILL)) {
+ handleQueryFill(body);
+ } else if (code == unsigned(ORDER_QUERY_TEMPERATURE)) {
+ handleQueryTemperature(body);
+ } else if (code == unsigned(ORDER_THROW_WEIGHT)) {
+ handleThrowWeight(body);
+ sendFrame(ORDER_THROW_WEIGHT, new byte[0]);
+ } else if (code == unsigned(ORDER_DEVICE_STATUS)) {
+ sendFrame(ORDER_DEVICE_STATUS, new byte[0]);
+ handleDeviceStatus(body);
+ } else if (code == unsigned(ORDER_CLOSE_TIME)) {
+ sendFrame(ORDER_CLOSE_TIME, new byte[0]);
+ } else if (code == unsigned(ORDER_GPS)) {
+ sendFrame(ORDER_GPS, new byte[0]);
+ } else if (code == unsigned(ORDER_POWER_ON)) {
+ sendFrame(ORDER_POWER_ON, new byte[0]);
+ } else {
+ Log.i(TAG, "unhandled order=0x" + Integer.toHexString(code));
+ }
+ }
+
+ private void handleIdentity(byte[] body) {
+ if (body.length < 3) {
+ return;
+ }
+ int version = unsigned(body[0]);
+ if (version != 1) {
+ return;
+ }
+ int type = unsigned(body[1]);
+ int length = Math.min(unsigned(body[2]), body.length - 3);
+ String identity = new String(body, 3, Math.max(length, 0), GBK).trim();
+ if (identity.startsWith("http") && identity.contains("card=")) {
+ identity = identity.substring(identity.indexOf("card=") + 5);
+ }
+ if (identity.matches("(?i)^[0-9a-f]{8}$")) {
+ type = 1;
+ }
+ if (identity.length() > 0 && listener != null) {
+ listener.onIdentity(type, identity);
+ }
+ }
+
+ private void handleImei(byte[] body) {
+ if (body.length < 2) {
+ return;
+ }
+ int length = Math.min(unsigned(body[1]), body.length - 2);
+ String imei = new String(body, 2, Math.max(length, 0), GBK).trim();
+ synchronized (imeiLock) {
+ lastImei = imei;
+ imeiLock.notifyAll();
+ }
+ if (imei.length() > 0 && listener != null) {
+ listener.onImei(imei);
+ }
+ }
+
+ private void handleVersion(byte[] body) {
+ if (body.length < 2) {
+ return;
+ }
+ int length = Math.min(unsigned(body[1]), body.length - 2);
+ String version = new String(body, 2, Math.max(length, 0), GBK).trim();
+ synchronized (versionLock) {
+ lastVersion = version;
+ versionLock.notifyAll();
+ }
+ if (version.length() > 0 && listener != null) {
+ listener.onVersion(version);
+ }
+ }
+
+ private void handleThrowWeight(byte[] body) {
+ if (body.length < 2 || listener == null) {
+ Log.w(TAG, "invalid throw weight body length=" + body.length + " data=" + toHex(body));
+ return;
+ }
+ int count = unsigned(body[1]);
+ int expectedLength = 2 + count * 8;
+ if (body.length < expectedLength) {
+ Log.w(TAG, "invalid throw weight body count=" + count
+ + " expected=" + expectedLength + " actual=" + body.length
+ + " data=" + toHex(body));
+ return;
+ }
+ List items = new ArrayList<>();
+ for (int i = 0; i < count; i += 1) {
+ int base = 2 + i * 8;
+ if (base + 7 >= body.length) {
+ break;
+ }
+ int port = unsigned(body[base]);
+ double weight = centWeight(body, base + 1);
+ double before = centWeight(body, base + 3);
+ double after = centWeight(body, base + 5);
+ int range = unsigned(body[base + 7]);
+ items.add(new ThrowWeightItem(port, weight, before, after, range, false));
+ }
+ for (ThrowWeightItem item : items) {
+ listener.onThrowWeight(item);
+ }
+ }
+
+ private void handleQueryWeight(byte[] body) {
+ if (body.length < 2 || listener == null) {
+ return;
+ }
+ int count = unsigned(body[1]);
+ for (int i = 0; i < count; i += 1) {
+ int base = 2 + i * 3;
+ if (base + 2 >= body.length) {
+ break;
+ }
+ int port = unsigned(body[base]);
+ double weight = centWeight(body, base + 1);
+ listener.onThrowWeight(new ThrowWeightItem(port, weight, weight, weight, -1, true));
+ }
+ }
+
+ private void handleQueryFill(byte[] body) {
+ if (body.length < 2 || listener == null) {
+ return;
+ }
+ int count = unsigned(body[1]);
+ for (int i = 0; i < count; i += 1) {
+ int base = 2 + i * 2;
+ if (base + 1 >= body.length) {
+ break;
+ }
+ listener.onDeviceStatus(new DeviceStatusItem(unsigned(body[base]), Double.NaN, unsigned(body[base + 1])));
+ }
+ }
+
+ private void handleQueryTemperature(byte[] body) {
+ if (body.length < 2 || listener == null) {
+ return;
+ }
+ int count = unsigned(body[1]);
+ for (int i = 0; i < count; i += 1) {
+ int base = 2 + i * 4;
+ if (base + 3 >= body.length) {
+ break;
+ }
+ listener.onDeviceStatus(new DeviceStatusItem(unsigned(body[base]), signedTemperature(body, base + 1), -1));
+ }
+ }
+
+ private void handleDeviceStatus(byte[] body) {
+ if (body.length < 2 || listener == null) {
+ return;
+ }
+ int count = unsigned(body[1]);
+ for (int i = 0; i < count; i += 1) {
+ int base = 2 + i * 5;
+ if (base + 4 >= body.length) {
+ break;
+ }
+ int port = unsigned(body[base]);
+ double temperature = signedTemperature(body, base + 1);
+ int range = unsigned(body[base + 4]);
+ listener.onDeviceStatus(new DeviceStatusItem(port, temperature, range));
+ }
+ }
+
+ private double centWeight(byte[] bytes, int index) {
+ int value = (unsigned(bytes[index]) << 8) | unsigned(bytes[index + 1]);
+ return new BigDecimal(value).divide(new BigDecimal(100)).doubleValue();
+ }
+
+ private double signedTemperature(byte[] bytes, int signIndex) {
+ int value = (unsigned(bytes[signIndex + 1]) << 8) | unsigned(bytes[signIndex + 2]);
+ BigDecimal temperature = new BigDecimal(value).divide(new BigDecimal(10));
+ return bytes[signIndex] == 0x01 ? temperature.negate().doubleValue() : temperature.doubleValue();
+ }
+
+ private String normalizePath(String path) {
+ if (path == null || path.trim().length() == 0) {
+ return DEFAULT_PATH;
+ }
+ return path.trim();
+ }
+
+ private boolean hasRecentValidFrame() {
+ long at = lastValidFrameAt;
+ return at > 0 && System.currentTimeMillis() - at <= 15000;
+ }
+
+ private boolean waitForValidFrameAfter(long baseline, long timeoutMs) {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ if (lastValidFrameAt > baseline) {
+ return true;
+ }
+ try {
+ Thread.sleep(40);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ return false;
+ }
+
+ private static int unsigned(byte value) {
+ return value & 0xff;
+ }
+
+ private static int clamp(int value, int min, int max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ private static String toHex(byte[] data) {
+ return toHex(data, data.length);
+ }
+
+ private static String toHex(byte[] data, int size) {
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < size; i += 1) {
+ if (i > 0) {
+ builder.append(' ');
+ }
+ int value = data[i] & 0xff;
+ if (value < 16) {
+ builder.append('0');
+ }
+ builder.append(Integer.toHexString(value).toUpperCase(Locale.ROOT));
+ }
+ return builder.toString();
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialReadLoop.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialReadLoop.java
new file mode 100644
index 0000000..309f7d0
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialReadLoop.java
@@ -0,0 +1,18 @@
+package com.xingyuan.zhiling;
+
+final class ZhitouSerialReadLoop implements Runnable {
+ private final ZhitouSerialPortManager manager;
+ private final long generation;
+
+ ZhitouSerialReadLoop(ZhitouSerialPortManager manager, long generation) {
+ this.manager = manager;
+ this.generation = generation;
+ }
+
+ @Override
+ public void run() {
+ while (manager.isRunning(generation)) {
+ manager.readSerialOnce(generation);
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialThrowWeightTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialThrowWeightTask.java
new file mode 100644
index 0000000..52a7979
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialThrowWeightTask.java
@@ -0,0 +1,33 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialThrowWeightTask implements Runnable {
+ private final WebView webView;
+ private final ZhitouSerialPortManager.ThrowWeightItem item;
+
+ ZhitouSerialThrowWeightTask(WebView webView, ZhitouSerialPortManager.ThrowWeightItem item) {
+ this.webView = webView;
+ this.item = item;
+ }
+
+ @Override
+ public void run() {
+ JSONObject payload = new JSONObject();
+ try {
+ payload.put("port", item.port);
+ payload.put("weight", item.weight);
+ payload.put("beforeWeight", item.beforeWeight);
+ payload.put("afterWeight", item.afterWeight);
+ payload.put("queryResponse", item.queryResponse);
+ if (item.range >= 0) {
+ payload.put("range", item.range);
+ }
+ } catch (Exception ignored) {
+ }
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onThrowWeight(" + payload.toString() + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialVersionRequestTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialVersionRequestTask.java
new file mode 100644
index 0000000..31c05df
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialVersionRequestTask.java
@@ -0,0 +1,24 @@
+package com.xingyuan.zhiling;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialVersionRequestTask implements Runnable {
+ private final ZhitouSerialPortManager serialPortManager;
+ private final String json;
+
+ ZhitouSerialVersionRequestTask(ZhitouSerialPortManager serialPortManager, String json) {
+ this.serialPortManager = serialPortManager;
+ this.json = json;
+ }
+
+ @Override
+ public void run() {
+ try {
+ JSONObject data = json == null || json.length() == 0 ? new JSONObject() : new JSONObject(json);
+ serialPortManager.requestVersion(
+ data.optString("path", "/dev/ttyS4"),
+ data.optInt("baudRate", 9600));
+ } catch (Exception ignored) {
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouSerialVersionTask.java b/app/src/main/java/com/zhitou/terminal/ZhitouSerialVersionTask.java
new file mode 100644
index 0000000..6097e61
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouSerialVersionTask.java
@@ -0,0 +1,22 @@
+package com.xingyuan.zhiling;
+
+import android.webkit.WebView;
+
+import org.json.JSONObject;
+
+final class ZhitouSerialVersionTask implements Runnable {
+ private final WebView webView;
+ private final String version;
+
+ ZhitouSerialVersionTask(WebView webView, String version) {
+ this.webView = webView;
+ this.version = version == null ? "" : version;
+ }
+
+ @Override
+ public void run() {
+ String js = "window.ZhitouSerial&&window.ZhitouSerial.onVersion("
+ + JSONObject.quote(version) + ");";
+ webView.evaluateJavascript(js, null);
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouTcpReporter.java b/app/src/main/java/com/zhitou/terminal/ZhitouTcpReporter.java
new file mode 100644
index 0000000..16d7e20
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouTcpReporter.java
@@ -0,0 +1,804 @@
+package com.xingyuan.zhiling;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.nio.charset.Charset;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.Locale;
+
+final class ZhitouTcpReporter {
+ interface Listener {
+ void onReporterLog(String kind, String message);
+ }
+
+ private static final String TAG = "ZhitouTcpReporter";
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+ private static final Charset ASCII = Charset.forName("US-ASCII");
+ private static final byte[] HEADER = "HERO".getBytes(ASCII);
+ private static final int ORDER_HEART = 1001;
+ private static final int ORDER_MEMBER_INFO = 4301;
+ private static final int ORDER_MEMBER_INFO_RESP = 4302;
+ private static final int ORDER_DEVICE_STATUS_2003 = 2003;
+ private static final int ORDER_THROW_INFO = 6011;
+ private static final int ORDER_ALARM_EVENT = 6006;
+ private static final int DEVICE_TYPE_GARBAGE_CAN = 0x01;
+ private static final int HEARTBEAT_INTERVAL_SECONDS = 30;
+ private static final int STATUS_INTERVAL_SECONDS = 10 * 60;
+
+ private final Context context;
+ private final ExecutorService sender = Executors.newSingleThreadExecutor();
+ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ private final Object socketLock = new Object();
+ private final Listener listener;
+ private final AtomicBoolean stopped = new AtomicBoolean(false);
+ private ScheduledFuture> heartbeatFuture;
+ private ScheduledFuture> statusFuture;
+ private Socket socket;
+ private InputStream inputStream;
+ private OutputStream outputStream;
+ private volatile String host = "112.74.41.228";
+ private volatile int port = 9956;
+ private volatile long terminalId;
+ private long lastDeviceStatusSentAt;
+ private int serialNum = 1;
+
+ ZhitouTcpReporter(Context context, Listener listener) {
+ this.context = context.getApplicationContext();
+ this.listener = listener;
+ this.terminalId = 0L;
+ }
+
+ synchronized void configure(String host, int port, String terminalIdText) {
+ if (stopped.get()) {
+ return;
+ }
+ boolean connectionChanged = false;
+ boolean terminalChanged = false;
+ if (host != null && host.trim().length() > 0) {
+ String nextHost = host.trim();
+ if (!nextHost.equals(this.host)) {
+ this.host = nextHost;
+ connectionChanged = true;
+ }
+ }
+ if (port > 0) {
+ if (this.port != port) {
+ this.port = port;
+ connectionChanged = true;
+ }
+ }
+ if (terminalIdText != null) {
+ long parsed = parseTerminalId(terminalIdText);
+ long nextTerminalId = parsed > 0 ? parsed : 0L;
+ if (nextTerminalId != this.terminalId) {
+ this.terminalId = nextTerminalId;
+ connectionChanged = true;
+ terminalChanged = true;
+ }
+ }
+ if (connectionChanged) {
+ closeSocketQuietly();
+ }
+ emitLog("传输", "配置 TCP " + this.host + ":" + this.port + " / IMEI " + getTerminalIdText());
+ if (terminalChanged && this.terminalId > 0) {
+ sendHeartbeatNow();
+ sendDeviceStatusNow();
+ }
+ }
+
+ synchronized void startHeartbeat() {
+ if (stopped.get()) {
+ return;
+ }
+ if (heartbeatFuture != null) {
+ heartbeatFuture.cancel(false);
+ }
+ sendHeartbeatNow();
+ heartbeatFuture = scheduler.scheduleAtFixedRate(
+ new ZhitouHeartbeatTask(this),
+ HEARTBEAT_INTERVAL_SECONDS,
+ HEARTBEAT_INTERVAL_SECONDS,
+ TimeUnit.SECONDS);
+ startDeviceStatusReporter();
+ emitLog("传输", "心跳任务已启动");
+ }
+
+ private synchronized void startDeviceStatusReporter() {
+ if (stopped.get()) {
+ return;
+ }
+ if (statusFuture != null) {
+ statusFuture.cancel(false);
+ }
+ sendDeviceStatusNow();
+ statusFuture = scheduler.scheduleAtFixedRate(
+ new ZhitouDeviceStatusTask(this),
+ STATUS_INTERVAL_SECONDS,
+ STATUS_INTERVAL_SECONDS,
+ TimeUnit.SECONDS);
+ emitLog("传输", "设备状态任务已启动");
+ }
+
+ void stop() {
+ if (!stopped.compareAndSet(false, true)) {
+ return;
+ }
+ if (heartbeatFuture != null) {
+ heartbeatFuture.cancel(false);
+ }
+ if (statusFuture != null) {
+ statusFuture.cancel(false);
+ }
+ scheduler.shutdownNow();
+ sender.shutdownNow();
+ // connect() 最长可能阻塞 10 秒;退出 Activity 时异步收尾,避免主线程被 socketLock 卡住。
+ Thread closer = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ closeSocketQuietly();
+ }
+ }, "zhitou-tcp-close");
+ closer.setDaemon(true);
+ closer.start();
+ }
+
+ String getTerminalIdText() {
+ return terminalId > 0 ? String.valueOf(terminalId) : "";
+ }
+
+ void reportMemberVerify(int idType, String idContent) {
+ sendFrameAsync(ORDER_MEMBER_INFO, buildMemberVerifyBody(idType, idContent));
+ }
+
+ ZhitouMemberInfo requestMemberVerify(int idType, String idContent) {
+ if (stopped.get()) {
+ return ZhitouMemberInfo.failed("网络服务已停止");
+ }
+ if (terminalId <= 0) {
+ emitLog("传输", "4301 跳过:未获取下位机IMEI");
+ return ZhitouMemberInfo.failed("未获取下位机IMEI");
+ }
+ byte[] body = buildMemberVerifyBody(idType, idContent);
+ byte[] frame = buildFrame(ORDER_MEMBER_INFO, body);
+ try {
+ synchronized (socketLock) {
+ ensureSocket();
+ socket.setSoTimeout(500);
+ outputStream.write(frame);
+ outputStream.flush();
+ emitTx(ORDER_MEMBER_INFO, frame);
+ long deadline = System.currentTimeMillis() + 8000;
+ while (System.currentTimeMillis() < deadline) {
+ ResponseFrame response = readResponseFrame(deadline);
+ if (response == null) {
+ continue;
+ }
+ emitRx(response);
+ if (response.orderCode == ORDER_MEMBER_INFO_RESP) {
+ return parseMemberInfo(response.body);
+ }
+ }
+ }
+ Log.w(TAG, "member verify timeout, closing socket and refreshing heartbeat");
+ emitLog("传输", "4301 超时:未收到 4302");
+ closeSocketQuietly();
+ sendHeartbeatNow();
+ return ZhitouMemberInfo.failed("会员资料返回超时");
+ } catch (Exception error) {
+ closeSocketQuietly();
+ Log.e(TAG, "member verify failed", error);
+ emitLog("传输", "4301 失败:" + error.getClass().getSimpleName());
+ sendHeartbeatNow();
+ return ZhitouMemberInfo.failed("会员鉴权通讯失败");
+ }
+ }
+
+ private byte[] buildMemberVerifyBody(int idType, String idContent) {
+ byte[] idBytes = fixedIdentityBytes(idType, idContent);
+ byte[] body = new byte[3 + 33];
+ int index = 0;
+ body[index++] = (byte) DEVICE_TYPE_GARBAGE_CAN;
+ body[index++] = (byte) idType;
+ body[index++] = (byte) Math.min(rawIdentityLength(idType, idContent), 33);
+ System.arraycopy(idBytes, 0, body, index, 33);
+ return body;
+ }
+
+ void reportDelivery(
+ int idType,
+ String idContent,
+ int throwIndex,
+ int garbageType,
+ int range,
+ double throwWeight,
+ double beforeWeight,
+ double afterWeight,
+ long createdAt) {
+ byte[] idBytes = fixedIdentityBytes(idType, idContent);
+ byte[] body = new byte[1 + 1 + 33 + 1 + 2 + 1 + 2 + 2];
+ int index = 0;
+ body[index++] = (byte) idType;
+ body[index++] = (byte) Math.min(rawIdentityLength(idType, idContent), 33);
+ System.arraycopy(idBytes, 0, body, index, 33);
+ index += 33;
+ body[index++] = (byte) garbageType;
+ putUInt16(body, index, kgToCentWeight(throwWeight));
+ index += 2;
+ body[index++] = (byte) clamp(range, 0, 255);
+ putUInt16(body, index, kgToCentWeight(beforeWeight));
+ index += 2;
+ putUInt16(body, index, kgToCentWeight(afterWeight));
+ sendFrameAsync(ORDER_THROW_INFO, body);
+ }
+
+ void reportDeviceStatus(JSONObject data) {
+ sendDeviceStatus(data);
+ }
+
+ void sendDeviceStatusNow() {
+ sendDeviceStatus(null);
+ }
+
+ private synchronized void sendDeviceStatus(JSONObject data) {
+ if (terminalId <= 0) {
+ sendFrameAsync(ORDER_DEVICE_STATUS_2003, buildDeviceStatusBody(data));
+ return;
+ }
+ long now = System.currentTimeMillis();
+ if (lastDeviceStatusSentAt > 0 && now - lastDeviceStatusSentAt < 60 * 1000) {
+ emitLog("传输", "设备状态 2003 跳过:60秒内已上报");
+ return;
+ }
+ lastDeviceStatusSentAt = now;
+ sendFrameAsync(ORDER_DEVICE_STATUS_2003, buildDeviceStatusBody(data));
+ }
+
+ private byte[] buildDeviceStatusBody(JSONObject data) {
+ if (data == null) {
+ data = new JSONObject();
+ }
+ JSONArray ports = data.optJSONArray("ports");
+ int count = ports == null ? 0 : ports.length();
+ if (count == 0) {
+ count = 2;
+ }
+ byte[] body = new byte[19 + count * 2];
+ int index = 0;
+ body[index++] = 0;
+ double temperature = data.optDouble("temperature", 0);
+ body[index++] = (byte) (temperature < 0 ? 1 : 0);
+ putUInt16(body, index, (int) Math.round(Math.abs(temperature) * 10));
+ index += 2;
+ body[index++] = (byte) clamp(data.optInt("humidity", 0), 0, 255);
+ body[index++] = (byte) clamp(data.optInt("signal", 30), 0, 255);
+ body[index++] = hasLocation(data) ? (byte) 1 : (byte) 0;
+ putUInt32(body, index, decimal4(data.optString("lng", "0")));
+ index += 4;
+ putUInt32(body, index, decimal4(data.optString("lat", "0")));
+ index += 4;
+ putUInt32(body, index, decimal4(data.optString("altitude", "0")));
+ index += 4;
+ for (int i = 0; i < count; i += 1) {
+ JSONObject item = ports == null ? null : ports.optJSONObject(i);
+ if (item == null) {
+ body[index++] = (byte) (i == 0 ? 3 : 1);
+ body[index++] = (byte) 150;
+ } else {
+ body[index++] = (byte) clamp(item.optInt("garbageType", 18), 0, 255);
+ body[index++] = (byte) clamp(item.optInt("range", 0), 0, 255);
+ }
+ }
+ return body;
+ }
+
+ void reportAlarm(JSONArray alarms) {
+ if (alarms.length() == 0) return;
+ boolean hot = false;
+ for (int i = 0; i < alarms.length(); i += 1) {
+ JSONObject alarm = alarms.optJSONObject(i);
+ if (alarm != null && alarm.optInt("errorCode", 0) == 311) {
+ hot = true;
+ break;
+ }
+ }
+ byte[] body = new byte[(hot ? 7 : 5) * alarms.length() + 2];
+ int index = 0;
+ body[index++] = 1;
+ body[index++] = (byte) alarms.length();
+ for (int i = 0; i < alarms.length(); i += 1) {
+ JSONObject alarm = alarms.optJSONObject(i);
+ if (alarm == null) alarm = new JSONObject();
+ int errorCode = alarm.optInt("errorCode", 301);
+ body[index++] = (byte) clamp(alarm.optInt("port", 0), 0, 255);
+ body[index++] = (byte) clamp(alarm.optInt("garbageType", 18), 0, 255);
+ putUInt16(body, index, errorCode);
+ index += 2;
+ body[index++] = (byte) clamp(alarm.optInt("isError", 1), 0, 1);
+ if (hot) {
+ if (errorCode == 311) {
+ putUInt16(body, index, (int) Math.round(alarm.optDouble("temperature", 0) * 10));
+ } else {
+ putUInt16(body, index, 0);
+ }
+ index += 2;
+ }
+ }
+ sendFrameAsync(ORDER_ALARM_EVENT, body);
+ }
+
+ void sendHeartbeatNow() {
+ byte[] body = new byte[1];
+ body[0] = (byte) DEVICE_TYPE_GARBAGE_CAN;
+ sendFrameAsync(ORDER_HEART, body);
+ }
+
+ private void sendFrameAsync(int orderCode, byte[] body) {
+ if (stopped.get()) {
+ return;
+ }
+ try {
+ sender.execute(new ZhitouSendFrameTask(this, orderCode, body));
+ } catch (RejectedExecutionException ignored) {
+ // Activity 销毁与页面回调并发发生时,丢弃已经过期的上报任务。
+ }
+ }
+
+ void sendFrameNow(int orderCode, byte[] body) {
+ if (stopped.get()) {
+ return;
+ }
+ if (terminalId <= 0) {
+ Log.w(TAG, "skip send order=" + orderCode + ", terminal imei not ready");
+ emitLog("传输", "TX " + orderCode + " 跳过:未获取下位机IMEI");
+ return;
+ }
+ byte[] frame = buildFrame(orderCode, body);
+ try {
+ synchronized (socketLock) {
+ writeFrameLocked(frame);
+ emitTx(orderCode, frame);
+ drainResponsesLocked(350);
+ }
+ Log.i(TAG, "sent order=" + orderCode + " bytes=" + frame.length + " to " + host + ":" + port);
+ } catch (Exception error) {
+ closeSocketQuietly();
+ Log.e(TAG, "send failed order=" + orderCode + " to " + host + ":" + port, error);
+ emitLog("传输", "TX " + orderCode + " 失败:" + errorSummary(error));
+ retryFrameOnce(orderCode, frame);
+ }
+ }
+
+ private void retryFrameOnce(int orderCode, byte[] frame) {
+ if (stopped.get()) {
+ return;
+ }
+ try {
+ synchronized (socketLock) {
+ writeFrameLocked(frame);
+ drainResponsesLocked(350);
+ }
+ Log.i(TAG, "retry sent order=" + orderCode + " bytes=" + frame.length + " to " + host + ":" + port);
+ emitLog("传输", "TX " + orderCode + " 重连重发成功 bytes=" + frame.length);
+ } catch (Exception retryError) {
+ closeSocketQuietly();
+ Log.e(TAG, "retry send failed order=" + orderCode + " to " + host + ":" + port, retryError);
+ emitLog("传输", "TX " + orderCode + " 重连重发失败:" + errorSummary(retryError));
+ }
+ }
+
+ private void writeFrameLocked(byte[] frame) throws Exception {
+ ensureSocket();
+ socket.setSoTimeout(500);
+ outputStream.write(frame);
+ outputStream.flush();
+ }
+
+ private void ensureSocket() throws Exception {
+ if (stopped.get()) {
+ throw new IOException("TCP reporter stopped");
+ }
+ if (socket != null && socket.isConnected() && !socket.isClosed()) {
+ return;
+ }
+ closeSocketQuietly();
+ Socket nextSocket = new Socket();
+ nextSocket.setKeepAlive(true);
+ nextSocket.setTcpNoDelay(true);
+ nextSocket.setSoTimeout(500);
+ nextSocket.connect(new InetSocketAddress(host, port), 10000);
+ if (stopped.get()) {
+ try {
+ nextSocket.close();
+ } catch (Exception ignored) {
+ }
+ throw new IOException("TCP reporter stopped");
+ }
+ socket = nextSocket;
+ inputStream = nextSocket.getInputStream();
+ outputStream = nextSocket.getOutputStream();
+ Log.i(TAG, "connected to " + host + ":" + port);
+ emitLog("传输", "TCP 已连接 " + host + ":" + port);
+ }
+
+ private void emitLog(String kind, String message) {
+ if (!stopped.get() && listener != null) {
+ listener.onReporterLog(kind, message);
+ }
+ }
+
+ private void emitTx(int orderCode, byte[] frame) {
+ emitLog("传输", "TX " + orderCode + " bytes=" + frame.length + " sn=" + frameSerial(frame));
+ emitLog("HEX", "TX " + orderCode + " " + toHex(frame, 160));
+ Log.i(TAG, "TX " + orderCode + " " + toHex(frame, 160));
+ }
+
+ private void emitRx(ResponseFrame response) {
+ emitLog("传输", "RX " + response.orderCode + " body=" + response.body.length + " sn=" + response.serialNum);
+ emitLog("HEX", "RX " + response.orderCode + " " + toHex(response.fullFrame, 160));
+ Log.i(TAG, "RX " + response.orderCode + " " + toHex(response.fullFrame, 160));
+ }
+
+ private String errorSummary(Exception error) {
+ if (error == null) {
+ return "未知错误";
+ }
+ String message = error.getMessage();
+ if (message == null || message.length() == 0) {
+ return error.getClass().getSimpleName();
+ }
+ return error.getClass().getSimpleName() + " " + message;
+ }
+
+ private void closeSocketQuietly() {
+ synchronized (socketLock) {
+ if (outputStream != null) {
+ try {
+ outputStream.close();
+ } catch (Exception ignored) {
+ }
+ }
+ outputStream = null;
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (Exception ignored) {
+ }
+ }
+ inputStream = null;
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (Exception ignored) {
+ }
+ }
+ socket = null;
+ }
+ }
+
+ private byte[] buildFrame(int orderCode, byte[] body) {
+ if (body == null) body = new byte[0];
+ int currentSerial = nextSerialNum();
+ int length = 8 + 2 + body.length + 2 + 1;
+ byte[] frame = new byte[HEADER.length + 2 + length];
+ int index = 0;
+ System.arraycopy(HEADER, 0, frame, index, HEADER.length);
+ index += HEADER.length;
+ putUInt16(frame, index, length);
+ index += 2;
+ putUInt64(frame, index, terminalId);
+ index += 8;
+ putUInt16(frame, index, orderCode);
+ index += 2;
+ System.arraycopy(body, 0, frame, index, body.length);
+ index += body.length;
+ putUInt16(frame, index, currentSerial);
+ index += 2;
+ frame[index] = calcCrc(frame, HEADER.length + 2, 8 + 2 + body.length + 2);
+ return frame;
+ }
+
+ private synchronized int nextSerialNum() {
+ int value = serialNum;
+ serialNum += 1;
+ if (serialNum > 65535) {
+ serialNum = 1;
+ }
+ return value;
+ }
+
+ private void drainResponsesLocked(long waitMs) {
+ if (inputStream == null) {
+ return;
+ }
+ long deadline = System.currentTimeMillis() + Math.max(50, waitMs);
+ try {
+ while (System.currentTimeMillis() < deadline) {
+ ResponseFrame response = readResponseFrame(deadline);
+ if (response == null) {
+ return;
+ }
+ emitRx(response);
+ }
+ } catch (Exception error) {
+ emitLog("传输", "RX 读取异常:" + errorSummary(error));
+ closeSocketQuietly();
+ }
+ }
+
+ private ResponseFrame readResponseFrame(long deadline) throws Exception {
+ if (inputStream == null) {
+ return null;
+ }
+ while (System.currentTimeMillis() < deadline) {
+ int first;
+ try {
+ first = inputStream.read();
+ } catch (SocketTimeoutException timeout) {
+ return null;
+ }
+ if (first < 0) {
+ return null;
+ }
+ if (first != (HEADER[0] & 0xff)) {
+ continue;
+ }
+ if (!readRemainingHeader()) {
+ continue;
+ }
+ byte[] lengthBytes = new byte[2];
+ readFully(lengthBytes, 0, 2);
+ int length = uint16(lengthBytes, 0);
+ if (length < 13 || length > 65535) {
+ continue;
+ }
+ byte[] payload = new byte[length];
+ readFully(payload, 0, length);
+ byte[] full = new byte[HEADER.length + 2 + length];
+ System.arraycopy(HEADER, 0, full, 0, HEADER.length);
+ full[HEADER.length] = lengthBytes[0];
+ full[HEADER.length + 1] = lengthBytes[1];
+ System.arraycopy(payload, 0, full, HEADER.length + 2, length);
+ byte expected = calcCrc(full, HEADER.length + 2, length - 1);
+ byte actual = payload[length - 1];
+ if (expected != actual) {
+ Log.w(TAG, "response crc mismatch expected=" + (expected & 0xff) + " actual=" + (actual & 0xff));
+ Log.w(TAG, "RX CRC mismatch frame=" + toHex(full, 160));
+ emitLog("传输", "RX CRC错误 expected=" + (expected & 0xff) + " actual=" + (actual & 0xff));
+ }
+ int orderCode = uint16(payload, 8);
+ int bodyLength = length - 8 - 2 - 2 - 1;
+ byte[] body = new byte[bodyLength];
+ System.arraycopy(payload, 10, body, 0, bodyLength);
+ int serialNum = uint16(payload, 10 + bodyLength);
+ return new ResponseFrame(orderCode, body, serialNum, full);
+ }
+ return null;
+ }
+
+ private boolean readRemainingHeader() throws Exception {
+ for (int i = 1; i < HEADER.length; i += 1) {
+ int value = inputStream.read();
+ if (value != (HEADER[i] & 0xff)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void readFully(byte[] target, int offset, int length) throws Exception {
+ int read = 0;
+ while (read < length) {
+ int size;
+ try {
+ size = inputStream.read(target, offset + read, length - read);
+ } catch (SocketTimeoutException timeout) {
+ throw timeout;
+ }
+ if (size < 0) {
+ throw new java.io.IOException("socket closed");
+ }
+ read += size;
+ }
+ }
+
+ private ZhitouMemberInfo parseMemberInfo(byte[] data) {
+ if (data == null || data.length < 1) {
+ return ZhitouMemberInfo.failed("会员资料为空");
+ }
+ ZhitouMemberInfo info = new ZhitouMemberInfo();
+ info.verifyStatus = data[0] & 0xff;
+ if (info.verifyStatus == 0) {
+ info.error = "会员鉴权未通过";
+ return info;
+ }
+ if (info.verifyStatus == 2) {
+ info.error = "用户已被禁用";
+ return info;
+ }
+ if (info.verifyStatus != 1) {
+ info.error = "会员鉴权失败:" + info.verifyStatus;
+ return info;
+ }
+ if (data.length < 16) {
+ return ZhitouMemberInfo.failed("会员资料长度不足");
+ }
+ info.memberId = String.valueOf(uint32(data, 1));
+ info.memberCode = String.valueOf(uint32(data, 5));
+ int nameLength = data[9] & 0xff;
+ if (10 + nameLength + 6 > data.length) {
+ return ZhitouMemberInfo.failed("会员资料字段长度异常");
+ }
+ info.name = nameLength > 0 ? new String(data, 10, nameLength, UTF8) : "";
+ info.resiCount = data[14 + nameLength] & 0xff;
+ long score = uint32(data, 10 + nameLength);
+ info.balance = formatMoney(score);
+ return info;
+ }
+
+ private String formatMoney(long cents) {
+ return new BigDecimal(cents).movePointLeft(2).setScale(2).toPlainString();
+ }
+
+ private byte calcCrc(byte[] data, int offset, int length) {
+ byte crc = data[offset];
+ for (int i = offset + 1; i < offset + length; i += 1) {
+ crc ^= data[i];
+ }
+ return crc;
+ }
+
+ private byte[] fixedIdentityBytes(int idType, String value) {
+ byte[] result = new byte[33];
+ if (value == null) return result;
+ byte[] source = identityContentBytes(idType, value);
+ System.arraycopy(source, 0, result, 0, Math.min(source.length, result.length));
+ return result;
+ }
+
+ private int rawIdentityLength(int idType, String value) {
+ if (value == null) return 0;
+ return identityContentBytes(idType, value).length;
+ }
+
+ private byte[] identityContentBytes(int idType, String value) {
+ if (value == null) {
+ return new byte[0];
+ }
+ String text = value.trim();
+ if (idType == 1 && text.matches("(?i)^[0-9a-f]{8}$")) {
+ byte[] bytes = new byte[4];
+ for (int i = 0; i < bytes.length; i += 1) {
+ int start = i * 2;
+ bytes[i] = (byte) Integer.parseInt(text.substring(start, start + 2), 16);
+ }
+ return bytes;
+ }
+ return text.getBytes(UTF8);
+ }
+
+ private int kgToCentWeight(double kg) {
+ return clamp((int) Math.round(kg * 100), 0, 65534);
+ }
+
+ private int decimal4(String value) {
+ try {
+ return new BigDecimal(value).multiply(new BigDecimal(10000)).intValue();
+ } catch (Exception ignored) {
+ return 0;
+ }
+ }
+
+ private boolean hasLocation(JSONObject data) {
+ return decimal4(data.optString("lng", "0")) != 0
+ || decimal4(data.optString("lat", "0")) != 0
+ || decimal4(data.optString("altitude", "0")) != 0;
+ }
+
+ private long parseTerminalId(String text) {
+ if (text == null || text.trim().length() == 0) {
+ return 0;
+ }
+ try {
+ return Long.parseLong(text.trim());
+ } catch (Exception ignored) {
+ return 0;
+ }
+ }
+
+ private int clamp(int value, int min, int max) {
+ if (value < min) return min;
+ if (value > max) return max;
+ return value;
+ }
+
+ private int uint16(byte[] data, int offset) {
+ return ((data[offset] & 0xff) << 8) | (data[offset + 1] & 0xff);
+ }
+
+ private int frameSerial(byte[] frame) {
+ if (frame == null || frame.length < HEADER.length + 2 + 8 + 2 + 2 + 1) {
+ return 0;
+ }
+ return uint16(frame, frame.length - 3);
+ }
+
+ private String toHex(byte[] data, int maxBytes) {
+ if (data == null) {
+ return "";
+ }
+ int limit = Math.min(data.length, Math.max(0, maxBytes));
+ StringBuilder builder = new StringBuilder(limit * 3 + 16);
+ for (int i = 0; i < limit; i += 1) {
+ if (i > 0) builder.append(' ');
+ int value = data[i] & 0xff;
+ if (value < 16) builder.append('0');
+ builder.append(Integer.toHexString(value).toUpperCase(Locale.ROOT));
+ }
+ if (limit < data.length) {
+ builder.append(" ...");
+ }
+ return builder.toString();
+ }
+
+ private long uint32(byte[] data, int offset) {
+ return ((long) (data[offset] & 0xff) << 24)
+ | ((long) (data[offset + 1] & 0xff) << 16)
+ | ((long) (data[offset + 2] & 0xff) << 8)
+ | (long) (data[offset + 3] & 0xff);
+ }
+
+ private void putUInt16(byte[] data, int offset, int value) {
+ int v = clamp(value, 0, 65535);
+ data[offset] = (byte) ((v >> 8) & 0xff);
+ data[offset + 1] = (byte) (v & 0xff);
+ }
+
+ private void putUInt32(byte[] data, int offset, int value) {
+ data[offset] = (byte) ((value >> 24) & 0xff);
+ data[offset + 1] = (byte) ((value >> 16) & 0xff);
+ data[offset + 2] = (byte) ((value >> 8) & 0xff);
+ data[offset + 3] = (byte) (value & 0xff);
+ }
+
+ private void putUInt64(byte[] data, int offset, long value) {
+ data[offset] = (byte) ((value >>> 56) & 0xff);
+ data[offset + 1] = (byte) ((value >>> 48) & 0xff);
+ data[offset + 2] = (byte) ((value >>> 40) & 0xff);
+ data[offset + 3] = (byte) ((value >>> 32) & 0xff);
+ data[offset + 4] = (byte) ((value >>> 24) & 0xff);
+ data[offset + 5] = (byte) ((value >>> 16) & 0xff);
+ data[offset + 6] = (byte) ((value >>> 8) & 0xff);
+ data[offset + 7] = (byte) (value & 0xff);
+ }
+
+ private static final class ResponseFrame {
+ final int orderCode;
+ final byte[] body;
+ final int serialNum;
+ final byte[] fullFrame;
+
+ ResponseFrame(int orderCode, byte[] body, int serialNum, byte[] fullFrame) {
+ this.orderCode = orderCode;
+ this.body = body;
+ this.serialNum = serialNum;
+ this.fullFrame = fullFrame;
+ }
+ }
+}
diff --git a/app/src/main/java/com/zhitou/terminal/ZhitouWebChromeClient.java b/app/src/main/java/com/zhitou/terminal/ZhitouWebChromeClient.java
new file mode 100644
index 0000000..af8f259
--- /dev/null
+++ b/app/src/main/java/com/zhitou/terminal/ZhitouWebChromeClient.java
@@ -0,0 +1,54 @@
+package com.xingyuan.zhiling;
+
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.net.Uri;
+import android.webkit.ValueCallback;
+import android.webkit.WebChromeClient;
+import android.webkit.PermissionRequest;
+import android.webkit.WebView;
+import android.widget.Toast;
+
+final class ZhitouWebChromeClient extends WebChromeClient {
+ private final MainActivity activity;
+
+ ZhitouWebChromeClient(MainActivity activity) {
+ this.activity = activity;
+ }
+
+ @Override
+ public void onPermissionRequest(final PermissionRequest request) {
+ if (request == null) {
+ return;
+ }
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ for (String resource : request.getResources()) {
+ if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) {
+ request.grant(new String[] { PermissionRequest.RESOURCE_VIDEO_CAPTURE });
+ return;
+ }
+ }
+ request.deny();
+ }
+ });
+ }
+
+ @Override
+ public boolean onShowFileChooser(
+ WebView view,
+ ValueCallback filePath,
+ FileChooserParams fileChooserParams) {
+ activity.setFilePathCallback(filePath);
+ Intent intent = fileChooserParams.createIntent();
+ try {
+ activity.launchFileChooser(intent);
+ } catch (ActivityNotFoundException exception) {
+ activity.clearFilePathCallback();
+ Toast.makeText(activity, "无法打开文件选择器", Toast.LENGTH_SHORT).show();
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/app/src/main/jniLibs/armeabi-v7a/libSeetaAuthorize.so b/app/src/main/jniLibs/armeabi-v7a/libSeetaAuthorize.so
new file mode 100644
index 0000000..0e96c9a
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libSeetaAuthorize.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceDetector600.so b/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceDetector600.so
new file mode 100644
index 0000000..d1e4811
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceDetector600.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceLandmarker600.so b/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceLandmarker600.so
new file mode 100644
index 0000000..b11cc68
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceLandmarker600.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceRecognizer600.so b/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceRecognizer600.so
new file mode 100644
index 0000000..c97e633
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libSeetaFaceRecognizer600.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libTenniS.so b/app/src/main/jniLibs/armeabi-v7a/libTenniS.so
new file mode 100644
index 0000000..277899a
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libTenniS.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libserial_port.so b/app/src/main/jniLibs/armeabi-v7a/libserial_port.so
new file mode 100755
index 0000000..0ea7904
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libserial_port.so differ
diff --git a/app/src/main/jniLibs/armeabi-v7a/libxingyuan_face.so b/app/src/main/jniLibs/armeabi-v7a/libxingyuan_face.so
new file mode 100755
index 0000000..a7d9c45
Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libxingyuan_face.so differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..37eb6f1
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..93147f7
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..7f260f7
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..e5ecd7f
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..e016d1b
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..b541425
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ 星元智灵
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..bd44cc7
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..8ae1658
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+# ============================================================
+# 星元智灵 APK 手动编译脚本 (无 Gradle)
+# 使用 Android SDK 命令行工具: aapt / javac / d8 / zipalign / apksigner
+# ============================================================
+set -e
+
+# ---- 路径配置 ----
+PROJECT="$(cd "$(dirname "$0")" && pwd)"
+SDK="$HOME/Library/Android/sdk"
+BT="$SDK/build-tools/34.0.0"
+PLATFORM="$SDK/platforms/android-28/android.jar"
+SRC="$PROJECT/app/src/main"
+BUILD="$PROJECT/build"
+KEYSTORE="$PROJECT/keystore/zhitou-debug.keystore"
+APK_BASENAME="xingyuan-zhiling"
+STORE_PASS="android"
+KEY_PASS="android"
+KEY_ALIAS="androiddebugkey"
+
+# ---- 工具路径 ----
+AAPT="$BT/aapt"
+D8="$BT/d8"
+ZIPALIGN="$BT/zipalign"
+APKSIGNER="$BT/apksigner"
+NDK_VERSION="27.3.13750724"
+NDK="$SDK/ndk/$NDK_VERSION"
+ARM_CLANGXX="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi21-clang++"
+
+# ---- 输出目录 ----
+OUT="$BUILD/out"
+GEN="$BUILD/gen"
+CLASSES="$BUILD/classes"
+DEX="$BUILD/dex"
+
+echo "=========================================="
+echo " 星元智灵 APK 编译"
+echo "=========================================="
+echo "SDK: $SDK"
+echo "BuildTools: 34.0.0"
+echo "Platform: android-28"
+echo "Project: $PROJECT"
+echo ""
+
+# ---- Debug 签名证书 ----
+if [ ! -f "$KEYSTORE" ]; then
+ echo "未找到 debug keystore,正在生成: $KEYSTORE"
+ mkdir -p "$(dirname "$KEYSTORE")"
+ keytool -genkeypair \
+ -keystore "$KEYSTORE" \
+ -storepass "$STORE_PASS" \
+ -keypass "$KEY_PASS" \
+ -alias "$KEY_ALIAS" \
+ -keyalg RSA \
+ -keysize 2048 \
+ -validity 10000 \
+ -dname "CN=Android Debug,O=Xingyuan Zhiling,C=CN" >/dev/null
+fi
+
+# ---- Step 1: 清理 ----
+echo "[1/8] 清理旧的编译产物..."
+rm -rf "$GEN" "$CLASSES" "$DEX"
+mkdir -p "$GEN" "$CLASSES" "$DEX" "$OUT"
+
+# ---- Step 2: 生成 R.java ----
+echo "[2/8] 生成 R.java..."
+"$AAPT" package -f -m \
+ -J "$GEN" \
+ -M "$SRC/AndroidManifest.xml" \
+ -S "$SRC/res" \
+ -A "$SRC/assets" \
+ -I "$PLATFORM"
+
+# ---- Step 3: 收集 Java 源文件 ----
+echo "[3/8] 收集 Java 源文件..."
+SRC_FILES=$(find "$SRC/java" "$GEN" -name "*.java" | tr '\n' ' ')
+echo " 源文件数量: $(echo $SRC_FILES | wc -w | tr -d ' ')"
+
+# ---- Step 4: 编译 Java ----
+echo "[4/8] 编译 Java 源码..."
+javac --release 8 -parameters \
+ -classpath "$PLATFORM" \
+ -sourcepath "$GEN" \
+ -d "$CLASSES" \
+ $SRC_FILES 2>&1
+echo " 编译完成"
+
+# ---- Step 5: 转换为 DEX ----
+echo "[5/8] 转换为 DEX..."
+CLASS_FILES=$(find "$CLASSES" -name "*.class" | tr '\n' ' ')
+"$D8" \
+ --output "$DEX" \
+ --lib "$PLATFORM" \
+ $CLASS_FILES 2>&1
+echo " DEX 生成完成: $DEX/classes.dex"
+
+# ---- Step 6: 打包资源 + Manifest + Assets ----
+echo "[6/8] 打包 APK (资源 + Manifest + Assets)..."
+rm -f "$OUT/$APK_BASENAME-unsigned.apk" "$OUT/$APK_BASENAME-aligned.apk" "$OUT/$APK_BASENAME-signed.apk"
+"$AAPT" package -f \
+ -M "$SRC/AndroidManifest.xml" \
+ -S "$SRC/res" \
+ -A "$SRC/assets" \
+ -I "$PLATFORM" \
+ -F "$OUT/$APK_BASENAME-unsigned.apk"
+
+# ---- Step 7: 添加 DEX 和 Native 库 ----
+echo "[7/8] 添加 DEX 和 Native 库到 APK..."
+# 添加 classes.dex
+cd "$DEX"
+zip -j "$OUT/$APK_BASENAME-unsigned.apk" classes.dex
+cd "$PROJECT"
+
+# 构建 SeetaFace6 JNI 桥(官方 Android 库就位后自动启用)
+FACE_LIB_DIR="$SRC/jniLibs/armeabi-v7a"
+FACE_JNI_SOURCE="$SRC/cpp/face_engine_jni.cpp"
+FACE_JNI_OUTPUT="$FACE_LIB_DIR/libxingyuan_face.so"
+FACE_REQUIRED_LIBS=(
+ libTenniS.so
+ libSeetaAuthorize.so
+ libSeetaFaceDetector600.so
+ libSeetaFaceLandmarker600.so
+ libSeetaFaceRecognizer600.so
+)
+FACE_LIBS_READY=true
+for library in "${FACE_REQUIRED_LIBS[@]}"; do
+ if [ ! -f "$FACE_LIB_DIR/$library" ]; then
+ FACE_LIBS_READY=false
+ fi
+done
+if [ "$FACE_LIBS_READY" = true ] && [ -x "$ARM_CLANGXX" ]; then
+ echo " 编译 SeetaFace6 JNI 桥..."
+ "$ARM_CLANGXX" \
+ --sysroot="$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot" \
+ -std=c++11 -O2 -fPIC -shared -static-libstdc++ \
+ -I"$SRC/cpp/include" \
+ "$FACE_JNI_SOURCE" \
+ -L"$FACE_LIB_DIR" \
+ -lSeetaFaceDetector600 \
+ -lSeetaFaceLandmarker600 \
+ -lSeetaFaceRecognizer600 \
+ -lSeetaAuthorize \
+ -lTenniS \
+ -llog -lz -ldl -lm \
+ -o "$FACE_JNI_OUTPUT"
+else
+ rm -f "$FACE_JNI_OUTPUT"
+ echo " ⚠ SeetaFace6 官方 Android 动态库尚未齐备,人脸 JNI 暂不打包"
+fi
+
+# 添加 native 库 (APK 中需要 lib// 结构)
+NATIVE_STAGING="$BUILD/native_staging"
+rm -rf "$NATIVE_STAGING"
+mkdir -p "$NATIVE_STAGING/lib/armeabi-v7a"
+find "$SRC/jniLibs/armeabi-v7a" -maxdepth 1 -type f -name "*.so" -exec cp {} "$NATIVE_STAGING/lib/armeabi-v7a/" \;
+cd "$NATIVE_STAGING"
+zip -r "$OUT/$APK_BASENAME-unsigned.apk" lib/
+cd "$PROJECT"
+echo " APK 打包完成"
+
+# ---- Step 8: Zipalign + 签名 ----
+echo "[8/8] Zipalign 并签名..."
+# Zipalign
+"$ZIPALIGN" -f 4 "$OUT/$APK_BASENAME-unsigned.apk" "$OUT/$APK_BASENAME-aligned.apk"
+
+# 签名
+"$APKSIGNER" sign \
+ --ks "$KEYSTORE" \
+ --ks-pass "pass:$STORE_PASS" \
+ --ks-key-alias "$KEY_ALIAS" \
+ --key-pass "pass:$KEY_PASS" \
+ --out "$OUT/$APK_BASENAME-signed.apk" \
+ "$OUT/$APK_BASENAME-aligned.apk"
+
+# 验证签名
+"$APKSIGNER" verify --verbose "$OUT/$APK_BASENAME-signed.apk" 2>&1
+
+# ---- 完成 ----
+echo ""
+echo "=========================================="
+echo " ✅ 编译成功!"
+echo "=========================================="
+echo ""
+ls -lh "$OUT/$APK_BASENAME-signed.apk"
+echo ""
+echo "输出文件: $OUT/$APK_BASENAME-signed.apk"
+echo ""
+echo "安装命令:"
+echo " adb install -r $OUT/$APK_BASENAME-signed.apk"
diff --git a/tests/browser/harness.html b/tests/browser/harness.html
new file mode 100644
index 0000000..3e2a576
--- /dev/null
+++ b/tests/browser/harness.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+ 星元智灵异常场景测试
+
+
+
+
+
+
+
+
+