Refine weighing flow and add localization
This commit is contained in:
+58
-14
@@ -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; }
|
||||
|
||||
+320
-141
@@ -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<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";
|
||||
@@ -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<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");
|
||||
@@ -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<Material | null>(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<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(
|
||||
() =>
|
||||
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 (
|
||||
<main className="app-shell">
|
||||
<main className="app-shell" lang={language}>
|
||||
<div className="ambient ambient-one" />
|
||||
<div className="ambient ambient-two" />
|
||||
|
||||
@@ -451,19 +601,19 @@ export default function Home() {
|
||||
<button className="brand" onClick={resetToHome} aria-label="返回首页">
|
||||
<span className="brand-mark">W</span>
|
||||
<span>
|
||||
<strong>智能称重终端</strong>
|
||||
<small>WOTE · SMART SCALE</small>
|
||||
<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="流程进度">
|
||||
{["人员确认", "智能称重", "结果上传"].map((label, index) => {
|
||||
{[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}>
|
||||
@@ -479,27 +629,22 @@ export default function Home() {
|
||||
{step === "home" && (
|
||||
<div className="home-view">
|
||||
<div className="home-copy">
|
||||
<div className="eyebrow"><span /> 安全 · 精准 · 可追溯</div>
|
||||
<h1>废料称重,<br /><em>简单一点。</em></h1>
|
||||
<p>选择废料类型后开始,系统将自动识别员工信息,<br />稳定采样并上传称重记录。</p>
|
||||
<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 ? `正在读取${material?.name ?? ""}称重信息…` : "点击右侧废料类型,即刻开始"}</b>
|
||||
<b>{loading ? t("loadingEmployee") : t("clickMaterial")}</b>
|
||||
</div>
|
||||
<button className="admin-link" onClick={() => setPasswordOpen(true)}>⚙ 管理员登录</button>
|
||||
<button className="admin-link" onClick={() => setPasswordOpen(true)}>⚙ {t("adminLogin")} <span>→</span></button>
|
||||
{notice && <div className="notice home-notice">ⓘ {notice}</div>}
|
||||
<div className="home-stats">
|
||||
<span><b>0.001 kg</b>显示精度</span>
|
||||
<span><b>连续采样</b>稳定判断</span>
|
||||
<span><b>4 种</b>废料分类</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="home-material-panel">
|
||||
<div className="material-panel-heading">
|
||||
<span className="step-kicker">称重前准备</span>
|
||||
<h2>选择废料类型</h2>
|
||||
<p>请选择本次需要称重的废料</p>
|
||||
<span className="step-kicker">{t("prep")}</span>
|
||||
<h2>{t("chooseMaterial")}</h2>
|
||||
<p>{t("chooseMaterialDesc")}</p>
|
||||
</div>
|
||||
<div className="home-material-grid">
|
||||
{materialOptions.map((item) => (
|
||||
@@ -510,15 +655,14 @@ export default function Home() {
|
||||
disabled={loading}
|
||||
aria-pressed={material?.id === item.id}
|
||||
>
|
||||
<span className="material-icon">{item.icon}</span>
|
||||
<span><b>{item.name}</b><small>TYPE ID · {item.id}</small></span>
|
||||
<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 ? "正在读取门禁人员信息" : material ? `已选择 ${material.name}` : "请选择一种废料类型,选择后自动进入下一步"}
|
||||
{loading ? t("loadingEmployee") : material ? `${t("chooseMaterial")} · ${materialName(material)}` : t("chooseAuto")}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -526,49 +670,33 @@ export default function Home() {
|
||||
|
||||
{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 · 人员与机台</span>
|
||||
<h2>确认本次称重信息</h2>
|
||||
<p>人员信息由门禁机自动获取,请选择对应生产机台。</p>
|
||||
<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}>↻ 重新获取</button>
|
||||
<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>当前操作员工</small><h3>{employee.name || "未获取"}</h3><p>工号 · {employee.id || "—"}</p></div>
|
||||
<span className={employee.name ? "verified" : "verified missing"}>{employee.name ? "✓ 已验证" : "! 待获取"}</span>
|
||||
<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" htmlFor="machine">生产机台号</label>
|
||||
<div className="machine-picker">
|
||||
<span>⌕</span>
|
||||
<input
|
||||
id="machine"
|
||||
value={machineQuery}
|
||||
onChange={(event) => {
|
||||
setMachineQuery(event.target.value);
|
||||
setMachine("");
|
||||
}}
|
||||
placeholder="输入机台号或名称,如 A01"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{machine && <b>✓</b>}
|
||||
<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>
|
||||
{machineQuery && !machine && (
|
||||
<div className="suggestions">
|
||||
{filteredMachines.length ? filteredMachines.map((item) => (
|
||||
<button key={item} onClick={() => { setMachine(item); setMachineQuery(item); }}>
|
||||
<span>▣</span>{item}<small>选择</small>
|
||||
</button>
|
||||
)) : <p>没有匹配机台,可点击“重新获取”同步最新数据</p>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="full-primary"
|
||||
disabled={!employee.name || !machine}
|
||||
onClick={() => { setNotice(""); setStep("weigh"); }}
|
||||
>
|
||||
确认并进入称重 <span>→</span>
|
||||
{t("confirmWeigh")} <span>→</span>
|
||||
</button>
|
||||
{notice && <div className="notice">ⓘ {notice}</div>}
|
||||
</div>
|
||||
@@ -576,17 +704,18 @@ export default function Home() {
|
||||
|
||||
{step === "weigh" && (
|
||||
<div className="weigh-view">
|
||||
<button className="back-button weigh-back" onClick={() => setStep("identity")}>← {t("back")}</button>
|
||||
<div className="weigh-meta">
|
||||
<span><small>员工</small><b>{employee.name} · {employee.id}</b></span>
|
||||
<span><small>机台</small><b>{machine}</b></span>
|
||||
<span className="material-meta"><small>废料类型</small><b>{selectedMaterial.name} · {selectedMaterial.id}</b></span>
|
||||
<button onClick={() => setStep("identity")}>修改人员/机台</button>
|
||||
<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 ? "重量已稳定" : "正在稳定采样"}
|
||||
<i /> {stable ? t("stable") : t("stabilizing")}
|
||||
</span>
|
||||
<div className="unit-switch">
|
||||
{(["kg", "g", "lb"] as Unit[]).map((item) => (
|
||||
@@ -599,30 +728,30 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="stability-track"><span style={{ width: `${stableProgress}%` }} /></div>
|
||||
<div className="display-details">
|
||||
<span>净重 <b>{formatWeight(netWeight, unit)} {unit}</b></span>
|
||||
<span>累计 <b>{formatWeight(totalKg, unit)} {unit}</b></span>
|
||||
<span>皮重 <b>{formatWeight(tare, unit)} {unit}</b></span>
|
||||
<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="scale-tools">
|
||||
<button onClick={() => { setZeroOffset(rawWeight); setTare(0); setTotalKg(0); setNotice("秤台已清零"); }}>⟲<span><b>清零</b><small>消除空秤误差</small></span></button>
|
||||
<button onClick={() => { setTare(Math.max(0, rawWeight - zeroOffset)); setNotice("已去皮,仅显示净重"); }}>▱<span><b>去皮</b><small>扣除容器重量</small></span></button>
|
||||
<button onClick={addToTotal}>+<span><b>累计</b><small>加入当前重量</small></span></button>
|
||||
<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 className="material-icon">{selectedMaterial.icon}</span>
|
||||
<span>
|
||||
<small>本次废料类型</small>
|
||||
<b>{selectedMaterial.name}</b>
|
||||
<small>{t("currentMaterial")}</small>
|
||||
<b>{materialName(selectedMaterial)}</b>
|
||||
<em>TYPE ID · {selectedMaterial.id}</em>
|
||||
</span>
|
||||
<i>✓ 已在首页确认</i>
|
||||
<i>✓ {t("confirmedHome")}</i>
|
||||
</div>
|
||||
{notice && <div className="notice compact">ⓘ {notice}</div>}
|
||||
<button className="full-primary upload" disabled={!stable || loading} onClick={submitWeight}>
|
||||
{loading ? "正在上传…" : stable ? `保存 ${selectedMaterial.name} 称重数据` : "请等待重量稳定"} <span>→</span>
|
||||
{loading ? t("uploading") : stable ? t("saveWeight") : t("waitStable")} <span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -630,44 +759,43 @@ export default function Home() {
|
||||
{step === "result" && (
|
||||
<div className="result-view">
|
||||
<div className="success-ring"><span>✓</span></div>
|
||||
<span className="step-kicker">称重完成</span>
|
||||
<h2>数据已保存</h2>
|
||||
<p>本次称重记录已进入上传队列</p>
|
||||
<span className="step-kicker">{t("weighComplete")}</span>
|
||||
<h2>{t("dataSaved")}</h2>
|
||||
<p>{t("uploadQueue")}</p>
|
||||
<div className="receipt">
|
||||
<div className="receipt-head"><span>称重凭证</span><small>{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString("zh-CN", { hour12: false })}</small></div>
|
||||
<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>{selectedMaterial.name}</small><b>{formatWeight(displayedWeight, unit)} <em>{unit}</em></b></span>
|
||||
<i>{selectedMaterial.icon}</i>
|
||||
<span><small>{materialName(selectedMaterial)}</small><b>{formatWeight(displayedWeight, unit)} <em>{unit}</em></b></span>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>员工姓名</dt><dd>{employee.name}</dd></div>
|
||||
<div><dt>员工工号</dt><dd>{employee.id}</dd></div>
|
||||
<div><dt>生产机台</dt><dd>{machine}</dd></div>
|
||||
<div><dt>废料类型 ID</dt><dd>{selectedMaterial.id}</dd></div>
|
||||
<div><dt>上传状态</dt><dd className="upload-ok">● 已保存</dd></div>
|
||||
<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}>返回首页 <span>→</span></button>
|
||||
<div className="countdown"><span style={{ width: `${countdown * 10}%` }} />{countdown} 秒后自动返回首页</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">管理员中心</span><h2>应用与设备设置</h2><p>管理设备、废料字典、外设、数据安全与运行日志。</p></div>
|
||||
<button className="refresh-button" onClick={resetToHome}>退出管理</button>
|
||||
<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", "▣", "设备与接口"],
|
||||
["materials", "◆", "废料字典"],
|
||||
["peripherals", "⌁", "拍照与打印"],
|
||||
["data", "↥", "补传与备份"],
|
||||
["security", "⌾", "安全设置"],
|
||||
["logs", "≡", "实时日志"],
|
||||
["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}
|
||||
@@ -687,24 +815,42 @@ export default function Home() {
|
||||
{settingsTab === "device" && (
|
||||
<>
|
||||
<div className="settings-panel-title">
|
||||
<span><b>设备信息</b><small>用于标识当前智能称重终端</small></span>
|
||||
<em>本机</em>
|
||||
<span><b>{t("deviceInfo")}</b><small>{t("deviceInfoDesc")}</small></span>
|
||||
<em>{t("localDevice")}</em>
|
||||
</div>
|
||||
<div className="settings-form-grid three">
|
||||
<label><span>设备名称</span><input value={appSettings.deviceName} onChange={(event) => setAppSettings({ ...appSettings, deviceName: event.target.value })} /></label>
|
||||
<label><span>设备号</span><input value={appSettings.deviceId} onChange={(event) => setAppSettings({ ...appSettings, deviceId: event.target.value })} /></label>
|
||||
<label><span>IMEI 号</span><input inputMode="numeric" value={appSettings.imei} onChange={(event) => setAppSettings({ ...appSettings, imei: event.target.value.replace(/\D/g, "").slice(0, 15) })} /></label>
|
||||
<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>本地服务器接口</b><small>留空时自动使用演示数据</small></span>
|
||||
<span><b>{t("serverApi")}</b><small>{t("serverApiDesc")}</small></span>
|
||||
</div>
|
||||
<div className="settings-form-grid two">
|
||||
{[
|
||||
["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]) => (
|
||||
<label key={key}>
|
||||
<span>{label}</span>
|
||||
@@ -722,14 +868,13 @@ export default function Home() {
|
||||
{settingsTab === "materials" && (
|
||||
<>
|
||||
<div className="settings-panel-title">
|
||||
<span><b>废料字典</b><small>修改后将同步更新首页分类与称重数据</small></span>
|
||||
<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}`}>
|
||||
<span className={`material-icon ${item.tone}`}>{item.icon}</span>
|
||||
<label><span>显示名称</span><input value={item.name} onChange={(event) => updateMaterial(index, "name", event.target.value)} /></label>
|
||||
<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>
|
||||
@@ -741,23 +886,23 @@ export default function Home() {
|
||||
{settingsTab === "peripherals" && (
|
||||
<>
|
||||
<div className="settings-panel-title">
|
||||
<span><b>完成称重后的外设动作</b><small>功能默认关闭,需由 Android 原生层接入 USB 设备</small></span>
|
||||
<em>预留接口</em>
|
||||
<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>USB 摄像头拍照</b><small>提交成功后自动拍摄现场照片</small></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>照片保存路径</small><code>{IMAGE_PATH}</code><em>文件名:YYYYMMDDHHmmss.jpg</em></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>称重凭证打印</b><small>提交成功后调用 USB / 蓝牙打印机</small></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">
|
||||
{["员工名称", "员工工号", "机台号", "废料类型", "废料重量", "称重时间"].map((item) => <span key={item}>✓ {item}</span>)}
|
||||
{[t("employeeName"), t("employeeId"), t("machineNo"), t("materialType"), t("scrapWeight"), t("weighingTime")].map((item) => <span key={item}>✓ {item}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -766,19 +911,19 @@ export default function Home() {
|
||||
{settingsTab === "data" && (
|
||||
<>
|
||||
<div className="settings-panel-title">
|
||||
<span><b>数据补传与本地备份</b><small>网络异常时确保称重记录不丢失</small></span>
|
||||
<em>自动保护</em>
|
||||
<span><b>{t("dataTitle")}</b><small>{t("dataDesc")}</small></span>
|
||||
<em>{t("autoProtect")}</em>
|
||||
</div>
|
||||
<div className="data-status-grid">
|
||||
<div><span>↥</span><small>待补传数据</small><b>{pendingRecords.length}</b><em>条记录</em></div>
|
||||
<div><span>▣</span><small>本地备份</small><b>{backupRecords.length}</b><em>条记录</em></div>
|
||||
<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>本地备份路径</small><code>{CACHE_PATH}</code><em>称重提交时自动写入</em></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 ? "正在补传…" : "↥ 立即补传全部数据"}
|
||||
{settingsBusy ? t("retrying") : `↥ ${t("retryAll")}`}
|
||||
</button>
|
||||
<span>补传成功后自动清理待传队列,本地备份仍会保留。</span>
|
||||
<span>{t("retryHint")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -786,14 +931,14 @@ export default function Home() {
|
||||
{settingsTab === "security" && (
|
||||
<>
|
||||
<div className="settings-panel-title">
|
||||
<span><b>管理员密码</b><small>默认密码为 88888888,建议部署后立即修改</small></span>
|
||||
<span><b>{t("adminPassword")}</b><small>{t("passwordAdvice")}</small></span>
|
||||
<em>8–12 位</em>
|
||||
</div>
|
||||
<div className="password-settings">
|
||||
<label><span>当前密码</span><input type="password" inputMode="numeric" maxLength={12} value={passwordForm.current} onChange={(event) => setPasswordForm({ ...passwordForm, current: event.target.value.replace(/\D/g, "") })} placeholder="请输入当前密码" /></label>
|
||||
<label><span>新密码</span><input type="password" inputMode="numeric" maxLength={12} value={passwordForm.next} onChange={(event) => setPasswordForm({ ...passwordForm, next: event.target.value.replace(/\D/g, "") })} placeholder="至少 8 位数字" /></label>
|
||||
<label><span>确认新密码</span><input type="password" inputMode="numeric" maxLength={12} value={passwordForm.confirm} onChange={(event) => setPasswordForm({ ...passwordForm, confirm: event.target.value.replace(/\D/g, "") })} placeholder="再次输入新密码" /></label>
|
||||
<button className="secondary-action" onClick={changeAdminPassword}>更新管理员密码</button>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -801,10 +946,10 @@ export default function Home() {
|
||||
{settingsTab === "logs" && (
|
||||
<>
|
||||
<div className="settings-panel-title log-title">
|
||||
<span><b><i className="live-dot" />实时运行日志</b><small>最多保留最近 100 条运行记录</small></span>
|
||||
<button className="secondary-action small" onClick={exportLogs}>↓ 导出日志</button>
|
||||
<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>Android 日志保存路径</small><code>{LOG_PATH}</code><em>按日期滚动保存</em></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}>
|
||||
@@ -819,8 +964,8 @@ export default function Home() {
|
||||
</section>
|
||||
</div>
|
||||
<div className="settings-footer-actions">
|
||||
<span className={settingsMessage ? "settings-feedback visible" : "settings-feedback"}>{settingsMessage || "修改完成后请保存设置"}</span>
|
||||
<button className="save-settings" onClick={() => persistConfiguration()}>保存全部设置 <span>✓</span></button>
|
||||
<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>
|
||||
)}
|
||||
@@ -834,11 +979,45 @@ export default function Home() {
|
||||
<div className="modal" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setPasswordOpen(false)}>×</button>
|
||||
<span className="modal-icon">⌁</span>
|
||||
<h2>管理员登录</h2>
|
||||
<p>请输入管理员密码进入应用设置<br />初始密码为 88888888</p>
|
||||
<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}>确认登录 <span>→</span></button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user