Files

1354 lines
73 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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: "请输入完整接口地址", 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 (
<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 [testEmployeeIndex, setTestEmployeeIndex] = useState(0);
const [machineQuery, setMachineQuery] = useState("");
const [machine, setMachine] = useState("");
const [machineOptions, setMachineOptions] = useState<string[]>(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<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[]>(TEST_WEIGHT_RECORDS);
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 {
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);
}, []);
/*
* 称重数据通过 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<ModbusEventDetail>).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 (
<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>
</header>
<div className="content">
{step === "home" && (
<div className="home-view">
<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>
<button className="admin-link-fab" onClick={() => setPasswordOpen(true)}>
{t("adminLogin")} <span>⌁</span>
</button>
</div>
)}
{step === "identity" && (
<div className="panel-view identity-view">
<button className="back-button identity-back" onClick={resetToHome}>← {t("back")}</button>
<div className="section-heading">
<div>
<h2>{t("confirmInfo")}</h2>
</div>
</div>
<div className={`selected-material-summary ${selectedMaterial.tone} identity-material-compact`}>
<small>{t("currentMaterial")}</small><b>{materialName(selectedMaterial)}</b><em>TYPE ID · {selectedMaterial.id}</em><i>✓</i>
</div>
<div className="identity-layout">
<section className="identity-person-panel">
<div className="identity-panel-heading">
<span>{t("currentEmployee")}</span>
<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>
</section>
<section className="identity-operation-panel">
<div className="identity-panel-heading"><span>{t("machineNo")}</span></div>
<div className={machine ? "identity-card identity-machine-card selected" : "identity-card identity-machine-card"}>
<div className="avatar">▣</div>
<div><small>{machine ? t("selectedMachine") : t("machineNo")}</small><h3>{machine || t("noMachine")}</h3><p>{machine ? t("selectedMachine") : t("selectMachine")}</p></div>
<button className="machine-select-action" onClick={() => { setMachineQuery(""); setMachineKeypadOpen(false); setMachineModalOpen(true); }}>{t("selectMachine")}</button>
</div>
</section>
</div>
<button className="full-primary identity-confirm" 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={() => { const sent = sendScaleCommand("zero"); setZeroOffset(rawWeight); setTare(0); setNotice(sent ? t("zeroed") : "清零指令发送失败,请检查电子秤后台服务"); }}>⟲<span><b>{t("zero")}</b><small>{t("zeroDesc")}</small></span></button>
<button onClick={() => { const sent = sendScaleCommand("tare"); setTare(Math.max(0, rawWeight - zeroOffset)); setNotice(sent ? 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>
{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={key === "weight" ? t("modbusConfigPlaceholder") : t("apiAddressPlaceholder")}
/>
</label>
))}
</div>
<div className="path-box data-path">
<small>{t("scaleApi")}</small>
<code>{t("modbusConfigHint")}</code>
</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 className="production-footer">沃尔核材 · 数字化生产</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>
);
}