"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { createFileTimestamp, evaluateStability, formatWeight, type WeightUnit, } from "./scale-utils"; type Step = "home" | "identity" | "weigh" | "result" | "settings"; type Unit = WeightUnit; type Language = "zh-CN" | "zh-TW" | "en"; type ScaleStatus = "unconfigured" | "connecting" | "connected" | "error"; type SettingsTab = "device" | "materials" | "peripherals" | "data" | "security" | "logs"; type Material = { id: string; name: string; tone: string; }; type AppSettings = { employee: string; machines: string; weight: string; upload: string; deviceName: string; deviceId: string; imei: string; photoEnabled: boolean; printEnabled: boolean; language: Language; }; type WeightRecord = { employeeName: string; employeeId: string; machineId: string; materialType: string; materialTypeId: string; weightKg: number; createdAt: string; }; type LogEntry = { id: string; time: string; level: "info" | "success" | "warning" | "error"; message: string; }; type PasswordField = "current" | "next" | "confirm"; type NumericEditor = | { kind: "material"; index: number; title: string; maxLength: number } | { kind: "password"; field: PasswordField; title: string; maxLength: number }; declare global { interface Window { WoerNative?: { saveBackup: (fileName: string, content: string) => boolean; appendLog: (fileName: string, content: string) => boolean; exportLogs: (fileName: string, content: string) => boolean; openSystemSettings: () => boolean; openFileManager: () => boolean; startScale: () => boolean; stopScale: () => boolean; scaleCommand: (command: "zero" | "tare") => boolean; }; } } const DEFAULT_MATERIALS: Material[] = [ { id: "3", name: "废铜", tone: "copper" }, { id: "4", name: "废电线", tone: "wire" }, { id: "5", name: "废铝", tone: "aluminum" }, { id: "6", name: "废锡", tone: "tin" }, ]; const TEST_EMPLOYEES = [ { name: "张伟", id: "E-0826" }, { name: "李敏", id: "E-1038" }, { name: "陈浩", id: "E-1172" }, ]; const TEST_MACHINES = ["A01 绕线机", "A03 挤出机", "B06 注塑机", "C02 裁切机"]; const TEST_WEIGHT_RECORDS: WeightRecord[] = [ { employeeName: "张伟", employeeId: "E-0826", machineId: "A01 绕线机", materialType: "废铜", materialTypeId: "3", weightKg: 12.48, createdAt: "2026-08-04T08:35:00.000Z" }, { employeeName: "李敏", employeeId: "E-1038", machineId: "A03 挤出机", materialType: "废电线", materialTypeId: "4", weightKg: 8.76, createdAt: "2026-08-04T09:12:00.000Z" }, { employeeName: "陈浩", employeeId: "E-1172", machineId: "B06 注塑机", materialType: "废铝", materialTypeId: "5", weightKg: 6.32, createdAt: "2026-08-04T10:06:00.000Z" }, ]; const DEFAULT_SETTINGS: AppSettings = { employee: "", machines: "", weight: "", upload: "", deviceName: "智能称重终端 01", deviceId: "SCALE-01", imei: "", photoEnabled: false, printEnabled: false, language: "zh-CN", }; const TRANSLATIONS: Record> = { "zh-CN": { brandTitle: "智能称重终端", online: "设备在线", progressEmployee: "人员确认", progressWeight: "智能称重", progressResult: "结果上传", heroEyebrow: "安全 · 精准 · 可追溯", heroTitle1: "废料称重,", heroTitle2: "简单一点。", heroDesc1: "选择废料类型后开始,系统将自动识别员工信息,", heroDesc2: "稳定采样并上传称重记录。", clickMaterial: "点击右侧废料类型,即刻开始", adminLogin: "管理员登录", prep: "称重前准备", chooseMaterial: "选择废料类型", chooseMaterialDesc: "请选择本次需要称重的废料", loadingEmployee: "正在读取门禁人员信息", chooseAuto: "请选择一种废料类型,选择后自动进入下一步", employeeMachine: "人员与机台", confirmInfo: "确认本次称重信息", identityDesc: "人员信息由门禁机自动获取,请选择对应生产机台。", refreshEmployee: "重新获取", currentEmployee: "当前操作员工", employeeId: "工号", verified: "已验证", back: "返回", machineNo: "生产机台号", selectMachine: "选择机台", refreshMachine: "刷新机台", selectedMachine: "已选择机台", noMachine: "尚未选择生产机台", confirmWeigh: "确认并进入称重", employee: "员工", machine: "机台", materialType: "废料类型", editIdentity: "修改人员/机台", stable: "重量已稳定", net: "净重", total: "累计", tareLabel: "皮重", zero: "清零", zeroDesc: "消除空秤误差", tareAction: "去皮", tareDesc: "扣除容器重量", accumulate: "累计", accumulateDesc: "加入当前重量", cancelAccum: "取消累计", cancelAccumDesc: "撤销上一次累计", currentMaterial: "本次废料类型", confirmedHome: "已在首页确认", uploading: "正在上传…", saveWeight: "保存称重数据", waitStable: "请等待重量稳定", weighComplete: "称重完成", dataSaved: "数据已保存", uploadQueue: "服务器暂时不可用,记录已备份并进入补传队列", uploadSuccess: "本次称重记录已上传并完成本地备份", receipt: "称重凭证", employeeName: "员工姓名", productionMachine: "生产机台", uploadStatus: "上传状态", saved: "已上传", pendingUpload: "待补传", returnHome: "返回首页", autoReturn: "秒后自动返回首页", adminCenter: "管理员中心", appSettings: "应用与设备设置", settingsDesc: "管理设备、废料字典、外设、数据安全与运行日志。", exitAdmin: "退出管理", deviceTab: "设备与接口", materialsTab: "废料字典", peripheralsTab: "拍照与打印", dataTab: "补传与备份", securityTab: "安全设置", logsTab: "实时日志", deviceInfo: "设备信息", deviceInfoDesc: "用于标识当前智能称重终端", localDevice: "本机", deviceName: "设备名称", deviceId: "设备号", imei: "IMEI 号", language: "界面语言", languageDesc: "切换后立即应用到操作界面", serverApi: "本地服务器接口", serverApiDesc: "接口未配置时不会生成模拟数据", materialDict: "废料字典", materialDictDesc: "修改后将同步更新首页分类与称重数据", displayName: "显示名称", saveAll: "保存全部设置", saveHint: "修改完成后请保存设置", passwordTitle: "管理员登录", passwordDesc: "请输入管理员密码进入应用设置", confirmLogin: "确认登录", employeeApi: "人员信息接口", machineApi: "机台列表接口", scaleApi: "电子秤广播服务", uploadApi: "称重上传接口", apiAddressPlaceholder: "请输入完整接口地址", modbusConfigPlaceholder: "后台电子秤服务自动连接,无需配置", modbusConfigHint: "请安装 android_hid_scale_service 后台服务;重量、皮重、单位和稳定状态会自动同步。", peripheralTitle: "完成称重后的外设动作", peripheralDesc: "功能默认关闭,需由 Android 原生层接入 USB 设备", reserved: "预留接口", cameraTitle: "USB 摄像头拍照", cameraDesc: "提交成功后自动拍摄现场照片", photoPath: "照片保存路径", fileName: "文件名", printTitle: "称重凭证打印", printDesc: "提交成功后调用 USB / 蓝牙打印机", scrapWeight: "废料重量", weighingTime: "称重时间", dataTitle: "数据补传与本地备份", dataDesc: "网络异常时确保称重记录不丢失", autoProtect: "自动保护", pending: "待补传数据", localBackup: "本地备份", records: "条记录", backupPath: "本地备份路径", autoWrite: "称重提交时自动写入", retrying: "正在补传…", retryAll: "立即补传全部数据", retryHint: "补传成功后自动清理待传队列,本地备份仍会保留。", adminPassword: "管理员密码", passwordAdvice: "默认密码为 88888888,建议部署后立即修改", currentPassword: "当前密码", newPassword: "新密码", confirmPassword: "确认新密码", updatePassword: "更新管理员密码", liveLogs: "实时运行日志", logDesc: "最多保留最近 100 条运行记录", exportLogs: "导出日志", androidLogPath: "Android 日志保存路径", dailySave: "按日期滚动保存", employeeError: "人员信息获取失败,请检查人员接口", machineError: "机台列表获取失败,请检查机台接口", stableNeeded: "请等待重量稳定后再保存", scaleUnconfigured: "电子秤接口未配置", scaleConnecting: "正在连接电子秤", scaleConnected: "正在稳定采样", scaleError: "电子秤连接异常", zeroed: "秤台已清零", tared: "已去皮,仅显示净重", accumulationNeeded: "重量稳定后才能累计", loginError: "密码不正确", systemTools: "系统工具", systemToolsDesc: "打开终端的 Android 系统功能", systemSettings: "系统设置", fileManager: "文件管理", machineDialogTitle: "选择生产机台", machineDialogDesc: "点击搜索框后,使用数字键盘筛选机台号", searchMachine: "点击输入机台号", close: "关闭", numberClear: "清空", numberDelete: "删除", numberConfirm: "确定", numberInput: "请输入数字", }, "zh-TW": { brandTitle: "智慧稱重終端", online: "設備在線", progressEmployee: "人員確認", progressWeight: "智慧稱重", progressResult: "結果上傳", heroEyebrow: "安全 · 精準 · 可追溯", heroTitle1: "廢料稱重,", heroTitle2: "簡單一點。", heroDesc1: "選擇廢料類型後開始,系統將自動識別員工資訊,", heroDesc2: "穩定採樣並上傳稱重記錄。", clickMaterial: "點擊右側廢料類型,即刻開始", adminLogin: "管理員登入", prep: "稱重前準備", chooseMaterial: "選擇廢料類型", chooseMaterialDesc: "請選擇本次需要稱重的廢料", loadingEmployee: "正在讀取門禁人員資訊", chooseAuto: "請選擇一種廢料類型,選擇後自動進入下一步", employeeMachine: "人員與機台", confirmInfo: "確認本次稱重資訊", identityDesc: "人員資訊由門禁機自動取得,請選擇對應生產機台。", refreshEmployee: "重新取得", currentEmployee: "目前操作員工", employeeId: "工號", verified: "已驗證", back: "返回", machineNo: "生產機台號", selectMachine: "選擇機台", refreshMachine: "刷新機台", selectedMachine: "已選擇機台", noMachine: "尚未選擇生產機台", confirmWeigh: "確認並進入稱重", employee: "員工", machine: "機台", materialType: "廢料類型", editIdentity: "修改人員/機台", stable: "重量已穩定", net: "淨重", total: "累計", tareLabel: "皮重", zero: "清零", zeroDesc: "消除空秤誤差", tareAction: "去皮", tareDesc: "扣除容器重量", accumulate: "累計", accumulateDesc: "加入目前重量", cancelAccum: "取消累計", cancelAccumDesc: "撤銷上一次累計", currentMaterial: "本次廢料類型", confirmedHome: "已在首頁確認", uploading: "正在上傳…", saveWeight: "儲存稱重資料", waitStable: "請等待重量穩定", weighComplete: "稱重完成", dataSaved: "資料已儲存", uploadQueue: "伺服器暫時不可用,記錄已備份並進入補傳佇列", uploadSuccess: "本次稱重記錄已上傳並完成本機備份", receipt: "稱重憑證", employeeName: "員工姓名", productionMachine: "生產機台", uploadStatus: "上傳狀態", saved: "已上傳", pendingUpload: "待補傳", returnHome: "返回首頁", autoReturn: "秒後自動返回首頁", adminCenter: "管理員中心", appSettings: "應用與設備設定", settingsDesc: "管理設備、廢料字典、外設、資料安全與運行日誌。", exitAdmin: "退出管理", deviceTab: "設備與介面", materialsTab: "廢料字典", peripheralsTab: "拍照與列印", dataTab: "補傳與備份", securityTab: "安全設定", logsTab: "即時日誌", deviceInfo: "設備資訊", deviceInfoDesc: "用於識別目前智慧稱重終端", localDevice: "本機", deviceName: "設備名稱", deviceId: "設備號", imei: "IMEI 號", language: "介面語言", languageDesc: "切換後立即套用到操作介面", serverApi: "本地伺服器介面", serverApiDesc: "介面未設定時不會產生模擬資料", materialDict: "廢料字典", materialDictDesc: "修改後將同步更新首頁分類與稱重資料", displayName: "顯示名稱", saveAll: "儲存全部設定", saveHint: "修改完成後請儲存設定", passwordTitle: "管理員登入", passwordDesc: "請輸入管理員密碼進入應用設定", confirmLogin: "確認登入", employeeApi: "人員資訊介面", machineApi: "機台列表介面", scaleApi: "電子秤廣播服務", uploadApi: "稱重上傳介面", apiAddressPlaceholder: "請輸入完整介面地址", modbusConfigPlaceholder: "後台電子秤服務自動連線,無需設定", modbusConfigHint: "請安裝 android_hid_scale_service 後台服務;重量、皮重、單位和穩定狀態會自動同步。", peripheralTitle: "完成稱重後的外設動作", peripheralDesc: "功能預設關閉,需由 Android 原生層接入 USB 設備", reserved: "預留介面", cameraTitle: "USB 攝影機拍照", cameraDesc: "提交成功後自動拍攝現場照片", photoPath: "照片儲存路徑", fileName: "檔名", printTitle: "稱重憑證列印", printDesc: "提交成功後呼叫 USB / 藍牙印表機", scrapWeight: "廢料重量", weighingTime: "稱重時間", dataTitle: "資料補傳與本地備份", dataDesc: "網路異常時確保稱重記錄不遺失", autoProtect: "自動保護", pending: "待補傳資料", localBackup: "本地備份", records: "條記錄", backupPath: "本地備份路徑", autoWrite: "稱重提交時自動寫入", retrying: "正在補傳…", retryAll: "立即補傳全部資料", retryHint: "補傳成功後自動清理待傳佇列,本地備份仍會保留。", adminPassword: "管理員密碼", passwordAdvice: "預設密碼為 88888888,建議部署後立即修改", currentPassword: "目前密碼", newPassword: "新密碼", confirmPassword: "確認新密碼", updatePassword: "更新管理員密碼", liveLogs: "即時運行日誌", logDesc: "最多保留最近 100 條運行記錄", exportLogs: "匯出日誌", androidLogPath: "Android 日誌儲存路徑", dailySave: "依日期滾動儲存", employeeError: "人員資訊取得失敗,請檢查人員介面", machineError: "機台列表取得失敗,請檢查機台介面", stableNeeded: "請等待重量穩定後再儲存", scaleUnconfigured: "電子秤介面未設定", scaleConnecting: "正在連接電子秤", scaleConnected: "正在穩定採樣", scaleError: "電子秤連線異常", zeroed: "秤台已清零", tared: "已去皮,僅顯示淨重", accumulationNeeded: "重量穩定後才能累計", loginError: "密碼不正確", systemTools: "系統工具", systemToolsDesc: "開啟終端的 Android 系統功能", systemSettings: "系統設定", fileManager: "檔案管理", machineDialogTitle: "選擇生產機台", machineDialogDesc: "點擊搜尋框後,使用數字鍵盤篩選機台號", searchMachine: "點擊輸入機台號", close: "關閉", numberClear: "清空", numberDelete: "刪除", numberConfirm: "確定", numberInput: "請輸入數字", }, en: { brandTitle: "Smart Weighing Terminal", online: "Device online", progressEmployee: "Operator", progressWeight: "Weighing", progressResult: "Upload", heroEyebrow: "SAFE · PRECISE · TRACEABLE", heroTitle1: "Scrap weighing,", heroTitle2: "made simple.", heroDesc1: "Choose a scrap type to identify the operator automatically,", heroDesc2: "stabilize the reading, and upload the record.", clickMaterial: "Select a scrap type to begin", adminLogin: "Administrator login", prep: "BEFORE WEIGHING", chooseMaterial: "Choose scrap type", chooseMaterialDesc: "Select the material for this weighing record", loadingEmployee: "Reading access-control operator data", chooseAuto: "Choose a scrap type to continue automatically", employeeMachine: "Operator & machine", confirmInfo: "Confirm weighing information", identityDesc: "Operator data is read automatically. Select the production machine.", refreshEmployee: "Refresh operator", currentEmployee: "Current operator", employeeId: "Employee ID", verified: "Verified", back: "Back", machineNo: "Production machine", selectMachine: "Select machine", refreshMachine: "Refresh", selectedMachine: "Selected machine", noMachine: "No production machine selected", confirmWeigh: "Confirm and start weighing", employee: "Operator", machine: "Machine", materialType: "Scrap type", editIdentity: "Edit operator/machine", stable: "Weight stable", net: "Net", total: "Accumulated", tareLabel: "Tare", zero: "Zero", zeroDesc: "Remove empty-scale offset", tareAction: "Tare", tareDesc: "Subtract container weight", accumulate: "Accumulate", accumulateDesc: "Add current weight", cancelAccum: "Cancel total", cancelAccumDesc: "Undo last accumulation", currentMaterial: "Selected scrap type", confirmedHome: "Confirmed on home", uploading: "Uploading…", saveWeight: "Save weighing record", waitStable: "Wait for stable weight", weighComplete: "Weighing complete", dataSaved: "Data saved", uploadQueue: "Server unavailable; the record is backed up and queued for retry", uploadSuccess: "The record was uploaded and backed up locally", receipt: "Weighing receipt", employeeName: "Employee name", productionMachine: "Production machine", uploadStatus: "Upload status", saved: "Uploaded", pendingUpload: "Pending retry", returnHome: "Return home", autoReturn: "seconds to return home", adminCenter: "ADMIN CENTER", appSettings: "Application & device settings", settingsDesc: "Manage the device, scrap dictionary, peripherals, data, and logs.", exitAdmin: "Exit settings", deviceTab: "Device & APIs", materialsTab: "Scrap dictionary", peripheralsTab: "Photo & print", dataTab: "Retry & backup", securityTab: "Security", logsTab: "Live logs", deviceInfo: "Device information", deviceInfoDesc: "Identifies this smart weighing terminal", localDevice: "Local", deviceName: "Device name", deviceId: "Device ID", imei: "IMEI", language: "Interface language", languageDesc: "Changes are applied to the interface immediately", serverApi: "Local server APIs", serverApiDesc: "No sample data is generated when an API is not configured", materialDict: "Scrap dictionary", materialDictDesc: "Changes update home categories and weighing records", displayName: "Display name", saveAll: "Save all settings", saveHint: "Save after making changes", passwordTitle: "Administrator login", passwordDesc: "Enter the administrator password to open settings", confirmLogin: "Log in", employeeApi: "Operator API", machineApi: "Machine list API", scaleApi: "Scale broadcast service", uploadApi: "Record upload API", apiAddressPlaceholder: "Enter the complete API address", modbusConfigPlaceholder: "The scale service connects automatically", modbusConfigHint: "Install the android_hid_scale_service companion app. Weight, tare, unit, and stability are synchronized automatically.", peripheralTitle: "Post-weighing peripheral actions", peripheralDesc: "Off by default; requires the Android native USB bridge", reserved: "Reserved", cameraTitle: "USB camera capture", cameraDesc: "Capture a site photo after successful submission", photoPath: "Photo save path", fileName: "Filename", printTitle: "Print weighing receipt", printDesc: "Call a USB / Bluetooth printer after submission", scrapWeight: "Scrap weight", weighingTime: "Weighing time", dataTitle: "Retry upload & local backup", dataDesc: "Protect weighing records during network failures", autoProtect: "Automatic", pending: "Pending uploads", localBackup: "Local backups", records: "records", backupPath: "Local backup path", autoWrite: "Written automatically on submission", retrying: "Retrying…", retryAll: "Retry all uploads", retryHint: "Successful uploads are removed from the queue; local backups are retained.", adminPassword: "Administrator password", passwordAdvice: "Default: 88888888. Change it after deployment.", currentPassword: "Current password", newPassword: "New password", confirmPassword: "Confirm password", updatePassword: "Update password", liveLogs: "Live runtime logs", logDesc: "Keeps the latest 100 runtime entries", exportLogs: "Export logs", androidLogPath: "Android log path", dailySave: "Rotated by date", employeeError: "Could not load the operator. Check the operator API.", machineError: "Could not load machines. Check the machine API.", stableNeeded: "Wait for a stable weight before saving", scaleUnconfigured: "Scale API is not configured", scaleConnecting: "Connecting to scale", scaleConnected: "Stabilizing samples", scaleError: "Scale connection error", zeroed: "Scale zeroed", tared: "Tare applied; showing net weight", accumulationNeeded: "Weight must be stable before accumulation", loginError: "Incorrect password", systemTools: "System tools", systemToolsDesc: "Open Android system functions", systemSettings: "System settings", fileManager: "File manager", machineDialogTitle: "Select production machine", machineDialogDesc: "Tap the search field to open the number pad", searchMachine: "Tap to enter machine number", close: "Close", numberClear: "Clear", numberDelete: "Delete", numberConfirm: "Confirm", numberInput: "Enter digits", }, }; const CONFIG_KEY = "smart-scale-config"; const BACKUP_KEY = "smart-scale-backup-records"; const PENDING_KEY = "smart-scale-pending-records"; const LOG_KEY = "smart-scale-logs"; const IMAGE_PATH = "/sdcard/smart/image/"; const CACHE_PATH = "/sdcard/smart/cache/"; const LOG_PATH = "/sdcard/smart/logs/"; /** * 数字输入统一使用应用内键盘,避免工业终端弹出系统输入法。 */ function NumericKeypad({ value, onChange, maxLength, clearLabel, deleteLabel, }: { value: string; onChange: (value: string) => void; maxLength: number; clearLabel: string; deleteLabel: string; }) { const appendDigit = (digit: string) => { if (value.length < maxLength) onChange(`${value}${digit}`); }; return (
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((digit) => ( ))}
); } export default function Home() { // 称重主流程状态:首页 → 人员/机台确认 → 称重 → 结果。 const [step, setStep] = useState("home"); const [employee, setEmployee] = useState({ name: "", id: "" }); const [testEmployeeIndex, setTestEmployeeIndex] = useState(0); const [machineQuery, setMachineQuery] = useState(""); const [machine, setMachine] = useState(""); const [machineOptions, setMachineOptions] = useState(TEST_MACHINES); const [machineModalOpen, setMachineModalOpen] = useState(false); const [machineKeypadOpen, setMachineKeypadOpen] = useState(false); const [machineLoading, setMachineLoading] = useState(false); const [loading, setLoading] = useState(false); const [notice, setNotice] = useState(""); const [unit, setUnit] = useState("kg"); const [rawWeight, setRawWeight] = useState(0); const [zeroOffset, setZeroOffset] = useState(0); const [tare, setTare] = useState(0); const [totalKg, setTotalKg] = useState(0); const [lastAccumulation, setLastAccumulation] = useState<{ total: number; zero: number; tare: number } | null>(null); const [material, setMaterial] = useState(null); const [stable, setStable] = useState(false); const [stableProgress, setStableProgress] = useState(0); const [scaleStatus, setScaleStatus] = useState("unconfigured"); const [countdown, setCountdown] = useState(10); const [passwordOpen, setPasswordOpen] = useState(false); const [password, setPassword] = useState(""); const [passwordError, setPasswordError] = useState(""); const [adminPassword, setAdminPassword] = useState("88888888"); const [appSettings, setAppSettings] = useState(DEFAULT_SETTINGS); const [materialOptions, setMaterialOptions] = useState(DEFAULT_MATERIALS); const [settingsTab, setSettingsTab] = useState("device"); const [settingsMessage, setSettingsMessage] = useState(""); const [settingsBusy, setSettingsBusy] = useState(false); const [passwordForm, setPasswordForm] = useState({ current: "", next: "", confirm: "" }); const [numericEditor, setNumericEditor] = useState(null); const [numericEditorValue, setNumericEditorValue] = useState(""); const [pendingRecords, setPendingRecords] = useState([]); const [backupRecords, setBackupRecords] = useState(TEST_WEIGHT_RECORDS); const [lastRecord, setLastRecord] = useState(null); const [lastUploadPending, setLastUploadPending] = useState(false); const [logs, setLogs] = useState([ { id: "boot", time: new Date().toISOString(), level: "success", message: "应用启动完成", }, ]); const stableSamples = useRef([]); const netWeightRef = useRef(0); const netWeight = Math.max(0, rawWeight - zeroOffset - tare); netWeightRef.current = netWeight; const displayedWeight = totalKg + netWeight; const selectedMaterial = material ?? materialOptions[0]; const language = appSettings.language; const t = (key: string) => TRANSLATIONS[language]?.[key] ?? TRANSLATIONS["zh-CN"][key] ?? key; const scaleStatusText = stable ? t("stable") : t({ unconfigured: "scaleUnconfigured", connecting: "scaleConnecting", connected: "scaleConnected", error: "scaleError", }[scaleStatus]); const materialName = (item: Material) => { const names: Record> = { "zh-CN": { copper: "废铜", wire: "废电线", aluminum: "废铝", tin: "废锡" }, "zh-TW": { copper: "廢銅", wire: "廢電線", aluminum: "廢鋁", tin: "廢錫" }, en: { copper: "Copper scrap", wire: "Waste wire", aluminum: "Aluminum scrap", tin: "Tin scrap" }, }; const knownDefaults = ["废铜", "废电线", "废铝", "废锡", "廢銅", "廢電線", "廢鋁", "廢錫", "Copper scrap", "Waste wire", "Aluminum scrap", "Tin scrap"]; return knownDefaults.includes(item.name) ? names[language][item.tone] : item.name; }; const filteredMachines = useMemo( () => machineOptions.filter((item) => item.toLowerCase().includes(machineQuery.trim().toLowerCase()), ), [machineOptions, machineQuery], ); useEffect(() => { // 延迟到首帧之后恢复配置,避免服务端渲染与本地配置产生水合差异。 const timer = window.setTimeout(() => { try { const savedConfig = window.localStorage.getItem(CONFIG_KEY); if (savedConfig) { const parsed = JSON.parse(savedConfig); if (parsed.settings) setAppSettings({ ...DEFAULT_SETTINGS, ...parsed.settings }); if (parsed.version >= 2 && Array.isArray(parsed.materials) && parsed.materials.length === 4) { setMaterialOptions(parsed.materials.map((item: Partial, index: number) => ({ id: String(item.id ?? DEFAULT_MATERIALS[index].id), name: String(item.name ?? DEFAULT_MATERIALS[index].name), tone: DEFAULT_MATERIALS[index].tone, }))); } else { setMaterialOptions(DEFAULT_MATERIALS); } if (typeof parsed.password === "string") setAdminPassword(parsed.password); } const savedPending = window.localStorage.getItem(PENDING_KEY); const savedBackup = window.localStorage.getItem(BACKUP_KEY); const savedLogs = window.localStorage.getItem(LOG_KEY); if (savedPending) { const parsedPending = JSON.parse(savedPending); if (Array.isArray(parsedPending)) setPendingRecords(parsedPending); } if (savedBackup) { const parsedBackup = JSON.parse(savedBackup); if (Array.isArray(parsedBackup)) setBackupRecords(parsedBackup); } if (savedLogs) { const parsedLogs = JSON.parse(savedLogs); if (Array.isArray(parsedLogs)) setLogs(parsedLogs); } } catch { addLog("本地配置读取失败,已使用默认设置", "warning"); } }, 0); return () => window.clearTimeout(timer); }, []); /* * 称重数据通过 Android 原生 RS-485 Modbus RTU 桥接读取。 * 前端只接收 native 事件,不生成任何模拟重量。 */ useEffect(() => { if (step !== "weigh") return; type ModbusEventDetail = { status?: string; message?: string; weightKg?: number }; let started = false; const handleModbusEvent = (event: Event) => { const detail = (event as CustomEvent).detail ?? {}; if (detail.status === "connecting") setScaleStatus("connecting"); if (detail.status === "connected" || detail.status === "sample") setScaleStatus("connected"); if (detail.status === "error") setScaleStatus("error"); if (typeof detail.weightKg === "number" && Number.isFinite(detail.weightKg) && detail.weightKg >= 0) { setRawWeight(detail.weightKg); } if (detail.message && detail.status !== "sample") { addLog(detail.message, detail.status === "error" ? "error" : detail.status === "connected" ? "success" : "info"); } }; window.addEventListener("woer-modbus", handleModbusEvent); let errorTimer: number | undefined; const reportModbusError = (message: string) => { errorTimer = window.setTimeout(() => { setScaleStatus("error"); addLog(message, "error"); }, 0); }; try { if (!window.WoerNative?.startScale) { reportModbusError("当前运行环境不支持电子秤广播服务"); } else { started = window.WoerNative.startScale(); if (!started) { reportModbusError("电子秤后台服务未安装或无法启动"); } } } catch { reportModbusError("电子秤后台服务启动失败"); } return () => { window.removeEventListener("woer-modbus", handleModbusEvent); if (errorTimer !== undefined) window.clearTimeout(errorTimer); if (started) window.WoerNative?.stopScale(); }; }, [step]); /* * 按固定周期连续采样,而不是依赖 React 的重量变化事件。 * 即使电子秤连续返回完全相同的数值,也能正确完成稳定判断。 */ useEffect(() => { if (step !== "weigh") return; const timer = window.setInterval(() => { stableSamples.current = [...stableSamples.current.slice(-7), netWeightRef.current]; const result = evaluateStability(stableSamples.current); setStable(result.stable); setStableProgress(result.progress); }, 200); return () => window.clearInterval(timer); }, [step]); // 结果页停留 10 秒后自动复位,避免上一位员工的数据残留。 useEffect(() => { if (step !== "result") return; const timer = window.setInterval(() => { setCountdown((value) => { if (value <= 1) { window.clearInterval(timer); resetToHome(); return 0; } return value - 1; }); }, 1000); return () => window.clearInterval(timer); // resetToHome 只读取当前状态并执行复位,不参与计时器创建。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); function resetToHome() { setStep("home"); setMaterial(null); setEmployee({ name: "", id: "" }); setMachine(""); setMachineQuery(""); setMachineKeypadOpen(false); setMachineModalOpen(false); setTotalKg(0); setRawWeight(0); setZeroOffset(0); setTare(0); setStable(false); setStableProgress(0); setScaleStatus("connecting"); setLastAccumulation(null); setLastUploadPending(false); setNotice(""); stableSamples.current = []; } /** * 同时记录到界面、本机缓存和 Android 日志目录。 * 任一存储渠道失败都不能阻断称重主流程。 */ function addLog(message: string, level: LogEntry["level"] = "info") { const entry: LogEntry = { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, time: new Date().toISOString(), level, message, }; try { const day = createFileTimestamp(entry.time).slice(0, 8); const line = `[${new Date(entry.time).toLocaleString("zh-CN", { hour12: false })}] [${level.toUpperCase()}] ${message}`; window.WoerNative?.appendLog(`smart-scale-${day}.log`, line); } catch { // 浏览器预览环境没有 Android 文件桥接,只保留 localStorage 日志。 } setLogs((current) => { const next = [entry, ...current].slice(0, 100); try { window.localStorage.setItem(LOG_KEY, JSON.stringify(next)); } catch { // 本机缓存不可用时,Android 原生日志文件仍可继续保存。 } return next; }); } function persistConfiguration(nextPassword = adminPassword) { const normalizedMaterials = materialOptions.map((item) => ({ ...item, id: item.id.trim(), name: item.name.trim(), })); if (normalizedMaterials.some((item) => !item.name || !/^\d+$/.test(item.id))) { setSettingsMessage("废料名称不能为空,Type ID 必须为数字"); addLog("设置保存失败:废料字典存在无效内容", "warning"); return false; } if (new Set(normalizedMaterials.map((item) => item.id)).size !== normalizedMaterials.length) { setSettingsMessage("Type ID 不能重复,请修改后再保存"); addLog("设置保存失败:废料 Type ID 重复", "warning"); return false; } try { window.localStorage.setItem( CONFIG_KEY, JSON.stringify({ version: 3, settings: appSettings, materials: normalizedMaterials, password: nextPassword, }), ); } catch { setSettingsMessage("设置保存失败,请检查本机存储空间"); addLog("管理员设置写入失败", "error"); return false; } setMaterialOptions(normalizedMaterials); addLog("管理员设置已保存", "success"); setSettingsMessage("全部设置已保存,本机配置已更新"); return true; } function updateMaterial(index: number, field: "name" | "id", value: string) { setMaterialOptions((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)), ); setSettingsMessage(""); } function openNumericEditor(editor: NumericEditor, value: string) { setNumericEditor(editor); setNumericEditorValue(value.replace(/\D/g, "").slice(0, editor.maxLength)); } function applyNumericEditor() { if (!numericEditor) return; if (numericEditor.kind === "material") { updateMaterial(numericEditor.index, "id", numericEditorValue); } else { setPasswordForm((current) => ({ ...current, [numericEditor.field]: numericEditorValue })); setSettingsMessage(""); } setNumericEditor(null); setNumericEditorValue(""); } async function fetchIdentity(chosenMaterial?: Material) { const activeMaterial = chosenMaterial ?? material; if (!activeMaterial) { setNotice("请先选择本次称重的废料类型"); return; } if (chosenMaterial) setMaterial(chosenMaterial); setEmployee({ name: "", id: "" }); setLoading(true); setNotice(""); try { if (!appSettings.employee.trim()) { const nextEmployee = TEST_EMPLOYEES[testEmployeeIndex % TEST_EMPLOYEES.length]; setEmployee(nextEmployee); setTestEmployeeIndex((index) => (index + 1) % TEST_EMPLOYEES.length); addLog(`已载入测试人员:${nextEmployee.name}`, "info"); setStep("identity"); return; } const response = await fetch(appSettings.employee.trim()); if (!response.ok) throw new Error("人员接口不可用"); const data = await response.json(); if (!data?.name || !data?.employeeId) throw new Error("人员数据不完整"); const nextEmployee = { name: String(data.name), id: String(data.employeeId) }; setEmployee(nextEmployee); addLog(`人员信息获取成功:${nextEmployee.name}`, "success"); setStep("identity"); } catch { setEmployee({ name: "", id: "" }); addLog("门禁人员信息获取失败", "error"); setNotice(t("employeeError")); setStep("identity"); } finally { setLoading(false); } } async function refreshMachines() { setMachineLoading(true); setSettingsMessage(""); try { if (!appSettings.machines.trim()) { setMachineOptions(TEST_MACHINES); addLog("已载入测试机台列表", "info"); return; } const response = await fetch(appSettings.machines.trim()); if (!response.ok) throw new Error("机台接口不可用"); const data = await response.json(); const next = Array.isArray(data) ? data : data.machines; if (!Array.isArray(next)) throw new Error("机台数据格式无效"); const parsedMachines = next .map((item: string | { id?: string; name?: string }) => typeof item === "string" ? item.trim() : `${item.id ?? ""} ${item.name ?? ""}`.trim(), ) .filter(Boolean); if (!parsedMachines.length) throw new Error("机台数据为空"); setMachineOptions(parsedMachines); addLog("生产机台列表刷新成功", "success"); } catch { setMachineOptions([]); setNotice(t("machineError")); addLog("生产机台列表刷新失败", "error"); } finally { setMachineLoading(false); } } function closePasswordDialog() { setPasswordOpen(false); setPassword(""); setPasswordError(""); } function beginWeighing() { // 每次进入称重页都从干净采样窗口开始,避免上一次读数影响稳定判断。 setRawWeight(0); setZeroOffset(0); setTare(0); setStable(false); setStableProgress(0); stableSamples.current = []; setScaleStatus("connecting"); setNotice(""); setStep("weigh"); } function enterAdmin() { if (password === adminPassword) { closePasswordDialog(); setSettingsMessage(""); addLog("管理员登录成功", "success"); setStep("settings"); } else { addLog("管理员登录失败:密码错误", "warning"); setPasswordError(t("loginError")); } } function backupRecord(record: WeightRecord) { let nativeSaved = true; try { const suffix = record.employeeId.replace(/[^A-Za-z0-9_-]/g, "_"); nativeSaved = window.WoerNative?.saveBackup( `${createFileTimestamp(record.createdAt)}-${suffix}.json`, JSON.stringify(record, null, 2), ) ?? true; } catch { nativeSaved = false; } setBackupRecords((current) => { const next = [...current, record].slice(-500); try { window.localStorage.setItem(BACKUP_KEY, JSON.stringify(next)); } catch { nativeSaved = false; } return next; }); addLog( nativeSaved ? `称重数据已备份至 ${CACHE_PATH}` : `本地备份文件写入失败:${CACHE_PATH}`, nativeSaved ? "success" : "error", ); } function queuePendingRecord(record: WeightRecord) { setPendingRecords((current) => { const next = [...current, record]; try { window.localStorage.setItem(PENDING_KEY, JSON.stringify(next)); } catch { // Android 文件备份已先完成;缓存写入失败不阻断结果页。 } return next; }); addLog("上传失败,数据已加入补传队列", "warning"); } function runCompletionHooks(record: WeightRecord) { // 拍照与打印当前仅保留触发点,等待原生 USB 驱动接入。 if (appSettings.photoEnabled) { const filename = `${createFileTimestamp(record.createdAt)}.jpg`; addLog(`USB 摄像头拍照调用:${IMAGE_PATH}${filename}`, "success"); } if (appSettings.printEnabled) { addLog( `打印任务已触发:员工 ${record.employeeName},工号 ${record.employeeId},机台 ${record.machineId},废料 ${record.materialType},重量 ${record.weightKg.toFixed(2)} kg,时间 ${new Date(record.createdAt).toLocaleString("zh-CN", { hour12: false })}`, "success", ); } } /** * 先做本地备份,再尝试上传;接口异常时保留到补传队列。 * 这样网络故障不会造成称重记录丢失。 */ async function submitWeight() { if (!stable) { setNotice(t("stableNeeded")); return; } setLoading(true); setNotice(""); const payload = { employeeName: employee.name, employeeId: employee.id, machineId: machine, materialType: selectedMaterial.name, materialTypeId: selectedMaterial.id, weightKg: Number(displayedWeight.toFixed(2)), createdAt: new Date().toISOString(), }; setLastRecord(payload); setLastUploadPending(false); backupRecord(payload); try { if (!appSettings.upload.trim()) throw new Error("上传接口未配置"); const response = await fetch(appSettings.upload.trim(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error("上传失败"); addLog(`称重数据上传成功:${payload.weightKg.toFixed(2)} kg`, "success"); } catch { queuePendingRecord(payload); setLastUploadPending(true); } finally { runCompletionHooks(payload); setCountdown(10); setStep("result"); setLoading(false); } } function addToTotal() { if (!stable || netWeight <= 0) { setNotice(t("accumulationNeeded")); return; } setLastAccumulation({ total: totalKg, zero: zeroOffset, tare }); setTotalKg((value) => value + netWeight); setZeroOffset(rawWeight); setTare(0); stableSamples.current = []; setNotice(`已累计 ${formatWeight(netWeight, unit)} ${unit},可继续放置下一批`); } function cancelAccumulation() { if (!lastAccumulation) return; setTotalKg(lastAccumulation.total); setZeroOffset(lastAccumulation.zero); setTare(lastAccumulation.tare); setLastAccumulation(null); stableSamples.current = []; setNotice(language === "en" ? "Last accumulation canceled" : language === "zh-TW" ? "已取消上一次累計" : "已取消上一次累计"); } function sendScaleCommand(command: "zero" | "tare") { try { return window.WoerNative?.scaleCommand(command) ?? false; } catch { return false; } } async function retryPendingUploads() { if (!pendingRecords.length) { setSettingsMessage("当前没有需要补传的数据"); addLog("手动检查补传队列:无待补传数据"); return; } if (!appSettings.upload.trim()) { setSettingsMessage("称重上传接口未配置,待补传数据已保留"); addLog("数据补传已取消:上传接口未配置", "warning"); return; } setSettingsBusy(true); setSettingsMessage(""); try { for (const record of pendingRecords) { const response = await fetch(appSettings.upload.trim(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(record), }); if (!response.ok) throw new Error("补传失败"); } const count = pendingRecords.length; setPendingRecords([]); window.localStorage.setItem(PENDING_KEY, "[]"); setSettingsMessage(`补传完成,共成功上传 ${count} 条数据`); addLog(`数据补传完成:${count} 条`, "success"); } catch { setSettingsMessage("补传失败,数据仍保留在本地队列"); addLog("数据补传失败,请检查服务器接口", "error"); } finally { setSettingsBusy(false); } } function changeAdminPassword() { if (passwordForm.current !== adminPassword) { setSettingsMessage("原密码不正确"); return; } if (passwordForm.next.length < 8) { setSettingsMessage("新密码至少需要 8 位"); return; } if (passwordForm.next !== passwordForm.confirm) { setSettingsMessage("两次输入的新密码不一致"); return; } if (!persistConfiguration(passwordForm.next)) return; setAdminPassword(passwordForm.next); setPasswordForm({ current: "", next: "", confirm: "" }); setSettingsMessage("管理员密码已修改,下次登录时生效"); addLog("管理员密码已修改", "success"); } function exportLogs() { const content = logs .map((entry) => `[${new Date(entry.time).toLocaleString("zh-CN", { hour12: false })}] [${entry.level.toUpperCase()}] ${entry.message}`) .join("\n"); const fileName = `smart-scale-export-${createFileTimestamp(new Date().toISOString())}.log`; let exported = false; try { exported = window.WoerNative?.exportLogs(fileName, content) ?? false; } catch { exported = false; } if (!exported) { const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url); } addLog( exported ? `日志已导出,目标目录 ${LOG_PATH}` : "日志已通过浏览器下载", exported ? "success" : "warning", ); setSettingsMessage(exported ? `日志已导出至 ${LOG_PATH}` : "日志已通过浏览器下载"); } function openSystemDestination(destination: "settings" | "files") { let opened = false; try { opened = destination === "settings" ? window.WoerNative?.openSystemSettings() ?? false : window.WoerNative?.openFileManager() ?? false; } catch { opened = false; } const label = destination === "settings" ? t("systemSettings") : t("fileManager"); setSettingsMessage(opened ? `${label}已打开` : `${label}无法打开`); addLog(`${label}${opened ? "已打开" : "打开失败"}`, opened ? "success" : "error"); } return (
{t("online")}
{step === "home" && (
)} {step === "identity" && (

{t("confirmInfo")}

{t("currentMaterial")}{materialName(selectedMaterial)}TYPE ID · {selectedMaterial.id}✓
{t("currentEmployee")}
{employee.name ? employee.name.slice(-1) : "?"}
{t("currentEmployee")}

{employee.name || "—"}

{t("employeeId")} · {employee.id || "—"}

{employee.name ? `✓ ${t("verified")}` : "!"}
{t("machineNo")}
▣ {machine ? t("selectedMachine") : t("machineNo")}{machine || t("noMachine")}
{notice &&
ⓘ {notice}
}
)} {step === "weigh" && (
{t("employee")}{employee.name} · {employee.id} {t("machine")}{machine} {t("materialType")}{materialName(selectedMaterial)} · {selectedMaterial.id}
{scaleStatusText}
{(["kg", "g", "lb"] as Unit[]).map((item) => ( ))}
{formatWeight(displayedWeight, unit)}{unit}
{t("net")} {formatWeight(netWeight, unit)} {unit} {t("total")} {formatWeight(totalKg, unit)} {unit} {t("tareLabel")} {formatWeight(tare, unit)} {unit}
{lastAccumulation && }
{notice &&
ⓘ {notice}
}
)} {step === "result" && (
✓
{t("weighComplete")}

{t("dataSaved")}

{t(lastUploadPending ? "uploadQueue" : "uploadSuccess")}

{t("receipt")}{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString(language, { hour12: false })}
{materialName(selectedMaterial)}{formatWeight(lastRecord?.weightKg ?? displayedWeight, unit)} {unit}
{t("employeeName")}
{employee.name}
{t("employeeId")}
{employee.id}
{t("productionMachine")}
{machine}
{t("materialType")} ID
{selectedMaterial.id}
{t("uploadStatus")}
● {t(lastUploadPending ? "pendingUpload" : "saved")}
{countdown} {t("autoReturn")}
)} {step === "settings" && (
{t("adminCenter")}

{t("appSettings")}

{t("settingsDesc")}

{settingsTab === "device" && ( <>
{t("deviceInfo")}{t("deviceInfoDesc")} {t("localDevice")}
{t("language")}{t("languageDesc")}
{[ ["zh-CN", "简体中文"], ["zh-TW", "繁體中文"], ["en", "English"], ].map(([value, label]) => ( ))}
{t("systemTools")}{t("systemToolsDesc")}
{t("serverApi")}{t("serverApiDesc")}
{[ ["employee", t("employeeApi")], ["machines", t("machineApi")], ["weight", t("scaleApi")], ["upload", t("uploadApi")], ].map(([key, label]) => ( ))}
{t("scaleApi")} {t("modbusConfigHint")}
)} {settingsTab === "materials" && ( <>
{t("materialDict")}{t("materialDictDesc")} 4 项
{materialOptions.map((item, index) => (
0{index + 1}
))}
)} {settingsTab === "peripherals" && ( <>
{t("peripheralTitle")}{t("peripheralDesc")} {t("reserved")}
◎ {t("cameraTitle")}{t("cameraDesc")}
{t("photoPath")}{IMAGE_PATH}{t("fileName")}:YYYYMMDDHHmmss.jpg
▤ {t("printTitle")}{t("printDesc")}
{[t("employeeName"), t("employeeId"), t("machineNo"), t("materialType"), t("scrapWeight"), t("weighingTime")].map((item) => ✓ {item})}
)} {settingsTab === "data" && ( <>
{t("dataTitle")}{t("dataDesc")} {t("autoProtect")}
↥{t("pending")}{pendingRecords.length}{t("records")}
▣{t("localBackup")}{backupRecords.length}{t("records")}
{t("backupPath")}{CACHE_PATH}{t("autoWrite")}
{t("retryHint")}
)} {settingsTab === "security" && ( <>
{t("adminPassword")}{t("passwordAdvice")} 8–12 位
{([ ["current", t("currentPassword")], ["next", t("newPassword")], ["confirm", t("confirmPassword")], ] as [PasswordField, string][]).map(([field, label]) => ( ))}
)} {settingsTab === "logs" && ( <>
{t("liveLogs")}{t("logDesc")}
{t("androidLogPath")}{LOG_PATH}{t("dailySave")}
{logs.map((entry) => (
{entry.level === "success" ? "成功" : entry.level === "warning" ? "警告" : entry.level === "error" ? "错误" : "信息"}

{entry.message}

))}
)}
{settingsMessage || t("saveHint")}
)}
沃尔核材 · 数字化生产
{passwordOpen && (
event.stopPropagation()}> ⌁

{t("passwordTitle")}

{t("passwordDesc")}

{password ? "●".repeat(password.length) : t("numberInput")}
{ setPassword(value); setPasswordError(""); }} maxLength={12} clearLabel={t("numberClear")} deleteLabel={t("numberDelete")} /> {passwordError && {passwordError}}
)} {numericEditor && (
setNumericEditor(null)}>
event.stopPropagation()}> #

{numericEditor.title}

{numericEditorValue ? numericEditor.kind === "password" ? "●".repeat(numericEditorValue.length) : numericEditorValue : t("numberInput")}
)} {machineModalOpen && (
{ setMachineKeypadOpen(false); setMachineModalOpen(false); }}>
event.stopPropagation()}>
▣

{t("machineDialogTitle")}

{t("machineDialogDesc")}

⌕ setMachineKeypadOpen(true)} onClick={() => setMachineKeypadOpen(true)} />
{machineKeypadOpen && }
{filteredMachines.map((item) => ( ))} {!filteredMachines.length &&

{t("noMachine")}

}
)}
); }