From af50188332be22083202e023d6623092a97f55b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 13:01:28 +0800 Subject: [PATCH] Refine weighing flow and add localization --- app/globals.css | 72 ++++++-- app/page.tsx | 461 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 378 insertions(+), 155 deletions(-) diff --git a/app/globals.css b/app/globals.css index 03c0c32..862f8c3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -73,8 +73,8 @@ h1 em { font-style: normal; color: var(--green); } .home-start-hint span { width: 26px; height: 26px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--green); color: white; font-size: 9px; } .home-start-hint.loading span { animation: pulse 1s ease-in-out infinite; } @keyframes pulse { 50% { opacity: .45; transform: scale(.92); } } -.admin-link { margin-top: 15px; margin-left: 8px; border: 0; background: none; color: #68746e; font-size: 12px; } -.admin-link:hover { color: var(--green); } +.admin-link { min-width: 190px; display: flex; align-items: center; justify-content: space-between; margin-top: 30px; border: 1px solid #cbd8d1; border-radius: 11px; background: rgba(255,255,255,.78); color: #53625a; padding: 11px 14px; font-size: 11px; font-weight: 800; box-shadow: 0 6px 18px rgba(30,55,43,.05); } +.admin-link:hover { color: var(--green); border-color: #9fc5b5; background: white; } .home-notice { width: min(390px, 100%); } .home-stats { margin-top: 36px; display: flex; gap: 32px; color: #849088; font-size: 9px; } .home-stats span { padding-left: 12px; border-left: 2px solid #e0e6e1; } @@ -84,13 +84,15 @@ h1 em { font-style: normal; color: var(--green); } .material-panel-heading h2 { margin: 7px 0 5px; font-size: 23px; letter-spacing: -.035em; } .material-panel-heading p { margin: 0 0 16px; color: var(--muted); font-size: 10px; } .home-material-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } -.home-material-grid .material-card { min-height: 73px; } +.home-material-grid .material-card { min-height: 82px; } .material-selection-status { display: flex; align-items: center; gap: 8px; margin-top: 13px; padding: 9px 11px; border-radius: 9px; background: #eef1ee; color: #7c8781; font-size: 9px; font-weight: 700; } .material-selection-status span { width: 19px; height: 19px; display: grid; place-items: center; border: 1px solid #bfc8c2; border-radius: 50%; font-size: 8px; } .material-selection-status.ready { background: var(--mint); color: var(--green); } .material-selection-status.ready span { border-color: var(--green); background: var(--green); color: white; } .panel-view { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 35px 0; } +.back-button { display: inline-flex; align-items: center; gap: 5px; margin: 0 0 12px; border: 0; background: transparent; color: #6f7b74; padding: 4px 0; font-size: 10px; font-weight: 800; } +.back-button:hover { color: var(--green); } .section-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 24px; } .section-heading h2, .result-view h2 { margin: 7px 0 7px; font-size: 29px; letter-spacing: -.035em; } .section-heading p, .result-view > p { margin: 0; color: var(--muted); font-size: 12px; } @@ -107,6 +109,15 @@ h1 em { font-style: normal; color: var(--green); } .machine-picker:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.08); } .machine-picker input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 13px; } .machine-picker b { color: var(--green); } +.machine-select-row { min-height: 62px; display: flex; align-items: center; gap: 12px; border: 1px solid #cfd8d2; border-radius: 12px; background: white; padding: 10px 12px; } +.machine-select-row.selected { border-color: #a9cbbb; background: #fbfefc; } +.machine-select-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: #e9f4ef; color: var(--green); } +.machine-select-row > span:nth-child(2) { flex: 1; } +.machine-select-row small, .machine-select-row b { display: block; } +.machine-select-row small { color: #89948e; font-size: 8px; margin-bottom: 4px; } +.machine-select-row b { font-size: 12px; } +.machine-select-row > button { border: 0; border-radius: 8px; background: var(--green); color: white; padding: 9px 12px; font-size: 9px; font-weight: 800; } +.machine-select-row > button.machine-refresh { border: 1px solid #cbd8d1; background: white; color: #65736b; } .suggestions { margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 5px; box-shadow: 0 15px 40px rgba(32,55,44,.1); } .suggestions button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 8px; background: transparent; padding: 10px; text-align: left; font-size: 12px; } .suggestions button:hover { background: #eef7f2; color: var(--green); } @@ -117,7 +128,8 @@ h1 em { font-style: normal; color: var(--green); } .notice { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: #f7edd8; color: #8b671e; font-size: 10px; } .notice.compact { margin: 10px 0 -10px; } -.weigh-view { padding: 22px 32px 28px; } +.weigh-view { padding: 14px 32px 28px; } +.weigh-back { margin-bottom: 7px; } .weigh-meta { display: flex; gap: 0; border: 1px solid var(--line); background: white; border-radius: 11px; padding: 10px 14px; margin-bottom: 14px; } .weigh-meta > span { min-width: 170px; padding-right: 18px; margin-right: 18px; border-right: 1px solid var(--line); } .weigh-meta > span.material-meta { min-width: 230px; } @@ -142,28 +154,31 @@ h1 em { font-style: normal; color: var(--green); } .display-details { display: flex; justify-content: center; gap: 42px; margin-top: 12px; color: #789187; font-size: 9px; } .display-details b { color: #dbe8e1; margin-left: 5px; } .scale-tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 11px 0 16px; } +.scale-tools.has-cancel { grid-template-columns: repeat(4, 1fr); } .scale-tools button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 10px 13px; color: var(--green); text-align: left; font-size: 18px; } .scale-tools button:hover { border-color: #a8cabb; } +.scale-tools button.cancel-total { border-color: #e3c3bd; color: #b65748; background: #fff8f6; } +.scale-tools button.cancel-total b { color: #a6493c; } .scale-tools span, .scale-tools b, .scale-tools small { display: block; } .scale-tools b { color: var(--ink); font-size: 11px; } .scale-tools small { color: #909a94; font-size: 8px; margin-top: 3px; } -.material-card { min-width: 0; display: flex; align-items: center; gap: 10px; position: relative; border: 1px solid var(--line); border-radius: 11px; background: white; padding: 12px 10px; text-align: left; } +.material-card { min-width: 0; display: flex; align-items: center; gap: 10px; position: relative; border: 1px solid var(--line); border-radius: 11px; background: white; padding: 14px 13px; text-align: left; } .material-card:hover { transform: translateY(-1px); } -.material-card.selected { border: 2px solid var(--green); background: #f3faf7; padding: 11px 9px; } +.material-card.selected { border: 2px solid var(--green); background: #f3faf7; padding: 13px 12px; } .material-icon { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; font-size: 12px; font-weight: 900; background: #edf1ed; color: #536259; } .material-card.copper .material-icon { background: #f3e2d6; color: #a65e2e; } .material-card.wire .material-icon { background: #f3ead2; color: #b27714; } .material-card.aluminum .material-icon { background: #e5ecee; color: #60747c; } .material-card.tin .material-icon { background: #e5e6ed; color: #62687c; } .material-card b, .material-card small { display: block; white-space: nowrap; } -.material-card b { font-size: 11px; } -.material-card small { margin-top: 4px; color: #9aa39e; font-size: 6px; } +.material-card b { font-size: 16px; letter-spacing: -.02em; } +.material-card small { margin-top: 6px; color: #87928c; font-size: 8px; font-weight: 700; } .material-card i { margin-left: auto; font-size: 10px; color: var(--green); font-style: normal; } -.selected-material-summary { display: flex; align-items: center; gap: 12px; border: 1px solid #bedacd; border-radius: 12px; background: #f3faf7; padding: 12px 15px; } +.selected-material-summary { display: flex; align-items: center; gap: 12px; border: 1px solid #bedacd; border-radius: 12px; background: #f3faf7; padding: 13px 16px; } .selected-material-summary > span:nth-child(2) { flex: 1; } .selected-material-summary small, .selected-material-summary b, .selected-material-summary em { display: block; } .selected-material-summary small { color: #7a8a82; font-size: 8px; } -.selected-material-summary b { margin: 2px 0; font-size: 14px; } +.selected-material-summary b { margin: 3px 0; font-size: 18px; } .selected-material-summary em { color: #8c9891; font-size: 7px; font-style: normal; } .selected-material-summary > i { color: var(--green); font-size: 9px; font-style: normal; font-weight: 800; } .full-primary.upload { margin-top: 18px; } @@ -216,8 +231,15 @@ h1 em { font-style: normal; color: var(--green); } .settings-form-grid input, .password-settings input, .material-editor-row input { width: 100%; height: 38px; border: 1px solid #d9e0dc; border-radius: 8px; background: #fafbf9; padding: 0 11px; outline: 0; font-size: 10px; color: var(--ink); } .settings-form-grid input:focus, .password-settings input:focus, .material-editor-row input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.06); background: white; } .settings-divider { height: 1px; margin: 18px 0 14px; background: var(--line); } +.language-setting { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-top: 14px; padding: 11px 12px; border: 1px solid #e0e6e2; border-radius: 10px; background: #fbfcfb; } +.language-setting > span b, .language-setting > span small { display: block; } +.language-setting > span b { font-size: 10px; } +.language-setting > span small { margin-top: 4px; color: #89948e; font-size: 8px; } +.language-setting > div { display: flex; gap: 4px; padding: 3px; border-radius: 8px; background: #edf2ef; } +.language-setting button { border: 0; border-radius: 6px; background: transparent; color: #718078; padding: 7px 9px; font-size: 8px; } +.language-setting button.active { background: white; color: var(--green); font-weight: 800; box-shadow: 0 2px 8px rgba(30,55,43,.08); } .material-editor { display: grid; gap: 9px; } -.material-editor-row { display: grid; grid-template-columns: 38px 1fr 1.45fr 22px; align-items: end; gap: 12px; padding: 10px 12px; border: 1px solid #e1e7e3; border-radius: 10px; background: #fbfcfb; } +.material-editor-row { display: grid; grid-template-columns: 1fr 1.45fr 22px; align-items: end; gap: 12px; padding: 10px 12px; border: 1px solid #e1e7e3; border-radius: 10px; background: #fbfcfb; } .material-editor-row > i { align-self: center; color: #b2bbb6; font-size: 9px; font-style: normal; font-weight: 800; } .material-icon.copper { background: #f3e2d6; color: #a65e2e; } .material-icon.wire { background: #f3ead2; color: #b27714; } @@ -283,6 +305,23 @@ h1 em { font-style: normal; color: var(--green); } .modal > input { width: 100%; margin-top: 12px; border: 1px solid #cfd8d2; border-radius: 11px; padding: 13px; text-align: center; font-size: 22px; letter-spacing: .45em; outline: 0; } .modal > input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.08); } .password-error { display: block; color: #b44b3e; margin-top: 8px; font-size: 9px; } +.machine-modal { width: min(560px, 100%); text-align: left; } +.machine-modal-heading { display: flex; align-items: center; gap: 12px; padding-right: 24px; } +.machine-modal-heading .modal-icon { margin: 0; flex: 0 0 auto; } +.machine-modal-heading > span:nth-child(2) { flex: 1; } +.machine-modal-heading h2 { font-size: 20px; } +.machine-modal-heading p { margin: 4px 0 0; } +.machine-search { height: 44px; display: flex; align-items: center; gap: 9px; margin-top: 18px; border: 1px solid #cfd8d2; border-radius: 10px; padding: 0 12px; } +.machine-search:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.07); } +.machine-search input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 11px; } +.machine-option-list { max-height: 280px; overflow-y: auto; display: grid; gap: 6px; margin-top: 10px; } +.machine-option-list > button { width: 100%; display: flex; align-items: center; gap: 11px; border: 1px solid #e0e6e2; border-radius: 9px; background: #fbfcfb; padding: 11px 12px; text-align: left; } +.machine-option-list > button:hover, .machine-option-list > button.selected { border-color: #a8cab9; background: #eff8f3; color: var(--green); } +.machine-option-list > button > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: #e7f1ec; } +.machine-option-list > button b { flex: 1; font-size: 11px; } +.machine-option-list > button i { font-size: 10px; font-style: normal; } +.machine-option-list > p { text-align: center; color: #8a958f; font-size: 10px; } +.machine-modal-close { width: 100%; margin-top: 12px; border: 1px solid #d4ddd7; border-radius: 9px; background: white; color: #68756e; padding: 10px; font-size: 9px; } @media (max-width: 760px) { .app-shell { padding: 0; display: block; } @@ -295,7 +334,6 @@ h1 em { font-style: normal; color: var(--green); } h1 { font-size: 48px; } .home-material-panel { margin-top: 30px; } .home-start-hint { width: 100%; } - .home-stats { gap: 15px; justify-content: space-between; } .panel-view { width: calc(100% - 32px); padding: 27px 0; } .section-heading { display: block; } .refresh-button { margin-top: 15px; } @@ -306,15 +344,21 @@ h1 em { font-style: normal; color: var(--green); } .scale-tools button { justify-content: center; padding: 10px 5px; } .scale-tools button > span { display: none; } .weigh-meta > span.material-meta { display: none; } + .machine-select-row { flex-wrap: wrap; } + .machine-select-row > span:nth-child(2) { min-width: calc(100% - 55px); } + .machine-select-row > button { flex: 1; } .settings-view { width: calc(100% - 24px); } .settings-layout { grid-template-columns: 1fr; } .settings-tabs { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); padding: 7px; } .settings-tabs button { min-width: 112px; justify-content: center; } .settings-panel { padding: 17px 14px; } .settings-form-grid.three, .settings-form-grid.two { grid-template-columns: 1fr; } - .material-editor-row { grid-template-columns: 34px 1fr; } - .material-editor-row label:nth-of-type(2) { grid-column: 2; } + .material-editor-row { grid-template-columns: 1fr; } + .material-editor-row label:nth-of-type(2) { grid-column: 1; } .material-editor-row > i { display: none; } + .language-setting { align-items: stretch; flex-direction: column; } + .language-setting > div { width: 100%; } + .language-setting button { flex: 1; } .path-box { margin-left: 0; grid-template-columns: 1fr; gap: 4px; } .print-fields { margin-left: 0; } .password-settings { grid-template-columns: 1fr; } diff --git a/app/page.tsx b/app/page.tsx index d6b3242..d88e1a9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,12 +4,12 @@ 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; - icon: string; }; type AppSettings = { employee: string; @@ -21,6 +21,7 @@ type AppSettings = { imei: string; photoEnabled: boolean; printEnabled: boolean; + language: Language; }; type WeightRecord = { employeeName: string; @@ -39,10 +40,10 @@ type LogEntry = { }; const DEFAULT_MATERIALS: Material[] = [ - { id: "SCRAP_CU_01", name: "废铜", tone: "copper", icon: "Cu" }, - { id: "SCRAP_WIRE_02", name: "废电线", tone: "wire", icon: "⚡" }, - { id: "SCRAP_AL_03", name: "废铝", tone: "aluminum", icon: "Al" }, - { id: "SCRAP_SN_04", name: "废锡", tone: "tin", icon: "Sn" }, + { id: "3", name: "废铜", tone: "copper" }, + { id: "4", name: "废电线", tone: "wire" }, + { id: "5", name: "废铝", tone: "aluminum" }, + { id: "6", name: "废锡", tone: "tin" }, ]; const DEFAULT_SETTINGS: AppSettings = { @@ -55,6 +56,97 @@ const DEFAULT_SETTINGS: AppSettings = { imei: "868120060012345", photoEnabled: false, printEnabled: false, + language: "zh-CN", +}; + +const TRANSLATIONS: Record> = { + "zh-CN": { + brandTitle: "智能称重终端", online: "设备在线", progressEmployee: "人员确认", progressWeight: "智能称重", progressResult: "结果上传", + heroEyebrow: "安全 · 精准 · 可追溯", heroTitle1: "废料称重,", heroTitle2: "简单一点。", heroDesc1: "选择废料类型后开始,系统将自动识别员工信息,", heroDesc2: "稳定采样并上传称重记录。", + clickMaterial: "点击右侧废料类型,即刻开始", adminLogin: "管理员登录", prep: "称重前准备", chooseMaterial: "选择废料类型", chooseMaterialDesc: "请选择本次需要称重的废料", + loadingEmployee: "正在读取门禁人员信息", chooseAuto: "请选择一种废料类型,选择后自动进入下一步", employeeMachine: "人员与机台", confirmInfo: "确认本次称重信息", + identityDesc: "人员信息由门禁机自动获取,请选择对应生产机台。", refreshEmployee: "重新获取", currentEmployee: "当前操作员工", employeeId: "工号", verified: "已验证", + back: "返回", machineNo: "生产机台号", selectMachine: "选择机台", refreshMachine: "刷新机台", selectedMachine: "已选择机台", noMachine: "尚未选择生产机台", + confirmWeigh: "确认并进入称重", employee: "员工", machine: "机台", materialType: "废料类型", editIdentity: "修改人员/机台", stable: "重量已稳定", 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"; @@ -84,6 +176,9 @@ export default function Home() { const [employee, setEmployee] = useState({ name: "", id: "" }); const [machineQuery, setMachineQuery] = useState(""); const [machine, setMachine] = useState(""); + const [machineOptions, setMachineOptions] = useState(machines); + const [machineModalOpen, setMachineModalOpen] = useState(false); + const [machineLoading, setMachineLoading] = useState(false); const [loading, setLoading] = useState(false); const [notice, setNotice] = useState(""); const [unit, setUnit] = useState("kg"); @@ -91,6 +186,7 @@ export default function Home() { const [zeroOffset, setZeroOffset] = useState(0); const [tare, setTare] = useState(0); const [totalKg, setTotalKg] = useState(0); + const [lastAccumulation, setLastAccumulation] = useState<{ total: number; zero: number; tare: number } | null>(null); const [material, setMaterial] = useState(null); const [stable, setStable] = useState(false); const [stableProgress, setStableProgress] = useState(0); @@ -121,13 +217,24 @@ export default function Home() { 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> = { + "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( () => - machines.filter((item) => + machineOptions.filter((item) => item.toLowerCase().includes(machineQuery.trim().toLowerCase()), ), - [machineQuery], + [machineOptions, machineQuery], ); useEffect(() => { @@ -136,7 +243,11 @@ export default function Home() { if (savedConfig) { const parsed = JSON.parse(savedConfig); if (parsed.settings) setAppSettings({ ...DEFAULT_SETTINGS, ...parsed.settings }); - if (Array.isArray(parsed.materials) && parsed.materials.length === 4) setMaterialOptions(parsed.materials); + 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); @@ -203,6 +314,7 @@ export default function Home() { setZeroOffset(0); setTare(0); setStable(false); + setLastAccumulation(null); stableSamples.current = []; } @@ -228,6 +340,7 @@ export default function Home() { window.localStorage.setItem( CONFIG_KEY, JSON.stringify({ + version: 2, settings: appSettings, materials: materialOptions, password: nextPassword, @@ -262,19 +375,45 @@ export default function Home() { } else { await new Promise((resolve) => window.setTimeout(resolve, 650)); setEmployee({ name: "张伟", id: "E-0826" }); - setNotice("当前使用模拟数据,接口接入后将自动切换"); + setNotice(t("demoData")); } addLog(`人员信息获取成功:${employee.name || "张伟"}`, "success"); setStep("identity"); } catch { addLog("门禁人员信息获取失败", "error"); - setNotice("暂时无法连接本地服务器,请点击“重新获取”"); + 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); @@ -285,7 +424,7 @@ export default function Home() { setStep("settings"); } else { addLog("管理员登录失败:密码错误", "warning"); - setPasswordError("密码不正确,默认密码为 88888888"); + setPasswordError(t("loginError")); } } @@ -322,7 +461,7 @@ export default function Home() { async function submitWeight() { if (!stable) { - setNotice("请等待重量稳定后再保存"); + setNotice(t("stableNeeded")); return; } setLoading(true); @@ -362,9 +501,10 @@ export default function Home() { function addToTotal() { if (!stable || netWeight <= 0) { - setNotice("重量稳定后才能累计"); + setNotice(t("accumulationNeeded")); return; } + setLastAccumulation({ total: totalKg, zero: zeroOffset, tare }); setTotalKg((value) => value + netWeight); setZeroOffset(rawWeight); setTare(0); @@ -372,6 +512,16 @@ export default function Home() { 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("当前没有需要补传的数据"); @@ -442,7 +592,7 @@ export default function Home() { } return ( -
+
@@ -451,19 +601,19 @@ export default function Home() {
- 设备在线 + {t("online")}
{step !== "home" && step !== "settings" && (
- {["人员确认", "智能称重", "结果上传"].map((label, index) => { + {[t("progressEmployee"), t("progressWeight"), t("progressResult")].map((label, index) => { const activeIndex = step === "identity" ? 0 : step === "weigh" ? 1 : 2; return (
@@ -479,27 +629,22 @@ export default function Home() { {step === "home" && (
-
安全 · 精准 · 可追溯
-

废料称重,
简单一点。

-

选择废料类型后开始,系统将自动识别员工信息,
稳定采样并上传称重记录。

+
{t("heroEyebrow")}
+

{t("heroTitle1")}
{t("heroTitle2")}

+

{t("heroDesc1")}
{t("heroDesc2")}

{loading ? "···" : "1"} - {loading ? `正在读取${material?.name ?? ""}称重信息…` : "点击右侧废料类型,即刻开始"} + {loading ? t("loadingEmployee") : t("clickMaterial")}
- + {notice &&
ⓘ {notice}
} -
- 0.001 kg显示精度 - 连续采样稳定判断 - 4 种废料分类 -
@@ -526,49 +670,33 @@ export default function Home() { {step === "identity" && (
+
- 01 · 人员与机台 -

确认本次称重信息

-

人员信息由门禁机自动获取,请选择对应生产机台。

+ 01 · {t("employeeMachine")} +

{t("confirmInfo")}

+

{t("identityDesc")}

- +
{employee.name ? employee.name.slice(-1) : "?"}
-
当前操作员工

{employee.name || "未获取"}

工号 · {employee.id || "—"}

- {employee.name ? "✓ 已验证" : "! 待获取"} +
{t("currentEmployee")}

{employee.name || "—"}

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

+ {employee.name ? `✓ ${t("verified")}` : "!"}
- -
- ⌕ - { - setMachineQuery(event.target.value); - setMachine(""); - }} - placeholder="输入机台号或名称,如 A01" - autoComplete="off" - /> - {machine && ✓} + +
+ ▣ + {machine ? t("selectedMachine") : t("machineNo")}{machine || t("noMachine")} + +
- {machineQuery && !machine && ( -
- {filteredMachines.length ? filteredMachines.map((item) => ( - - )) :

没有匹配机台,可点击“重新获取”同步最新数据

} -
- )} {notice &&
ⓘ {notice}
}
@@ -576,17 +704,18 @@ export default function Home() { {step === "weigh" && (
+
- 员工{employee.name} · {employee.id} - 机台{machine} - 废料类型{selectedMaterial.name} · {selectedMaterial.id} - + {t("employee")}{employee.name} · {employee.id} + {t("machine")}{machine} + {t("materialType")}{materialName(selectedMaterial)} · {selectedMaterial.id} +
- {stable ? "重量已稳定" : "正在稳定采样"} + {stable ? t("stable") : t("stabilizing")}
{(["kg", "g", "lb"] as Unit[]).map((item) => ( @@ -599,30 +728,30 @@ export default function Home() {
- 净重 {formatWeight(netWeight, unit)} {unit} - 累计 {formatWeight(totalKg, unit)} {unit} - 皮重 {formatWeight(tare, unit)} {unit} + {t("net")} {formatWeight(netWeight, unit)} {unit} + {t("total")} {formatWeight(totalKg, unit)} {unit} + {t("tareLabel")} {formatWeight(tare, unit)} {unit}
-
- - - +
+ + + + {lastAccumulation && }
- {selectedMaterial.icon} - 本次废料类型 - {selectedMaterial.name} + {t("currentMaterial")} + {materialName(selectedMaterial)} TYPE ID · {selectedMaterial.id} - ✓ 已在首页确认 + ✓ {t("confirmedHome")}
{notice &&
ⓘ {notice}
}
)} @@ -630,44 +759,43 @@ export default function Home() { {step === "result" && (
✓
- 称重完成 -

数据已保存

-

本次称重记录已进入上传队列

+ {t("weighComplete")} +

{t("dataSaved")}

+

{t("uploadQueue")}

-
称重凭证{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString("zh-CN", { hour12: false })}
+
{t("receipt")}{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString(language, { hour12: false })}
- {selectedMaterial.name}{formatWeight(displayedWeight, unit)} {unit} - {selectedMaterial.icon} + {materialName(selectedMaterial)}{formatWeight(displayedWeight, unit)} {unit}
-
员工姓名
{employee.name}
-
员工工号
{employee.id}
-
生产机台
{machine}
-
废料类型 ID
{selectedMaterial.id}
-
上传状态
● 已保存
+
{t("employeeName")}
{employee.name}
+
{t("employeeId")}
{employee.id}
+
{t("productionMachine")}
{machine}
+
{t("materialType")} ID
{selectedMaterial.id}
+
{t("uploadStatus")}
● {t("saved")}
- -
{countdown} 秒后自动返回首页
+ +
{countdown} {t("autoReturn")}
)} {step === "settings" && (
-
管理员中心

应用与设备设置

管理设备、废料字典、外设、数据安全与运行日志。

- +
{t("adminCenter")}

{t("appSettings")}

{t("settingsDesc")}

+
- 本地服务器接口留空时自动使用演示数据 + {t("serverApi")}{t("serverApiDesc")}
{[ - ["employee", "人员信息接口", "http://192.168.1.20/api/employee"], - ["machines", "机台列表接口", "http://192.168.1.20/api/machines"], - ["weight", "电子秤数据接口", "ws://192.168.1.21/weight"], - ["upload", "称重上传接口", "http://192.168.1.20/api/records"], + ["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]) => (