Files
SmartScale_MODBUS_Woer/app/page.tsx
T

1027 lines
59 KiB
TypeScript
Raw 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";
type Step = "home" | "identity" | "weigh" | "result" | "settings";
type Unit = "kg" | "g" | "lb";
type Language = "zh-CN" | "zh-TW" | "en";
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;
};
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: "868120060012345",
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: "重量已稳定", stabilizing: "正在稳定采样",
net: "净重", total: "累计", tareLabel: "皮重", zero: "清零", zeroDesc: "消除空秤误差", tareAction: "去皮", tareDesc: "扣除容器重量",
accumulate: "累计", accumulateDesc: "加入当前重量", cancelAccum: "取消累计", cancelAccumDesc: "撤销上一次累计", currentMaterial: "本次废料类型", confirmedHome: "已在首页确认",
uploading: "正在上传…", saveWeight: "保存称重数据", waitStable: "请等待重量稳定", weighComplete: "称重完成", dataSaved: "数据已保存", uploadQueue: "本次称重记录已进入上传队列",
receipt: "称重凭证", employeeName: "员工姓名", productionMachine: "生产机台", uploadStatus: "上传状态", saved: "已保存", 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: "请输入管理员密码进入应用设置", initialPassword: "初始密码为 88888888", confirmLogin: "确认登录",
employeeApi: "人员信息接口", machineApi: "机台列表接口", scaleApi: "电子秤数据接口", uploadApi: "称重上传接口",
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: "按日期滚动保存",
demoData: "当前使用模拟数据,接口接入后将自动切换", employeeError: "暂时无法连接本地服务器,请重新获取", machineError: "机台列表获取失败,请检查服务器连接", stableNeeded: "请等待重量稳定后再保存",
zeroed: "秤台已清零", tared: "已去皮,仅显示净重", accumulationNeeded: "重量稳定后才能累计", loginError: "密码不正确,默认密码为 88888888",
machineDialogTitle: "选择生产机台", machineDialogDesc: "支持输入机台号或名称进行模糊搜索", searchMachine: "搜索机台号或名称", close: "关闭",
},
"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: "重量已穩定", stabilizing: "正在穩定採樣",
net: "淨重", total: "累計", tareLabel: "皮重", zero: "清零", zeroDesc: "消除空秤誤差", tareAction: "去皮", tareDesc: "扣除容器重量",
accumulate: "累計", accumulateDesc: "加入目前重量", cancelAccum: "取消累計", cancelAccumDesc: "撤銷上一次累計", currentMaterial: "本次廢料類型", confirmedHome: "已在首頁確認",
uploading: "正在上傳…", saveWeight: "儲存稱重資料", waitStable: "請等待重量穩定", weighComplete: "稱重完成", dataSaved: "資料已儲存", uploadQueue: "本次稱重記錄已進入上傳佇列",
receipt: "稱重憑證", employeeName: "員工姓名", productionMachine: "生產機台", uploadStatus: "上傳狀態", saved: "已儲存", 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: "請輸入管理員密碼進入應用設定", initialPassword: "初始密碼為 88888888", confirmLogin: "確認登入",
employeeApi: "人員資訊介面", machineApi: "機台列表介面", scaleApi: "電子秤資料介面", uploadApi: "稱重上傳介面",
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: "依日期滾動儲存",
demoData: "目前使用模擬資料,介面接入後將自動切換", employeeError: "暫時無法連接本地伺服器,請重新取得", machineError: "機台列表取得失敗,請檢查伺服器連線", stableNeeded: "請等待重量穩定後再儲存",
zeroed: "秤台已清零", tared: "已去皮,僅顯示淨重", accumulationNeeded: "重量穩定後才能累計", loginError: "密碼不正確,預設密碼為 88888888",
machineDialogTitle: "選擇生產機台", machineDialogDesc: "支援輸入機台號或名稱進行模糊搜尋", searchMachine: "搜尋機台號或名稱", close: "關閉",
},
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", stabilizing: "Stabilizing samples",
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: "This record has entered the upload queue",
receipt: "Weighing receipt", employeeName: "Employee name", productionMachine: "Production machine", uploadStatus: "Upload status", saved: "Saved", 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: "Leave blank to use demo data",
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", initialPassword: "Initial password: 88888888", confirmLogin: "Log in",
employeeApi: "Operator API", machineApi: "Machine list API", scaleApi: "Scale data API", uploadApi: "Record upload API",
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",
demoData: "Using demo data until the local API is connected", employeeError: "Cannot reach the local server. Please refresh.", machineError: "Could not load machines. Check the server connection.", stableNeeded: "Wait for a stable weight before saving",
zeroed: "Scale zeroed", tared: "Tare applied; showing net weight", accumulationNeeded: "Weight must be stable before accumulation", loginError: "Incorrect password. Default: 88888888",
machineDialogTitle: "Select production machine", machineDialogDesc: "Search by machine number or name", searchMachine: "Search machine number or name", close: "Close",
},
};
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/";
const machines = ["A01 绕线机", "A02 押出机", "B03 裁线机", "B06 编织机", "C12 包装机"];
const unitFactors: Record<Unit, number> = { kg: 1, g: 1000, lb: 2.20462262 };
function formatWeight(valueKg: number, unit: Unit) {
const value = Math.max(0, valueKg) * unitFactors[unit];
const digits = unit === "g" ? 0 : 3;
return value.toFixed(digits);
}
function createFileTimestamp(iso: string) {
return iso.replace(/\D/g, "").slice(0, 14);
}
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[]>(machines);
const [machineModalOpen, setMachineModalOpen] = 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 [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 [pendingRecords, setPendingRecords] = useState<WeightRecord[]>([]);
const [backupRecords, setBackupRecords] = useState<WeightRecord[]>([]);
const [lastRecord, setLastRecord] = useState<WeightRecord | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([
{
id: "boot",
time: new Date().toISOString(),
level: "success",
message: "应用启动完成,称重设备连接正常",
},
]);
const stableSamples = useRef<number[]>([]);
const netWeight = Math.max(0, rawWeight - zeroOffset - tare);
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 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(() => {
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: Material) => ({ id: item.id, name: item.name, tone: item.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) setPendingRecords(JSON.parse(savedPending));
if (savedBackup) setBackupRecords(JSON.parse(savedBackup));
if (savedLogs) setLogs(JSON.parse(savedLogs));
} catch {
addLog("本地配置读取失败,已使用默认设置", "warning");
}
}, []);
useEffect(() => {
if (step !== "weigh") return;
const start = Date.now();
const timer = window.setInterval(() => {
const elapsed = (Date.now() - start) / 1000;
const target = elapsed < 1.4 ? 0 : 2.486;
const movement = elapsed < 3 ? Math.sin(elapsed * 9) * 0.085 : Math.sin(elapsed * 3) * 0.0012;
setRawWeight(Math.max(0, target + movement));
}, 180);
return () => window.clearInterval(timer);
}, [step]);
useEffect(() => {
if (step !== "weigh") return;
stableSamples.current = [...stableSamples.current.slice(-7), netWeight];
const samples = stableSamples.current;
if (samples.length < 6 || netWeight < 0.01) {
setStable(false);
setStableProgress(Math.min(85, samples.length * 14));
return;
}
const spread = Math.max(...samples) - Math.min(...samples);
const isStable = spread < 0.008;
setStable(isStable);
setStableProgress(isStable ? 100 : Math.max(25, 90 - spread * 500));
}, [netWeight, step]);
useEffect(() => {
if (step !== "result") return;
setCountdown(10);
const timer = window.setInterval(() => {
setCountdown((value) => {
if (value <= 1) {
window.clearInterval(timer);
resetToHome();
return 0;
}
return value - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [step]);
function resetToHome() {
setStep("home");
setMaterial(null);
setMachine("");
setMachineQuery("");
setTotalKg(0);
setRawWeight(0);
setZeroOffset(0);
setTare(0);
setStable(false);
setLastAccumulation(null);
stableSamples.current = [];
}
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,
};
setLogs((current) => {
const next = [entry, ...current].slice(0, 100);
try {
window.localStorage.setItem(LOG_KEY, JSON.stringify(next));
} catch {
// Native Android builds write to LOG_PATH through the reserved bridge.
}
return next;
});
}
function persistConfiguration(nextPassword = adminPassword) {
window.localStorage.setItem(
CONFIG_KEY,
JSON.stringify({
version: 2,
settings: appSettings,
materials: materialOptions,
password: nextPassword,
}),
);
addLog("管理员设置已保存", "success");
setSettingsMessage("全部设置已保存,本机配置已更新");
}
function updateMaterial(index: number, field: "name" | "id", value: string) {
setMaterialOptions((current) =>
current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)),
);
setSettingsMessage("");
}
async function fetchIdentity(chosenMaterial?: Material) {
const activeMaterial = chosenMaterial ?? material;
if (!activeMaterial) {
setNotice("请先选择本次称重的废料类型");
return;
}
if (chosenMaterial) setMaterial(chosenMaterial);
setLoading(true);
setNotice("");
try {
if (appSettings.employee) {
const response = await fetch(appSettings.employee);
if (!response.ok) throw new Error("接口不可用");
const data = await response.json();
setEmployee({ name: data.name, id: data.employeeId });
} else {
await new Promise((resolve) => window.setTimeout(resolve, 650));
setEmployee({ name: "张伟", id: "E-0826" });
setNotice(t("demoData"));
}
addLog(`人员信息获取成功:${employee.name || "张伟"}`, "success");
setStep("identity");
} catch {
addLog("门禁人员信息获取失败", "error");
setNotice(t("employeeError"));
setStep("identity");
} finally {
setLoading(false);
}
}
async function refreshMachines() {
setMachineLoading(true);
setSettingsMessage("");
try {
if (appSettings.machines) {
const response = await fetch(appSettings.machines);
if (!response.ok) throw new Error("machine api unavailable");
const data = await response.json();
const next = Array.isArray(data) ? data : data.machines;
if (!Array.isArray(next)) throw new Error("invalid machine data");
setMachineOptions(next.map((item: string | { id?: string; name?: string }) =>
typeof item === "string" ? item : `${item.id ?? ""} ${item.name ?? ""}`.trim(),
));
} else {
await new Promise((resolve) => window.setTimeout(resolve, 550));
setMachineOptions(machines);
}
addLog("生产机台列表刷新成功", "success");
} catch {
setNotice(t("machineError"));
addLog("生产机台列表刷新失败", "error");
} finally {
setMachineLoading(false);
}
}
function enterAdmin() {
if (password === adminPassword) {
setPasswordOpen(false);
setPassword("");
setPasswordError("");
setSettingsMessage("");
addLog("管理员登录成功", "success");
setStep("settings");
} else {
addLog("管理员登录失败:密码错误", "warning");
setPasswordError(t("loginError"));
}
}
function backupRecord(record: WeightRecord) {
setBackupRecords((current) => {
const next = [...current, record].slice(-500);
window.localStorage.setItem(BACKUP_KEY, JSON.stringify(next));
return next;
});
addLog(`称重数据已备份至 ${CACHE_PATH}`, "success");
}
function queuePendingRecord(record: WeightRecord) {
setPendingRecords((current) => {
const next = [...current, record];
window.localStorage.setItem(PENDING_KEY, JSON.stringify(next));
return next;
});
addLog("上传失败,数据已加入补传队列", "warning");
}
function runCompletionHooks(record: WeightRecord) {
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(3)} 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(3)),
createdAt: new Date().toISOString(),
};
setLastRecord(payload);
backupRecord(payload);
try {
if (appSettings.upload) {
const response = await fetch(appSettings.upload, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("上传失败");
} else {
await new Promise((resolve) => window.setTimeout(resolve, 700));
}
addLog(`称重数据上传成功:${payload.weightKg.toFixed(3)} kg`, "success");
runCompletionHooks(payload);
setStep("result");
} catch {
queuePendingRecord(payload);
setNotice("数据上传失败,请检查服务器连接后重试");
} finally {
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;
}
setSettingsBusy(true);
setSettingsMessage("");
try {
if (appSettings.upload) {
for (const record of pendingRecords) {
const response = await fetch(appSettings.upload, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(record),
});
if (!response.ok) throw new Error("补传失败");
}
} else {
await new Promise((resolve) => window.setTimeout(resolve, 900));
}
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;
}
setAdminPassword(passwordForm.next);
persistConfiguration(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 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 = `smart-scale-${createFileTimestamp(new Date().toISOString())}.log`;
link.click();
URL.revokeObjectURL(url);
addLog(`日志已导出,目标目录 ${LOG_PATH}`, "success");
setSettingsMessage(`日志已导出,Android 设备保存至 ${LOG_PATH}`);
}
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={() => 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.id}
className={`material-card ${item.tone} ${material?.id === item.id ? "selected" : ""}`}
onClick={() => void fetchIdentity(item)}
disabled={loading}
aria-pressed={material?.id === item.id}
>
<span><b>{materialName(item)}</b><small>TYPE ID · {item.id}</small></span>
<i>{material?.id === item.id ? "✓" : "→"}</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(""); 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={() => { setNotice(""); setStep("weigh"); }}
>
{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 /> {stable ? t("stable") : t("stabilizing")}
</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); setTotalKg(0); setLastAccumulation(null); 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("uploadQueue")}</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(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="upload-ok">● {t("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="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"), "http://192.168.1.20/api/employee"],
["machines", t("machineApi"), "http://192.168.1.20/api/machines"],
["weight", t("scaleApi"), "ws://192.168.1.21/weight"],
["upload", t("uploadApi"), "http://192.168.1.20/api/records"],
].map(([key, label, placeholder]) => (
<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={placeholder}
/>
</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 value={item.id} onChange={(event) => updateMaterial(index, "id", event.target.value.toUpperCase())} /></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">
<label><span>{t("currentPassword")}</span><input type="password" inputMode="numeric" maxLength={12} value={passwordForm.current} onChange={(event) => setPasswordForm({ ...passwordForm, current: event.target.value.replace(/\D/g, "") })} /></label>
<label><span>{t("newPassword")}</span><input type="password" inputMode="numeric" maxLength={12} value={passwordForm.next} onChange={(event) => setPasswordForm({ ...passwordForm, next: event.target.value.replace(/\D/g, "") })} /></label>
<label><span>{t("confirmPassword")}</span><input type="password" inputMode="numeric" maxLength={12} value={passwordForm.confirm} onChange={(event) => setPasswordForm({ ...passwordForm, confirm: event.target.value.replace(/\D/g, "") })} /></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={() => setPasswordOpen(false)}>
<div className="modal" onMouseDown={(event) => event.stopPropagation()}>
<button className="modal-close" onClick={() => setPasswordOpen(false)}>×</button>
<span className="modal-icon">⌁</span>
<h2>{t("passwordTitle")}</h2>
<p>{t("passwordDesc")}<br />{t("initialPassword")}</p>
<input type="password" inputMode="numeric" autoFocus maxLength={12} value={password} onChange={(event) => setPassword(event.target.value.replace(/\D/g, ""))} onKeyDown={(event) => event.key === "Enter" && enterAdmin()} placeholder="••••••••" aria-label="管理员密码" />
{passwordError && <small className="password-error">{passwordError}</small>}
<button className="full-primary" onClick={enterAdmin}>{t("confirmLogin")} <span>→</span></button>
</div>
</div>
)}
{machineModalOpen && (
<div className="modal-backdrop" onMouseDown={() => setMachineModalOpen(false)}>
<div className="modal machine-modal" onMouseDown={(event) => event.stopPropagation()}>
<button className="modal-close" onClick={() => 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 value={machineQuery} onChange={(event) => setMachineQuery(event.target.value)} placeholder={t("searchMachine")} autoFocus />
</div>
<div className="machine-option-list">
{filteredMachines.map((item) => (
<button
key={item}
className={machine === item ? "selected" : ""}
onClick={() => {
setMachine(item);
setMachineQuery("");
setMachineModalOpen(false);
}}
>
<span>▣</span><b>{item}</b><i>{machine === item ? "✓" : "→"}</i>
</button>
))}
{!filteredMachines.length && <p>{t("noMachine")}</p>}
</div>
<button className="machine-modal-close" onClick={() => setMachineModalOpen(false)}>{t("close")}</button>
</div>
</div>
)}
</main>
);
}