1354 lines
72 KiB
TypeScript
1354 lines
72 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
createFileTimestamp,
|
||
evaluateStability,
|
||
formatWeight,
|
||
parseScaleMessage,
|
||
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;
|
||
};
|
||
}
|
||
}
|
||
|
||
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 DEFAULT_SETTINGS: AppSettings = {
|
||
employee: "",
|
||
machines: "",
|
||
weight: "",
|
||
upload: "",
|
||
deviceName: "智能称重终端 01",
|
||
deviceId: "SCALE-01",
|
||
imei: "",
|
||
photoEnabled: false,
|
||
printEnabled: false,
|
||
language: "zh-CN",
|
||
};
|
||
|
||
const TRANSLATIONS: Record<Language, Record<string, string>> = {
|
||
"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: "请输入完整接口地址",
|
||
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: "請輸入完整介面地址",
|
||
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 data API", uploadApi: "Record upload API", apiAddressPlaceholder: "Enter the complete API address",
|
||
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 PRODUCTION_DATA_KEY = "smart-scale-production-data-v1";
|
||
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 (
|
||
<div className="numeric-keypad" aria-label="数字键盘">
|
||
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((digit) => (
|
||
<button type="button" key={digit} onClick={() => appendDigit(digit)}>{digit}</button>
|
||
))}
|
||
<button type="button" className="key-action key-clear" onClick={() => onChange("")}>{clearLabel}</button>
|
||
<button type="button" onClick={() => appendDigit("0")}>0</button>
|
||
<button type="button" className="key-action" onClick={() => onChange(value.slice(0, -1))}>⌫<small>{deleteLabel}</small></button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Home() {
|
||
// 称重主流程状态:首页 → 人员/机台确认 → 称重 → 结果。
|
||
const [step, setStep] = useState<Step>("home");
|
||
const [employee, setEmployee] = useState({ name: "", id: "" });
|
||
const [machineQuery, setMachineQuery] = useState("");
|
||
const [machine, setMachine] = useState("");
|
||
const [machineOptions, setMachineOptions] = useState<string[]>([]);
|
||
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<Unit>("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<Material | null>(null);
|
||
const [stable, setStable] = useState(false);
|
||
const [stableProgress, setStableProgress] = useState(0);
|
||
const [scaleStatus, setScaleStatus] = useState<ScaleStatus>("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<AppSettings>(DEFAULT_SETTINGS);
|
||
const [materialOptions, setMaterialOptions] = useState<Material[]>(DEFAULT_MATERIALS);
|
||
const [settingsTab, setSettingsTab] = useState<SettingsTab>("device");
|
||
const [settingsMessage, setSettingsMessage] = useState("");
|
||
const [settingsBusy, setSettingsBusy] = useState(false);
|
||
const [passwordForm, setPasswordForm] = useState({ current: "", next: "", confirm: "" });
|
||
const [numericEditor, setNumericEditor] = useState<NumericEditor | null>(null);
|
||
const [numericEditorValue, setNumericEditorValue] = useState("");
|
||
const [pendingRecords, setPendingRecords] = useState<WeightRecord[]>([]);
|
||
const [backupRecords, setBackupRecords] = useState<WeightRecord[]>([]);
|
||
const [lastRecord, setLastRecord] = useState<WeightRecord | null>(null);
|
||
const [lastUploadPending, setLastUploadPending] = useState(false);
|
||
const [logs, setLogs] = useState<LogEntry[]>([
|
||
{
|
||
id: "boot",
|
||
time: new Date().toISOString(),
|
||
level: "success",
|
||
message: "应用启动完成",
|
||
},
|
||
]);
|
||
const stableSamples = useRef<number[]>([]);
|
||
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<Language, Record<string, string>> = {
|
||
"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 {
|
||
/*
|
||
* 早期测试版会自动生成模拟人员、机台和重量记录。
|
||
* 首次升级到正式数据模式时仅清理这些历史业务记录,
|
||
* 管理员密码、接口地址和废料字典等配置继续保留。
|
||
*/
|
||
if (window.localStorage.getItem(PRODUCTION_DATA_KEY) !== "ready") {
|
||
window.localStorage.removeItem(PENDING_KEY);
|
||
window.localStorage.removeItem(BACKUP_KEY);
|
||
window.localStorage.removeItem(LOG_KEY);
|
||
window.localStorage.setItem(PRODUCTION_DATA_KEY, "ready");
|
||
}
|
||
|
||
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<Material>, 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);
|
||
}, []);
|
||
|
||
/*
|
||
* 称重数据只接受已配置的 WebSocket 接口,不再注入任何模拟重量。
|
||
* 服务端可发送纯数字或带 weight / weightKg / value 字段的 JSON。
|
||
*/
|
||
useEffect(() => {
|
||
if (step !== "weigh") return;
|
||
if (!appSettings.weight.trim()) return;
|
||
|
||
let socket: WebSocket;
|
||
let disposed = false;
|
||
|
||
try {
|
||
socket = new WebSocket(appSettings.weight.trim());
|
||
} catch {
|
||
window.setTimeout(() => {
|
||
setScaleStatus("error");
|
||
addLog("电子秤接口地址无效", "error");
|
||
}, 0);
|
||
return;
|
||
}
|
||
|
||
socket.onopen = () => {
|
||
if (disposed) return;
|
||
setScaleStatus("connected");
|
||
addLog("电子秤数据接口连接成功", "success");
|
||
};
|
||
socket.onmessage = (event) => {
|
||
const nextWeight = parseScaleMessage(String(event.data ?? ""));
|
||
if (nextWeight !== null) setRawWeight(nextWeight);
|
||
};
|
||
socket.onerror = () => {
|
||
if (disposed) return;
|
||
setScaleStatus("error");
|
||
addLog("电子秤数据接口连接异常", "error");
|
||
};
|
||
socket.onclose = () => {
|
||
if (disposed) return;
|
||
setScaleStatus("error");
|
||
};
|
||
|
||
return () => {
|
||
disposed = true;
|
||
socket.close();
|
||
};
|
||
}, [appSettings.weight, 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(appSettings.weight.trim() ? "connecting" : "unconfigured");
|
||
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()) throw new Error("人员接口未配置");
|
||
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()) throw new Error("机台接口未配置");
|
||
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(appSettings.weight.trim() ? "connecting" : "unconfigured");
|
||
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" ? "已取消上一次累計" : "已取消上一次累计");
|
||
}
|
||
|
||
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 (
|
||
<main className="app-shell" lang={language}>
|
||
<div className="ambient ambient-one" />
|
||
<div className="ambient ambient-two" />
|
||
|
||
<section className="device-frame">
|
||
<header className="app-header">
|
||
<button className="brand" onClick={resetToHome} aria-label="返回首页">
|
||
<span className="brand-mark">W</span>
|
||
<span>
|
||
<strong>{t("brandTitle")}</strong>
|
||
<small>WOER · SMART SCALE</small>
|
||
</span>
|
||
</button>
|
||
<div className="status-pill">
|
||
<span className="status-dot" />
|
||
{t("online")}
|
||
</div>
|
||
</header>
|
||
|
||
{step !== "home" && step !== "settings" && (
|
||
<div className="progress-strip" aria-label="流程进度">
|
||
{[t("progressEmployee"), t("progressWeight"), t("progressResult")].map((label, index) => {
|
||
const activeIndex = step === "identity" ? 0 : step === "weigh" ? 1 : 2;
|
||
return (
|
||
<div className={index <= activeIndex ? "progress-item active" : "progress-item"} key={label}>
|
||
<span>{index < activeIndex ? "✓" : index + 1}</span>
|
||
{label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="content">
|
||
{step === "home" && (
|
||
<div className="home-view">
|
||
<div className="home-copy">
|
||
<div className="eyebrow"><span /> {t("heroEyebrow")}</div>
|
||
<h1>{t("heroTitle1")}<br /><em>{t("heroTitle2")}</em></h1>
|
||
<p>{t("heroDesc1")}<br />{t("heroDesc2")}</p>
|
||
<div className={loading ? "home-start-hint loading" : "home-start-hint"}>
|
||
<span>{loading ? "···" : "1"}</span>
|
||
<b>{loading ? t("loadingEmployee") : t("clickMaterial")}</b>
|
||
</div>
|
||
<button className="admin-link" onClick={() => { setPassword(""); setPasswordError(""); setPasswordOpen(true); }}>⚙ {t("adminLogin")} <span>→</span></button>
|
||
{notice && <div className="notice home-notice">ⓘ {notice}</div>}
|
||
</div>
|
||
|
||
<aside className="home-material-panel">
|
||
<div className="material-panel-heading">
|
||
<span className="step-kicker">{t("prep")}</span>
|
||
<h2>{t("chooseMaterial")}</h2>
|
||
<p>{t("chooseMaterialDesc")}</p>
|
||
</div>
|
||
<div className="home-material-grid">
|
||
{materialOptions.map((item) => (
|
||
<button
|
||
key={item.tone}
|
||
className={`material-card ${item.tone} ${material?.tone === item.tone ? "selected" : ""}`}
|
||
onClick={() => void fetchIdentity(item)}
|
||
disabled={loading}
|
||
aria-pressed={material?.tone === item.tone}
|
||
>
|
||
<span><b>{materialName(item)}</b><small>TYPE ID · {item.id}</small></span>
|
||
<i>{material?.tone === item.tone ? "✓" : "→"}</i>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className={material ? "material-selection-status ready" : "material-selection-status"}>
|
||
<span>{loading ? "···" : material ? "✓" : "1"}</span>
|
||
{loading ? t("loadingEmployee") : material ? `${t("chooseMaterial")} · ${materialName(material)}` : t("chooseAuto")}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
)}
|
||
|
||
{step === "identity" && (
|
||
<div className="panel-view identity-view">
|
||
<button className="back-button" onClick={resetToHome}>← {t("back")}</button>
|
||
<div className="section-heading">
|
||
<div>
|
||
<span className="step-kicker">01 · {t("employeeMachine")}</span>
|
||
<h2>{t("confirmInfo")}</h2>
|
||
<p>{t("identityDesc")}</p>
|
||
</div>
|
||
<button className="refresh-button" onClick={() => void fetchIdentity()} disabled={loading}>↻ {t("refreshEmployee")}</button>
|
||
</div>
|
||
<div className="identity-card">
|
||
<div className="avatar">{employee.name ? employee.name.slice(-1) : "?"}</div>
|
||
<div><small>{t("currentEmployee")}</small><h3>{employee.name || "—"}</h3><p>{t("employeeId")} · {employee.id || "—"}</p></div>
|
||
<span className={employee.name ? "verified" : "verified missing"}>{employee.name ? `✓ ${t("verified")}` : "!"}</span>
|
||
</div>
|
||
<label className="field-label">{t("machineNo")}</label>
|
||
<div className={machine ? "machine-select-row selected" : "machine-select-row"}>
|
||
<span className="machine-select-icon">▣</span>
|
||
<span><small>{machine ? t("selectedMachine") : t("machineNo")}</small><b>{machine || t("noMachine")}</b></span>
|
||
<button onClick={() => { setMachineQuery(""); setMachineKeypadOpen(false); setMachineModalOpen(true); }}>{t("selectMachine")}</button>
|
||
<button className="machine-refresh" onClick={() => void refreshMachines()} disabled={machineLoading}>↻ {t("refreshMachine")}</button>
|
||
</div>
|
||
<button
|
||
className="full-primary"
|
||
disabled={!employee.name || !machine}
|
||
onClick={beginWeighing}
|
||
>
|
||
{t("confirmWeigh")} <span>→</span>
|
||
</button>
|
||
{notice && <div className="notice">ⓘ {notice}</div>}
|
||
</div>
|
||
)}
|
||
|
||
{step === "weigh" && (
|
||
<div className="weigh-view">
|
||
<button className="back-button weigh-back" onClick={() => setStep("identity")}>← {t("back")}</button>
|
||
<div className="weigh-meta">
|
||
<span><small>{t("employee")}</small><b>{employee.name} · {employee.id}</b></span>
|
||
<span><small>{t("machine")}</small><b>{machine}</b></span>
|
||
<span className="material-meta"><small>{t("materialType")}</small><b>{materialName(selectedMaterial)} · {selectedMaterial.id}</b></span>
|
||
<button onClick={() => setStep("identity")}>{t("editIdentity")}</button>
|
||
</div>
|
||
|
||
<div className="scale-display">
|
||
<div className="display-top">
|
||
<span className={stable ? "stable-badge ready" : "stable-badge"}>
|
||
<i /> {scaleStatusText}
|
||
</span>
|
||
<div className="unit-switch">
|
||
{(["kg", "g", "lb"] as Unit[]).map((item) => (
|
||
<button className={unit === item ? "active" : ""} onClick={() => setUnit(item)} key={item}>{item}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="weight-number">
|
||
<span>{formatWeight(displayedWeight, unit)}</span><b>{unit}</b>
|
||
</div>
|
||
<div className="stability-track"><span style={{ width: `${stableProgress}%` }} /></div>
|
||
<div className="display-details">
|
||
<span>{t("net")} <b>{formatWeight(netWeight, unit)} {unit}</b></span>
|
||
<span>{t("total")} <b>{formatWeight(totalKg, unit)} {unit}</b></span>
|
||
<span>{t("tareLabel")} <b>{formatWeight(tare, unit)} {unit}</b></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={lastAccumulation ? "scale-tools has-cancel" : "scale-tools"}>
|
||
<button onClick={() => { setZeroOffset(rawWeight); setTare(0); setNotice(t("zeroed")); }}>⟲<span><b>{t("zero")}</b><small>{t("zeroDesc")}</small></span></button>
|
||
<button onClick={() => { setTare(Math.max(0, rawWeight - zeroOffset)); setNotice(t("tared")); }}>▱<span><b>{t("tareAction")}</b><small>{t("tareDesc")}</small></span></button>
|
||
<button onClick={addToTotal}>+<span><b>{t("accumulate")}</b><small>{t("accumulateDesc")}</small></span></button>
|
||
{lastAccumulation && <button className="cancel-total" onClick={cancelAccumulation}>×<span><b>{t("cancelAccum")}</b><small>{t("cancelAccumDesc")}</small></span></button>}
|
||
</div>
|
||
|
||
<div className={`selected-material-summary ${selectedMaterial.tone}`}>
|
||
<span>
|
||
<small>{t("currentMaterial")}</small>
|
||
<b>{materialName(selectedMaterial)}</b>
|
||
<em>TYPE ID · {selectedMaterial.id}</em>
|
||
</span>
|
||
<i>✓ {t("confirmedHome")}</i>
|
||
</div>
|
||
{notice && <div className="notice compact">ⓘ {notice}</div>}
|
||
<button className="full-primary upload" disabled={!stable || loading} onClick={submitWeight}>
|
||
{loading ? t("uploading") : stable ? t("saveWeight") : t("waitStable")} <span>→</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === "result" && (
|
||
<div className="result-view">
|
||
<div className="success-ring"><span>✓</span></div>
|
||
<span className="step-kicker">{t("weighComplete")}</span>
|
||
<h2>{t("dataSaved")}</h2>
|
||
<p>{t(lastUploadPending ? "uploadQueue" : "uploadSuccess")}</p>
|
||
<div className="receipt">
|
||
<div className="receipt-head"><span>{t("receipt")}</span><small>{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString(language, { hour12: false })}</small></div>
|
||
<div className="receipt-weight">
|
||
<span><small>{materialName(selectedMaterial)}</small><b>{formatWeight(lastRecord?.weightKg ?? displayedWeight, unit)} <em>{unit}</em></b></span>
|
||
</div>
|
||
<dl>
|
||
<div><dt>{t("employeeName")}</dt><dd>{employee.name}</dd></div>
|
||
<div><dt>{t("employeeId")}</dt><dd>{employee.id}</dd></div>
|
||
<div><dt>{t("productionMachine")}</dt><dd>{machine}</dd></div>
|
||
<div><dt>{t("materialType")} ID</dt><dd>{selectedMaterial.id}</dd></div>
|
||
<div><dt>{t("uploadStatus")}</dt><dd className={lastUploadPending ? "upload-pending" : "upload-ok"}>● {t(lastUploadPending ? "pendingUpload" : "saved")}</dd></div>
|
||
</dl>
|
||
</div>
|
||
<button className="full-primary" onClick={resetToHome}>{t("returnHome")} <span>→</span></button>
|
||
<div className="countdown"><span style={{ width: `${countdown * 10}%` }} />{countdown} {t("autoReturn")}</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === "settings" && (
|
||
<div className="panel-view settings-view">
|
||
<div className="section-heading settings-heading">
|
||
<div><span className="step-kicker">{t("adminCenter")}</span><h2>{t("appSettings")}</h2><p>{t("settingsDesc")}</p></div>
|
||
<button className="refresh-button" onClick={resetToHome}>{t("exitAdmin")}</button>
|
||
</div>
|
||
|
||
<div className="settings-layout">
|
||
<nav className="settings-tabs" aria-label="设置分类">
|
||
{[
|
||
["device", "▣", t("deviceTab")],
|
||
["materials", "◆", t("materialsTab")],
|
||
["peripherals", "⌁", t("peripheralsTab")],
|
||
["data", "↥", t("dataTab")],
|
||
["security", "⌾", t("securityTab")],
|
||
["logs", "≡", t("logsTab")],
|
||
].map(([key, icon, label]) => (
|
||
<button
|
||
key={key}
|
||
className={settingsTab === key ? "active" : ""}
|
||
onClick={() => {
|
||
setSettingsTab(key as SettingsTab);
|
||
setSettingsMessage("");
|
||
}}
|
||
>
|
||
<span>{icon}</span>{label}
|
||
{key === "data" && pendingRecords.length > 0 && <b>{pendingRecords.length}</b>}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<section className="settings-panel">
|
||
{settingsTab === "device" && (
|
||
<>
|
||
<div className="settings-panel-title">
|
||
<span><b>{t("deviceInfo")}</b><small>{t("deviceInfoDesc")}</small></span>
|
||
<em>{t("localDevice")}</em>
|
||
</div>
|
||
<div className="settings-form-grid three">
|
||
<label><span>{t("deviceName")}</span><input value={appSettings.deviceName} onChange={(event) => setAppSettings({ ...appSettings, deviceName: event.target.value })} /></label>
|
||
<label><span>{t("deviceId")}</span><input value={appSettings.deviceId} onChange={(event) => setAppSettings({ ...appSettings, deviceId: event.target.value })} /></label>
|
||
<label><span>{t("imei")}</span><input inputMode="numeric" value={appSettings.imei} onChange={(event) => setAppSettings({ ...appSettings, imei: event.target.value.replace(/\D/g, "").slice(0, 15) })} /></label>
|
||
</div>
|
||
<div className="language-setting">
|
||
<span><b>{t("language")}</b><small>{t("languageDesc")}</small></span>
|
||
<div>
|
||
{[
|
||
["zh-CN", "简体中文"],
|
||
["zh-TW", "繁體中文"],
|
||
["en", "English"],
|
||
].map(([value, label]) => (
|
||
<button
|
||
key={value}
|
||
className={language === value ? "active" : ""}
|
||
onClick={() => setAppSettings({ ...appSettings, language: value as Language })}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="system-tools">
|
||
<span><b>{t("systemTools")}</b><small>{t("systemToolsDesc")}</small></span>
|
||
<div>
|
||
<button className="secondary-action" onClick={() => openSystemDestination("settings")}>⚙ {t("systemSettings")}</button>
|
||
<button className="secondary-action" onClick={() => openSystemDestination("files")}>▣ {t("fileManager")}</button>
|
||
</div>
|
||
</div>
|
||
<div className="settings-divider" />
|
||
<div className="settings-panel-title compact">
|
||
<span><b>{t("serverApi")}</b><small>{t("serverApiDesc")}</small></span>
|
||
</div>
|
||
<div className="settings-form-grid two">
|
||
{[
|
||
["employee", t("employeeApi")],
|
||
["machines", t("machineApi")],
|
||
["weight", t("scaleApi")],
|
||
["upload", t("uploadApi")],
|
||
].map(([key, label]) => (
|
||
<label key={key}>
|
||
<span>{label}</span>
|
||
<input
|
||
value={appSettings[key as keyof Pick<AppSettings, "employee" | "machines" | "weight" | "upload">] as string}
|
||
onChange={(event) => setAppSettings({ ...appSettings, [key]: event.target.value })}
|
||
placeholder={t("apiAddressPlaceholder")}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{settingsTab === "materials" && (
|
||
<>
|
||
<div className="settings-panel-title">
|
||
<span><b>{t("materialDict")}</b><small>{t("materialDictDesc")}</small></span>
|
||
<em>4 项</em>
|
||
</div>
|
||
<div className="material-editor">
|
||
{materialOptions.map((item, index) => (
|
||
<div className="material-editor-row" key={`${item.tone}-${index}`}>
|
||
<label><span>{t("displayName")}</span><input value={item.name} onChange={(event) => updateMaterial(index, "name", event.target.value)} /></label>
|
||
<label>
|
||
<span>Type ID</span>
|
||
<input
|
||
className="numeric-readonly"
|
||
value={item.id}
|
||
readOnly
|
||
inputMode="none"
|
||
aria-label={`${materialName(item)} Type ID`}
|
||
onClick={() => openNumericEditor({ kind: "material", index, title: `${materialName(item)} · Type ID`, maxLength: 8 }, item.id)}
|
||
/>
|
||
</label>
|
||
<i>0{index + 1}</i>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{settingsTab === "peripherals" && (
|
||
<>
|
||
<div className="settings-panel-title">
|
||
<span><b>{t("peripheralTitle")}</b><small>{t("peripheralDesc")}</small></span>
|
||
<em>{t("reserved")}</em>
|
||
</div>
|
||
<div className="feature-settings">
|
||
<div className="feature-row">
|
||
<span className="feature-icon">◎</span>
|
||
<span className="feature-copy"><b>{t("cameraTitle")}</b><small>{t("cameraDesc")}</small></span>
|
||
<label className="switch"><input type="checkbox" checked={appSettings.photoEnabled} onChange={(event) => setAppSettings({ ...appSettings, photoEnabled: event.target.checked })} /><span /></label>
|
||
</div>
|
||
<div className="path-box"><small>{t("photoPath")}</small><code>{IMAGE_PATH}</code><em>{t("fileName")}:YYYYMMDDHHmmss.jpg</em></div>
|
||
<div className="feature-row">
|
||
<span className="feature-icon">▤</span>
|
||
<span className="feature-copy"><b>{t("printTitle")}</b><small>{t("printDesc")}</small></span>
|
||
<label className="switch"><input type="checkbox" checked={appSettings.printEnabled} onChange={(event) => setAppSettings({ ...appSettings, printEnabled: event.target.checked })} /><span /></label>
|
||
</div>
|
||
<div className="print-fields">
|
||
{[t("employeeName"), t("employeeId"), t("machineNo"), t("materialType"), t("scrapWeight"), t("weighingTime")].map((item) => <span key={item}>✓ {item}</span>)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{settingsTab === "data" && (
|
||
<>
|
||
<div className="settings-panel-title">
|
||
<span><b>{t("dataTitle")}</b><small>{t("dataDesc")}</small></span>
|
||
<em>{t("autoProtect")}</em>
|
||
</div>
|
||
<div className="data-status-grid">
|
||
<div><span>↥</span><small>{t("pending")}</small><b>{pendingRecords.length}</b><em>{t("records")}</em></div>
|
||
<div><span>▣</span><small>{t("localBackup")}</small><b>{backupRecords.length}</b><em>{t("records")}</em></div>
|
||
</div>
|
||
<div className="path-box data-path"><small>{t("backupPath")}</small><code>{CACHE_PATH}</code><em>{t("autoWrite")}</em></div>
|
||
<div className="data-actions">
|
||
<button className="secondary-action" onClick={() => void retryPendingUploads()} disabled={settingsBusy}>
|
||
{settingsBusy ? t("retrying") : `↥ ${t("retryAll")}`}
|
||
</button>
|
||
<span>{t("retryHint")}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{settingsTab === "security" && (
|
||
<>
|
||
<div className="settings-panel-title">
|
||
<span><b>{t("adminPassword")}</b><small>{t("passwordAdvice")}</small></span>
|
||
<em>8–12 位</em>
|
||
</div>
|
||
<div className="password-settings">
|
||
{([
|
||
["current", t("currentPassword")],
|
||
["next", t("newPassword")],
|
||
["confirm", t("confirmPassword")],
|
||
] as [PasswordField, string][]).map(([field, label]) => (
|
||
<label key={field}>
|
||
<span>{label}</span>
|
||
<input
|
||
className="numeric-readonly"
|
||
type="password"
|
||
readOnly
|
||
inputMode="none"
|
||
maxLength={12}
|
||
value={passwordForm[field]}
|
||
aria-label={label}
|
||
onClick={() => openNumericEditor({ kind: "password", field, title: label, maxLength: 12 }, passwordForm[field])}
|
||
/>
|
||
</label>
|
||
))}
|
||
<button className="secondary-action" onClick={changeAdminPassword}>{t("updatePassword")}</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{settingsTab === "logs" && (
|
||
<>
|
||
<div className="settings-panel-title log-title">
|
||
<span><b><i className="live-dot" />{t("liveLogs")}</b><small>{t("logDesc")}</small></span>
|
||
<button className="secondary-action small" onClick={exportLogs}>↓ {t("exportLogs")}</button>
|
||
</div>
|
||
<div className="path-box log-path"><small>{t("androidLogPath")}</small><code>{LOG_PATH}</code><em>{t("dailySave")}</em></div>
|
||
<div className="log-viewer">
|
||
{logs.map((entry) => (
|
||
<div className={`log-entry ${entry.level}`} key={entry.id}>
|
||
<time>{new Date(entry.time).toLocaleTimeString("zh-CN", { hour12: false })}</time>
|
||
<span>{entry.level === "success" ? "成功" : entry.level === "warning" ? "警告" : entry.level === "error" ? "错误" : "信息"}</span>
|
||
<p>{entry.message}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
<div className="settings-footer-actions">
|
||
<span className={settingsMessage ? "settings-feedback visible" : "settings-feedback"}>{settingsMessage || t("saveHint")}</span>
|
||
<button className="save-settings" onClick={() => persistConfiguration()}>{t("saveAll")} <span>✓</span></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<footer><span>沃尔核材 · 数字化生产</span><span>{appSettings.deviceName} · {appSettings.deviceId} · v1.1</span></footer>
|
||
</section>
|
||
|
||
{passwordOpen && (
|
||
<div className="modal-backdrop" onMouseDown={closePasswordDialog}>
|
||
<div className="modal admin-keypad-modal" onMouseDown={(event) => event.stopPropagation()}>
|
||
<button className="modal-close" onClick={closePasswordDialog}>×</button>
|
||
<span className="modal-icon">⌁</span>
|
||
<h2>{t("passwordTitle")}</h2>
|
||
<p>{t("passwordDesc")}</p>
|
||
<div className={password ? "numeric-display masked" : "numeric-display placeholder"} role="textbox" aria-label="管理员密码" aria-readonly="true">
|
||
{password ? "●".repeat(password.length) : t("numberInput")}
|
||
</div>
|
||
<NumericKeypad value={password} onChange={(value) => { setPassword(value); setPasswordError(""); }} maxLength={12} clearLabel={t("numberClear")} deleteLabel={t("numberDelete")} />
|
||
{passwordError && <small className="password-error">{passwordError}</small>}
|
||
<button className="full-primary" onClick={enterAdmin}>{t("confirmLogin")} <span>→</span></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{numericEditor && (
|
||
<div className="modal-backdrop numeric-editor-backdrop" onMouseDown={() => setNumericEditor(null)}>
|
||
<div className="modal numeric-editor-modal" onMouseDown={(event) => event.stopPropagation()}>
|
||
<button className="modal-close" onClick={() => setNumericEditor(null)}>×</button>
|
||
<span className="modal-icon">#</span>
|
||
<h2>{numericEditor.title}</h2>
|
||
<div className={numericEditor.kind === "password" ? "numeric-display masked" : "numeric-display"} role="textbox" aria-label={numericEditor.title} aria-readonly="true">
|
||
{numericEditorValue
|
||
? numericEditor.kind === "password" ? "●".repeat(numericEditorValue.length) : numericEditorValue
|
||
: t("numberInput")}
|
||
</div>
|
||
<NumericKeypad value={numericEditorValue} onChange={setNumericEditorValue} maxLength={numericEditor.maxLength} clearLabel={t("numberClear")} deleteLabel={t("numberDelete")} />
|
||
<button className="full-primary" onClick={applyNumericEditor}>{t("numberConfirm")} <span>✓</span></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{machineModalOpen && (
|
||
<div className="modal-backdrop" onMouseDown={() => { setMachineKeypadOpen(false); setMachineModalOpen(false); }}>
|
||
<div className="modal machine-modal" onMouseDown={(event) => event.stopPropagation()}>
|
||
<button className="modal-close" onClick={() => { setMachineKeypadOpen(false); setMachineModalOpen(false); }}>×</button>
|
||
<div className="machine-modal-heading">
|
||
<span className="modal-icon">▣</span>
|
||
<span><h2>{t("machineDialogTitle")}</h2><p>{t("machineDialogDesc")}</p></span>
|
||
<button className="refresh-button" onClick={() => void refreshMachines()} disabled={machineLoading}>↻ {t("refreshMachine")}</button>
|
||
</div>
|
||
<div className="machine-search">
|
||
<span>⌕</span>
|
||
<input
|
||
className="machine-number-display"
|
||
type="text"
|
||
inputMode="none"
|
||
readOnly
|
||
value={machineQuery}
|
||
placeholder={t("searchMachine")}
|
||
aria-label={t("searchMachine")}
|
||
onFocus={() => setMachineKeypadOpen(true)}
|
||
onClick={() => setMachineKeypadOpen(true)}
|
||
/>
|
||
</div>
|
||
<div className={machineKeypadOpen ? "machine-modal-body keypad-open" : "machine-modal-body"}>
|
||
{machineKeypadOpen && <NumericKeypad value={machineQuery} onChange={setMachineQuery} maxLength={8} clearLabel={t("numberClear")} deleteLabel={t("numberDelete")} />}
|
||
<div className="machine-option-list">
|
||
{filteredMachines.map((item) => (
|
||
<button
|
||
key={item}
|
||
className={machine === item ? "selected" : ""}
|
||
onClick={() => {
|
||
setMachine(item);
|
||
setMachineQuery("");
|
||
setMachineKeypadOpen(false);
|
||
setMachineModalOpen(false);
|
||
}}
|
||
>
|
||
<span>▣</span><b>{item}</b><i>{machine === item ? "✓" : "→"}</i>
|
||
</button>
|
||
))}
|
||
{!filteredMachines.length && <p>{t("noMachine")}</p>}
|
||
</div>
|
||
</div>
|
||
<button className="machine-modal-close" onClick={() => { setMachineKeypadOpen(false); setMachineModalOpen(false); }}>{t("close")}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|