Expand administrator settings and device operations

This commit is contained in:
Codex
2026-07-28 12:32:57 +08:00
parent f5ef61a1ce
commit 4c6b5cc495
2 changed files with 515 additions and 41 deletions
+100 -9
View File
@@ -190,14 +190,89 @@ h1 em { font-style: normal; color: var(--green); }
.countdown { position: relative; margin-top: 10px; color: #8b9690; font-size: 9px; overflow: hidden; }
.countdown span { position: absolute; left: 0; bottom: -4px; height: 2px; background: var(--green); transition: width 1s linear; }
.settings-card { background: white; border: 1px solid var(--line); border-radius: 15px; padding: 8px 18px; }
.settings-card label { display: grid; grid-template-columns: 160px 1fr; align-items: center; gap: 18px; padding: 13px 0; border-bottom: 1px solid var(--line); }
.settings-card label:last-child { border: 0; }
.settings-card label > span { font-size: 11px; font-weight: 800; }
.settings-card label small { display: block; color: #ac8154; font-size: 8px; margin-top: 4px; font-weight: 400; }
.settings-card input { width: 100%; border: 1px solid #d8dfda; border-radius: 8px; background: #fafbf9; padding: 10px 12px; outline: 0; font-size: 10px; }
.settings-card input:focus { border-color: var(--green); }
.setting-hint { margin-top: 12px; background: #edf5f1; border-radius: 9px; padding: 10px 12px; color: #5f7369; font-size: 9px; }
.settings-view { width: min(980px, calc(100% - 40px)); padding: 21px 0 22px; }
.settings-heading { margin-bottom: 14px; }
.settings-heading h2 { font-size: 25px; }
.settings-layout { display: grid; grid-template-columns: 165px minmax(0, 1fr); min-height: 456px; border: 1px solid var(--line); border-radius: 16px; background: white; overflow: hidden; box-shadow: 0 12px 35px rgba(30,55,43,.06); }
.settings-tabs { padding: 12px 9px; border-right: 1px solid var(--line); background: #f2f6f3; }
.settings-tabs button { width: 100%; display: flex; align-items: center; gap: 9px; position: relative; border: 0; border-radius: 9px; background: transparent; color: #6e7a73; padding: 11px 10px; font-size: 10px; text-align: left; }
.settings-tabs button:hover { background: rgba(255,255,255,.8); color: var(--ink); }
.settings-tabs button.active { background: white; color: var(--green); font-weight: 800; box-shadow: 0 5px 18px rgba(30,55,43,.08); }
.settings-tabs button > span { width: 21px; height: 21px; display: grid; place-items: center; border-radius: 6px; background: #e4ebe7; font-size: 10px; }
.settings-tabs button.active > span { background: var(--mint); }
.settings-tabs button > b { margin-left: auto; min-width: 17px; height: 17px; display: grid; place-items: center; border-radius: 99px; background: #c9792d; color: white; font-size: 8px; }
.settings-panel { min-width: 0; padding: 19px 22px; }
.settings-panel-title { min-height: 42px; display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
.settings-panel-title.compact { min-height: 32px; margin-bottom: 10px; }
.settings-panel-title > span > b, .settings-panel-title > span > small { display: block; }
.settings-panel-title > span > b { font-size: 13px; }
.settings-panel-title > span > small { margin-top: 4px; color: #89948e; font-size: 9px; font-weight: 400; }
.settings-panel-title > em { padding: 5px 8px; border-radius: 99px; background: #e9f5ef; color: var(--green); font-size: 8px; font-style: normal; font-weight: 800; }
.settings-form-grid { display: grid; gap: 11px; }
.settings-form-grid.three { grid-template-columns: 1.2fr .85fr 1fr; }
.settings-form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-form-grid label, .password-settings label, .material-editor-row label { display: block; }
.settings-form-grid label > span, .password-settings label > span, .material-editor-row label > span { display: block; margin-bottom: 6px; color: #647069; font-size: 9px; font-weight: 800; }
.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); }
.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 > 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; }
.material-icon.aluminum { background: #e5ecee; color: #60747c; }
.material-icon.tin { background: #e5e6ed; color: #62687c; }
.feature-settings { display: grid; gap: 10px; }
.feature-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid #e0e6e2; border-radius: 11px; background: #fbfcfb; }
.feature-icon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: var(--mint); color: var(--green); font-size: 17px; }
.feature-copy { flex: 1; }
.feature-copy b, .feature-copy small { display: block; }
.feature-copy b { font-size: 11px; }
.feature-copy small { margin-top: 4px; color: #8a958f; font-size: 8px; }
.switch { width: 40px; height: 22px; position: relative; flex: 0 0 auto; }
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch span { position: absolute; inset: 0; border-radius: 99px; background: #cbd4cf; transition: .2s; }
.switch span::after { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: white; box-shadow: 0 2px 5px rgba(0,0,0,.15); transition: .2s; }
.switch input:checked + span { background: var(--green); }
.switch input:checked + span::after { transform: translateX(18px); }
.path-box { display: grid; grid-template-columns: 100px 1fr auto; align-items: center; gap: 10px; margin: -3px 0 0 48px; padding: 9px 11px; border-radius: 8px; background: #f0f4f1; }
.path-box small { color: #7f8b84; font-size: 8px; }
.path-box code { color: #2d5748; font-family: "Courier New", monospace; font-size: 9px; font-weight: 800; }
.path-box em { color: #929c96; font-size: 7px; font-style: normal; }
.print-fields { display: flex; flex-wrap: wrap; gap: 7px; margin: -3px 0 0 48px; }
.print-fields span { padding: 5px 7px; border-radius: 6px; background: #edf5f1; color: #497061; font-size: 7px; }
.data-status-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.data-status-grid > div { display: grid; grid-template-columns: 38px 1fr auto; grid-template-rows: auto auto; align-items: center; gap: 2px 10px; padding: 14px; border: 1px solid #e1e7e3; border-radius: 11px; background: #fbfcfb; }
.data-status-grid > div > span { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--mint); color: var(--green); font-size: 17px; }
.data-status-grid small { color: #7e8a83; font-size: 8px; }
.data-status-grid b { grid-column: 2; font-size: 22px; }
.data-status-grid em { grid-column: 3; grid-row: 1 / 3; color: #98a29c; font-size: 8px; font-style: normal; }
.path-box.data-path, .path-box.log-path { margin: 14px 0 0; }
.data-actions { display: flex; align-items: center; gap: 15px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.data-actions > span { color: #8a958f; font-size: 8px; }
.secondary-action { border: 1px solid #bdd2c8; border-radius: 8px; background: white; color: var(--green); padding: 10px 13px; font-size: 9px; font-weight: 800; }
.secondary-action:hover { background: #f1f8f4; }
.secondary-action.small { padding: 8px 10px; }
.password-settings { max-width: 540px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 13px; }
.password-settings label:first-child { grid-column: 1 / -1; }
.password-settings .secondary-action { grid-column: 1 / -1; justify-self: start; }
.log-title { margin-bottom: 8px; }
.log-title > span > b { display: flex; align-items: center; gap: 7px; }
.live-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: #35b77c; box-shadow: 0 0 0 4px rgba(53,183,124,.12); }
.log-viewer { height: 286px; margin-top: 12px; overflow-y: auto; border-radius: 10px; background: #13231d; padding: 8px 12px; color: #dce9e2; font-family: "Courier New", monospace; }
.log-entry { display: grid; grid-template-columns: 72px 36px 1fr; gap: 9px; align-items: start; padding: 8px 2px; border-bottom: 1px solid rgba(255,255,255,.06); font-size: 8px; }
.log-entry time { color: #6f8c80; }
.log-entry > span { color: #8aa398; }
.log-entry.success > span { color: #5bd19c; }
.log-entry.warning > span { color: #e0b864; }
.log-entry.error > span { color: #e67b6e; }
.log-entry p { margin: 0; line-height: 1.45; }
.settings-footer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; margin-top: 12px; }
.settings-feedback { color: #87928c; font-size: 9px; }
.settings-feedback.visible { color: var(--green); }
.save-settings { min-width: 180px; border: 0; border-radius: 10px; background: var(--green); color: white; padding: 12px 15px; font-size: 10px; font-weight: 800; box-shadow: 0 8px 20px rgba(0,127,95,.16); }
.save-settings span { float: right; }
.modal-backdrop { position: fixed; inset: 0; z-index: 10; display: grid; place-items: center; background: rgba(13,31,24,.58); backdrop-filter: blur(5px); padding: 20px; }
.modal { width: min(390px, 100%); position: relative; background: #fff; border-radius: 20px; padding: 30px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.28); }
@@ -231,6 +306,22 @@ 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; }
.settings-card label { grid-template-columns: 1fr; gap: 7px; }
.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 > i { display: none; }
.path-box { margin-left: 0; grid-template-columns: 1fr; gap: 4px; }
.print-fields { margin-left: 0; }
.password-settings { grid-template-columns: 1fr; }
.password-settings label:first-child { grid-column: auto; }
.password-settings .secondary-action { grid-column: auto; }
.log-entry { grid-template-columns: 62px 34px 1fr; }
.settings-footer-actions { align-items: stretch; flex-direction: column; }
.save-settings { width: 100%; }
footer { padding: 0 16px; }
}
+415 -32
View File
@@ -4,20 +4,67 @@ import { useEffect, useMemo, useRef, useState } from "react";
type Step = "home" | "identity" | "weigh" | "result" | "settings";
type Unit = "kg" | "g" | "lb";
type SettingsTab = "device" | "materials" | "peripherals" | "data" | "security" | "logs";
type Material = {
id: string;
name: string;
tone: string;
icon: string;
};
type AppSettings = {
employee: string;
machines: string;
weight: string;
upload: string;
deviceName: string;
deviceId: string;
imei: string;
photoEnabled: boolean;
printEnabled: boolean;
};
type WeightRecord = {
employeeName: string;
employeeId: string;
machineId: string;
materialType: string;
materialTypeId: string;
weightKg: number;
createdAt: string;
};
type LogEntry = {
id: string;
time: string;
level: "info" | "success" | "warning" | "error";
message: string;
};
const materials: Material[] = [
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" },
];
const DEFAULT_SETTINGS: AppSettings = {
employee: "",
machines: "",
weight: "",
upload: "",
deviceName: "智能称重终端 01",
deviceId: "SCALE-01",
imei: "868120060012345",
photoEnabled: false,
printEnabled: false,
};
const CONFIG_KEY = "smart-scale-config";
const BACKUP_KEY = "smart-scale-backup-records";
const PENDING_KEY = "smart-scale-pending-records";
const LOG_KEY = "smart-scale-logs";
const IMAGE_PATH = "/sdcard/smart/image/";
const CACHE_PATH = "/sdcard/smart/cache/";
const LOG_PATH = "/sdcard/smart/logs/";
const machines = ["A01 绕线机", "A02 押出机", "B03 裁线机", "B06 编织机", "C12 包装机"];
const unitFactors: Record<Unit, number> = { kg: 1, g: 1000, lb: 2.20462262 };
@@ -28,6 +75,10 @@ function formatWeight(valueKg: number, unit: Unit) {
return value.toFixed(digits);
}
function createFileTimestamp(iso: string) {
return iso.replace(/\D/g, "").slice(0, 14);
}
export default function Home() {
const [step, setStep] = useState<Step>("home");
const [employee, setEmployee] = useState({ name: "", id: "" });
@@ -47,17 +98,29 @@ export default function Home() {
const [passwordOpen, setPasswordOpen] = useState(false);
const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [apiSettings, setApiSettings] = useState({
employee: "",
machines: "",
weight: "",
upload: "",
});
const [adminPassword, setAdminPassword] = useState("88888888");
const [appSettings, setAppSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [materialOptions, setMaterialOptions] = useState<Material[]>(DEFAULT_MATERIALS);
const [settingsTab, setSettingsTab] = useState<SettingsTab>("device");
const [settingsMessage, setSettingsMessage] = useState("");
const [settingsBusy, setSettingsBusy] = useState(false);
const [passwordForm, setPasswordForm] = useState({ current: "", next: "", confirm: "" });
const [pendingRecords, setPendingRecords] = useState<WeightRecord[]>([]);
const [backupRecords, setBackupRecords] = useState<WeightRecord[]>([]);
const [lastRecord, setLastRecord] = useState<WeightRecord | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([
{
id: "boot",
time: new Date().toISOString(),
level: "success",
message: "应用启动完成,称重设备连接正常",
},
]);
const stableSamples = useRef<number[]>([]);
const netWeight = Math.max(0, rawWeight - zeroOffset - tare);
const displayedWeight = totalKg + netWeight;
const selectedMaterial = material ?? materials[0];
const selectedMaterial = material ?? materialOptions[0];
const filteredMachines = useMemo(
() =>
@@ -67,6 +130,26 @@ export default function Home() {
[machineQuery],
);
useEffect(() => {
try {
const savedConfig = window.localStorage.getItem(CONFIG_KEY);
if (savedConfig) {
const parsed = JSON.parse(savedConfig);
if (parsed.settings) setAppSettings({ ...DEFAULT_SETTINGS, ...parsed.settings });
if (Array.isArray(parsed.materials) && parsed.materials.length === 4) setMaterialOptions(parsed.materials);
if (typeof parsed.password === "string") setAdminPassword(parsed.password);
}
const savedPending = window.localStorage.getItem(PENDING_KEY);
const savedBackup = window.localStorage.getItem(BACKUP_KEY);
const savedLogs = window.localStorage.getItem(LOG_KEY);
if (savedPending) setPendingRecords(JSON.parse(savedPending));
if (savedBackup) setBackupRecords(JSON.parse(savedBackup));
if (savedLogs) setLogs(JSON.parse(savedLogs));
} catch {
addLog("本地配置读取失败,已使用默认设置", "warning");
}
}, []);
useEffect(() => {
if (step !== "weigh") return;
const start = Date.now();
@@ -123,6 +206,44 @@ export default function Home() {
stableSamples.current = [];
}
function addLog(message: string, level: LogEntry["level"] = "info") {
const entry: LogEntry = {
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
time: new Date().toISOString(),
level,
message,
};
setLogs((current) => {
const next = [entry, ...current].slice(0, 100);
try {
window.localStorage.setItem(LOG_KEY, JSON.stringify(next));
} catch {
// Native Android builds write to LOG_PATH through the reserved bridge.
}
return next;
});
}
function persistConfiguration(nextPassword = adminPassword) {
window.localStorage.setItem(
CONFIG_KEY,
JSON.stringify({
settings: appSettings,
materials: materialOptions,
password: nextPassword,
}),
);
addLog("管理员设置已保存", "success");
setSettingsMessage("全部设置已保存,本机配置已更新");
}
function updateMaterial(index: number, field: "name" | "id", value: string) {
setMaterialOptions((current) =>
current.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)),
);
setSettingsMessage("");
}
async function fetchIdentity(chosenMaterial?: Material) {
const activeMaterial = chosenMaterial ?? material;
if (!activeMaterial) {
@@ -133,8 +254,8 @@ export default function Home() {
setLoading(true);
setNotice("");
try {
if (apiSettings.employee) {
const response = await fetch(apiSettings.employee);
if (appSettings.employee) {
const response = await fetch(appSettings.employee);
if (!response.ok) throw new Error("接口不可用");
const data = await response.json();
setEmployee({ name: data.name, id: data.employeeId });
@@ -143,8 +264,10 @@ export default function Home() {
setEmployee({ name: "张伟", id: "E-0826" });
setNotice("当前使用模拟数据,接口接入后将自动切换");
}
addLog(`人员信息获取成功:${employee.name || "张伟"}`, "success");
setStep("identity");
} catch {
addLog("门禁人员信息获取失败", "error");
setNotice("暂时无法连接本地服务器,请点击“重新获取”");
setStep("identity");
} finally {
@@ -153,13 +276,47 @@ export default function Home() {
}
function enterAdmin() {
if (password === "888888") {
if (password === adminPassword) {
setPasswordOpen(false);
setPassword("");
setPasswordError("");
setSettingsMessage("");
addLog("管理员登录成功", "success");
setStep("settings");
} else {
setPasswordError("密码不正确,演示密码为 888888");
addLog("管理员登录失败:密码错误", "warning");
setPasswordError("密码不正确,默认密码为 88888888");
}
}
function backupRecord(record: WeightRecord) {
setBackupRecords((current) => {
const next = [...current, record].slice(-500);
window.localStorage.setItem(BACKUP_KEY, JSON.stringify(next));
return next;
});
addLog(`称重数据已备份至 ${CACHE_PATH}`, "success");
}
function queuePendingRecord(record: WeightRecord) {
setPendingRecords((current) => {
const next = [...current, record];
window.localStorage.setItem(PENDING_KEY, JSON.stringify(next));
return next;
});
addLog("上传失败,数据已加入补传队列", "warning");
}
function runCompletionHooks(record: WeightRecord) {
if (appSettings.photoEnabled) {
const filename = `${createFileTimestamp(record.createdAt)}.jpg`;
addLog(`USB 摄像头拍照调用:${IMAGE_PATH}${filename}`, "success");
}
if (appSettings.printEnabled) {
addLog(
`打印任务已发送:员工 ${record.employeeName},工号 ${record.employeeId},机台 ${record.machineId},废料 ${record.materialType},重量 ${record.weightKg.toFixed(3)} kg,时间 ${new Date(record.createdAt).toLocaleString("zh-CN", { hour12: false })}`,
"success",
);
}
}
@@ -179,9 +336,11 @@ export default function Home() {
weightKg: Number(displayedWeight.toFixed(3)),
createdAt: new Date().toISOString(),
};
setLastRecord(payload);
backupRecord(payload);
try {
if (apiSettings.upload) {
const response = await fetch(apiSettings.upload, {
if (appSettings.upload) {
const response = await fetch(appSettings.upload, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -190,8 +349,11 @@ export default function Home() {
} else {
await new Promise((resolve) => window.setTimeout(resolve, 700));
}
addLog(`称重数据上传成功:${payload.weightKg.toFixed(3)} kg`, "success");
runCompletionHooks(payload);
setStep("result");
} catch {
queuePendingRecord(payload);
setNotice("数据上传失败,请检查服务器连接后重试");
} finally {
setLoading(false);
@@ -210,6 +372,75 @@ export default function Home() {
setNotice(`已累计 ${formatWeight(netWeight, unit)} ${unit},可继续放置下一批`);
}
async function retryPendingUploads() {
if (!pendingRecords.length) {
setSettingsMessage("当前没有需要补传的数据");
addLog("手动检查补传队列:无待补传数据");
return;
}
setSettingsBusy(true);
setSettingsMessage("");
try {
if (appSettings.upload) {
for (const record of pendingRecords) {
const response = await fetch(appSettings.upload, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(record),
});
if (!response.ok) throw new Error("补传失败");
}
} else {
await new Promise((resolve) => window.setTimeout(resolve, 900));
}
const count = pendingRecords.length;
setPendingRecords([]);
window.localStorage.setItem(PENDING_KEY, "[]");
setSettingsMessage(`补传完成,共成功上传 ${count} 条数据`);
addLog(`数据补传完成:${count} 条`, "success");
} catch {
setSettingsMessage("补传失败,数据仍保留在本地队列");
addLog("数据补传失败,请检查服务器接口", "error");
} finally {
setSettingsBusy(false);
}
}
function changeAdminPassword() {
if (passwordForm.current !== adminPassword) {
setSettingsMessage("原密码不正确");
return;
}
if (passwordForm.next.length < 8) {
setSettingsMessage("新密码至少需要 8 位");
return;
}
if (passwordForm.next !== passwordForm.confirm) {
setSettingsMessage("两次输入的新密码不一致");
return;
}
setAdminPassword(passwordForm.next);
persistConfiguration(passwordForm.next);
setPasswordForm({ current: "", next: "", confirm: "" });
setSettingsMessage("管理员密码已修改,下次登录时生效");
addLog("管理员密码已修改", "success");
}
function exportLogs() {
const content = logs
.map((entry) => `[${new Date(entry.time).toLocaleString("zh-CN", { hour12: false })}] [${entry.level.toUpperCase()}] ${entry.message}`)
.join("\n");
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `smart-scale-${createFileTimestamp(new Date().toISOString())}.log`;
link.click();
URL.revokeObjectURL(url);
addLog(`日志已导出,目标目录 ${LOG_PATH}`, "success");
setSettingsMessage(`日志已导出,Android 设备保存至 ${LOG_PATH}`);
}
return (
<main className="app-shell">
<div className="ambient ambient-one" />
@@ -271,7 +502,7 @@ export default function Home() {
<p>请选择本次需要称重的废料</p>
</div>
<div className="home-material-grid">
{materials.map((item) => (
{materialOptions.map((item) => (
<button
key={item.id}
className={`material-card ${item.tone} ${material?.id === item.id ? "selected" : ""}`}
@@ -403,7 +634,7 @@ export default function Home() {
<h2>数据已保存</h2>
<p>本次称重记录已进入上传队列</p>
<div className="receipt">
<div className="receipt-head"><span>称重凭证</span><small>{new Date().toLocaleString("zh-CN", { hour12: false })}</small></div>
<div className="receipt-head"><span>称重凭证</span><small>{new Date(lastRecord?.createdAt ?? new Date()).toLocaleString("zh-CN", { 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>
@@ -423,27 +654,179 @@ export default function Home() {
{step === "settings" && (
<div className="panel-view settings-view">
<div className="section-heading">
<div><span className="step-kicker">管理员中心</span><h2>接口与设备设置</h2><p>接口开发完成后,在此填入本地服务器地址。</p></div>
<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>
<div className="settings-card">
{[
["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"],
].map(([key, label, placeholder]) => (
<label key={key}><span>{label}<small>暂未配置</small></span><input value={apiSettings[key as keyof typeof apiSettings]} onChange={(event) => setApiSettings({ ...apiSettings, [key]: event.target.value })} placeholder={placeholder} /></label>
))}
<div className="settings-layout">
<nav className="settings-tabs" aria-label="设置分类">
{[
["device", "▣", "设备与接口"],
["materials", "◆", "废料字典"],
["peripherals", "⌁", "拍照与打印"],
["data", "↥", "补传与备份"],
["security", "⌾", "安全设置"],
["logs", "≡", "实时日志"],
].map(([key, icon, label]) => (
<button
key={key}
className={settingsTab === key ? "active" : ""}
onClick={() => {
setSettingsTab(key as SettingsTab);
setSettingsMessage("");
}}
>
<span>{icon}</span>{label}
{key === "data" && pendingRecords.length > 0 && <b>{pendingRecords.length}</b>}
</button>
))}
</nav>
<section className="settings-panel">
{settingsTab === "device" && (
<>
<div className="settings-panel-title">
<span><b>设备信息</b><small>用于标识当前智能称重终端</small></span>
<em>本机</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>
</div>
<div className="settings-divider" />
<div className="settings-panel-title compact">
<span><b>本地服务器接口</b><small>留空时自动使用演示数据</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"],
].map(([key, label, placeholder]) => (
<label key={key}>
<span>{label}</span>
<input
value={appSettings[key as keyof Pick<AppSettings, "employee" | "machines" | "weight" | "upload">] as string}
onChange={(event) => setAppSettings({ ...appSettings, [key]: event.target.value })}
placeholder={placeholder}
/>
</label>
))}
</div>
</>
)}
{settingsTab === "materials" && (
<>
<div className="settings-panel-title">
<span><b>废料字典</b><small>修改后将同步更新首页分类与称重数据</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>Type ID</span><input value={item.id} onChange={(event) => updateMaterial(index, "id", event.target.value.toUpperCase())} /></label>
<i>0{index + 1}</i>
</div>
))}
</div>
</>
)}
{settingsTab === "peripherals" && (
<>
<div className="settings-panel-title">
<span><b>完成称重后的外设动作</b><small>功能默认关闭,需由 Android 原生层接入 USB 设备</small></span>
<em>预留接口</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>
<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="feature-row">
<span className="feature-icon">▤</span>
<span className="feature-copy"><b>称重凭证打印</b><small>提交成功后调用 USB / 蓝牙打印机</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>)}
</div>
</div>
</>
)}
{settingsTab === "data" && (
<>
<div className="settings-panel-title">
<span><b>数据补传与本地备份</b><small>网络异常时确保称重记录不丢失</small></span>
<em>自动保护</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>
<div className="path-box data-path"><small>本地备份路径</small><code>{CACHE_PATH}</code><em>称重提交时自动写入</em></div>
<div className="data-actions">
<button className="secondary-action" onClick={() => void retryPendingUploads()} disabled={settingsBusy}>
{settingsBusy ? "正在补传…" : "↥ 立即补传全部数据"}
</button>
<span>补传成功后自动清理待传队列,本地备份仍会保留。</span>
</div>
</>
)}
{settingsTab === "security" && (
<>
<div className="settings-panel-title">
<span><b>管理员密码</b><small>默认密码为 88888888,建议部署后立即修改</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>
</div>
</>
)}
{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>
</div>
<div className="path-box log-path"><small>Android 日志保存路径</small><code>{LOG_PATH}</code><em>按日期滚动保存</em></div>
<div className="log-viewer">
{logs.map((entry) => (
<div className={`log-entry ${entry.level}`} key={entry.id}>
<time>{new Date(entry.time).toLocaleTimeString("zh-CN", { hour12: false })}</time>
<span>{entry.level === "success" ? "成功" : entry.level === "warning" ? "警告" : entry.level === "error" ? "错误" : "信息"}</span>
<p>{entry.message}</p>
</div>
))}
</div>
</>
)}
</section>
</div>
<div className="settings-footer-actions">
<span className={settingsMessage ? "settings-feedback visible" : "settings-feedback"}>{settingsMessage || "修改完成后请保存设置"}</span>
<button className="save-settings" onClick={() => persistConfiguration()}>保存全部设置 <span>✓</span></button>
</div>
<div className="setting-hint">当前为空时,应用会使用内置模拟数据,便于先行演示与验收交互流程。</div>
<button className="full-primary" onClick={() => { setNotice("设置已保存在本次会话中"); resetToHome(); }}>保存设置 <span>✓</span></button>
</div>
)}
</div>
<footer><span>沃尔核材 · 数字化生产</span><span>设备编号 SCALE-01 · v1.0</span></footer>
<footer><span>沃尔核材 · 数字化生产</span><span>{appSettings.deviceName} · {appSettings.deviceId} · v1.1</span></footer>
</section>
{passwordOpen && (
@@ -452,8 +835,8 @@ export default function Home() {
<button className="modal-close" onClick={() => setPasswordOpen(false)}>×</button>
<span className="modal-icon">⌁</span>
<h2>管理员登录</h2>
<p>请输入六位管理密码进入设置</p>
<input type="password" inputMode="numeric" autoFocus maxLength={6} value={password} onChange={(event) => setPassword(event.target.value.replace(/\D/g, ""))} onKeyDown={(event) => event.key === "Enter" && enterAdmin()} placeholder="••••••" aria-label="管理员密码" />
<p>请输入管理员密码进入应用设置<br />初始密码为 88888888</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>
</div>