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
+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>