848 lines
38 KiB
TypeScript
848 lines
38 KiB
TypeScript
"use client";
|
||
|
||
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 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 };
|
||
|
||
function formatWeight(valueKg: number, unit: Unit) {
|
||
const value = Math.max(0, valueKg) * unitFactors[unit];
|
||
const digits = unit === "g" ? 0 : 3;
|
||
return value.toFixed(digits);
|
||
}
|
||
|
||
function createFileTimestamp(iso: string) {
|
||
return iso.replace(/\D/g, "").slice(0, 14);
|
||
}
|
||
|
||
export default function Home() {
|
||
const [step, setStep] = useState<Step>("home");
|
||
const [employee, setEmployee] = useState({ name: "", id: "" });
|
||
const [machineQuery, setMachineQuery] = useState("");
|
||
const [machine, setMachine] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [notice, setNotice] = useState("");
|
||
const [unit, setUnit] = useState<Unit>("kg");
|
||
const [rawWeight, setRawWeight] = useState(0);
|
||
const [zeroOffset, setZeroOffset] = useState(0);
|
||
const [tare, setTare] = useState(0);
|
||
const [totalKg, setTotalKg] = useState(0);
|
||
const [material, setMaterial] = useState<Material | null>(null);
|
||
const [stable, setStable] = useState(false);
|
||
const [stableProgress, setStableProgress] = useState(0);
|
||
const [countdown, setCountdown] = useState(10);
|
||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||
const [password, setPassword] = useState("");
|
||
const [passwordError, setPasswordError] = useState("");
|
||
const [adminPassword, setAdminPassword] = useState("88888888");
|
||
const [appSettings, setAppSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||
const [materialOptions, setMaterialOptions] = useState<Material[]>(DEFAULT_MATERIALS);
|
||
const [settingsTab, setSettingsTab] = useState<SettingsTab>("device");
|
||
const [settingsMessage, setSettingsMessage] = useState("");
|
||
const [settingsBusy, setSettingsBusy] = useState(false);
|
||
const [passwordForm, setPasswordForm] = useState({ current: "", next: "", confirm: "" });
|
||
const [pendingRecords, setPendingRecords] = useState<WeightRecord[]>([]);
|
||
const [backupRecords, setBackupRecords] = useState<WeightRecord[]>([]);
|
||
const [lastRecord, setLastRecord] = useState<WeightRecord | null>(null);
|
||
const [logs, setLogs] = useState<LogEntry[]>([
|
||
{
|
||
id: "boot",
|
||
time: new Date().toISOString(),
|
||
level: "success",
|
||
message: "应用启动完成,称重设备连接正常",
|
||
},
|
||
]);
|
||
const stableSamples = useRef<number[]>([]);
|
||
|
||
const netWeight = Math.max(0, rawWeight - zeroOffset - tare);
|
||
const displayedWeight = totalKg + netWeight;
|
||
const selectedMaterial = material ?? materialOptions[0];
|
||
|
||
const filteredMachines = useMemo(
|
||
() =>
|
||
machines.filter((item) =>
|
||
item.toLowerCase().includes(machineQuery.trim().toLowerCase()),
|
||
),
|
||
[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();
|
||
const timer = window.setInterval(() => {
|
||
const elapsed = (Date.now() - start) / 1000;
|
||
const target = elapsed < 1.4 ? 0 : 2.486;
|
||
const movement = elapsed < 3 ? Math.sin(elapsed * 9) * 0.085 : Math.sin(elapsed * 3) * 0.0012;
|
||
setRawWeight(Math.max(0, target + movement));
|
||
}, 180);
|
||
return () => window.clearInterval(timer);
|
||
}, [step]);
|
||
|
||
useEffect(() => {
|
||
if (step !== "weigh") return;
|
||
stableSamples.current = [...stableSamples.current.slice(-7), netWeight];
|
||
const samples = stableSamples.current;
|
||
if (samples.length < 6 || netWeight < 0.01) {
|
||
setStable(false);
|
||
setStableProgress(Math.min(85, samples.length * 14));
|
||
return;
|
||
}
|
||
const spread = Math.max(...samples) - Math.min(...samples);
|
||
const isStable = spread < 0.008;
|
||
setStable(isStable);
|
||
setStableProgress(isStable ? 100 : Math.max(25, 90 - spread * 500));
|
||
}, [netWeight, step]);
|
||
|
||
useEffect(() => {
|
||
if (step !== "result") return;
|
||
setCountdown(10);
|
||
const timer = window.setInterval(() => {
|
||
setCountdown((value) => {
|
||
if (value <= 1) {
|
||
window.clearInterval(timer);
|
||
resetToHome();
|
||
return 0;
|
||
}
|
||
return value - 1;
|
||
});
|
||
}, 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, [step]);
|
||
|
||
function resetToHome() {
|
||
setStep("home");
|
||
setMaterial(null);
|
||
setMachine("");
|
||
setMachineQuery("");
|
||
setTotalKg(0);
|
||
setRawWeight(0);
|
||
setZeroOffset(0);
|
||
setTare(0);
|
||
setStable(false);
|
||
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) {
|
||
setNotice("请先选择本次称重的废料类型");
|
||
return;
|
||
}
|
||
if (chosenMaterial) setMaterial(chosenMaterial);
|
||
setLoading(true);
|
||
setNotice("");
|
||
try {
|
||
if (appSettings.employee) {
|
||
const response = await fetch(appSettings.employee);
|
||
if (!response.ok) throw new Error("接口不可用");
|
||
const data = await response.json();
|
||
setEmployee({ name: data.name, id: data.employeeId });
|
||
} else {
|
||
await new Promise((resolve) => window.setTimeout(resolve, 650));
|
||
setEmployee({ name: "张伟", id: "E-0826" });
|
||
setNotice("当前使用模拟数据,接口接入后将自动切换");
|
||
}
|
||
addLog(`人员信息获取成功:${employee.name || "张伟"}`, "success");
|
||
setStep("identity");
|
||
} catch {
|
||
addLog("门禁人员信息获取失败", "error");
|
||
setNotice("暂时无法连接本地服务器,请点击“重新获取”");
|
||
setStep("identity");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function enterAdmin() {
|
||
if (password === adminPassword) {
|
||
setPasswordOpen(false);
|
||
setPassword("");
|
||
setPasswordError("");
|
||
setSettingsMessage("");
|
||
addLog("管理员登录成功", "success");
|
||
setStep("settings");
|
||
} else {
|
||
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",
|
||
);
|
||
}
|
||
}
|
||
|
||
async function submitWeight() {
|
||
if (!stable) {
|
||
setNotice("请等待重量稳定后再保存");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setNotice("");
|
||
const payload = {
|
||
employeeName: employee.name,
|
||
employeeId: employee.id,
|
||
machineId: machine,
|
||
materialType: selectedMaterial.name,
|
||
materialTypeId: selectedMaterial.id,
|
||
weightKg: Number(displayedWeight.toFixed(3)),
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
setLastRecord(payload);
|
||
backupRecord(payload);
|
||
try {
|
||
if (appSettings.upload) {
|
||
const response = await fetch(appSettings.upload, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok) throw new Error("上传失败");
|
||
} else {
|
||
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
||
}
|
||
addLog(`称重数据上传成功:${payload.weightKg.toFixed(3)} kg`, "success");
|
||
runCompletionHooks(payload);
|
||
setStep("result");
|
||
} catch {
|
||
queuePendingRecord(payload);
|
||
setNotice("数据上传失败,请检查服务器连接后重试");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function addToTotal() {
|
||
if (!stable || netWeight <= 0) {
|
||
setNotice("重量稳定后才能累计");
|
||
return;
|
||
}
|
||
setTotalKg((value) => value + netWeight);
|
||
setZeroOffset(rawWeight);
|
||
setTare(0);
|
||
stableSamples.current = [];
|
||
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" />
|
||
<div className="ambient ambient-two" />
|
||
|
||
<section className="device-frame">
|
||
<header className="app-header">
|
||
<button className="brand" onClick={resetToHome} aria-label="返回首页">
|
||
<span className="brand-mark">W</span>
|
||
<span>
|
||
<strong>智能称重终端</strong>
|
||
<small>WOTE · SMART SCALE</small>
|
||
</span>
|
||
</button>
|
||
<div className="status-pill">
|
||
<span className="status-dot" />
|
||
设备在线
|
||
</div>
|
||
</header>
|
||
|
||
{step !== "home" && step !== "settings" && (
|
||
<div className="progress-strip" aria-label="流程进度">
|
||
{["人员确认", "智能称重", "结果上传"].map((label, index) => {
|
||
const activeIndex = step === "identity" ? 0 : step === "weigh" ? 1 : 2;
|
||
return (
|
||
<div className={index <= activeIndex ? "progress-item active" : "progress-item"} key={label}>
|
||
<span>{index < activeIndex ? "✓" : index + 1}</span>
|
||
{label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="content">
|
||
{step === "home" && (
|
||
<div className="home-view">
|
||
<div className="home-copy">
|
||
<div className="eyebrow"><span /> 安全 · 精准 · 可追溯</div>
|
||
<h1>废料称重,<br /><em>简单一点。</em></h1>
|
||
<p>选择废料类型后开始,系统将自动识别员工信息,<br />稳定采样并上传称重记录。</p>
|
||
<div className={loading ? "home-start-hint loading" : "home-start-hint"}>
|
||
<span>{loading ? "···" : "1"}</span>
|
||
<b>{loading ? `正在读取${material?.name ?? ""}称重信息…` : "点击右侧废料类型,即刻开始"}</b>
|
||
</div>
|
||
<button className="admin-link" onClick={() => setPasswordOpen(true)}>⚙ 管理员登录</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>
|
||
</div>
|
||
<div className="home-material-grid">
|
||
{materialOptions.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
className={`material-card ${item.tone} ${material?.id === item.id ? "selected" : ""}`}
|
||
onClick={() => void fetchIdentity(item)}
|
||
disabled={loading}
|
||
aria-pressed={material?.id === item.id}
|
||
>
|
||
<span className="material-icon">{item.icon}</span>
|
||
<span><b>{item.name}</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}` : "请选择一种废料类型,选择后自动进入下一步"}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
)}
|
||
|
||
{step === "identity" && (
|
||
<div className="panel-view identity-view">
|
||
<div className="section-heading">
|
||
<div>
|
||
<span className="step-kicker">01 · 人员与机台</span>
|
||
<h2>确认本次称重信息</h2>
|
||
<p>人员信息由门禁机自动获取,请选择对应生产机台。</p>
|
||
</div>
|
||
<button className="refresh-button" onClick={() => void fetchIdentity()} disabled={loading}>↻ 重新获取</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>
|
||
<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>}
|
||
</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>
|
||
</button>
|
||
{notice && <div className="notice">ⓘ {notice}</div>}
|
||
</div>
|
||
)}
|
||
|
||
{step === "weigh" && (
|
||
<div className="weigh-view">
|
||
<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>
|
||
</div>
|
||
|
||
<div className="scale-display">
|
||
<div className="display-top">
|
||
<span className={stable ? "stable-badge ready" : "stable-badge"}>
|
||
<i /> {stable ? "重量已稳定" : "正在稳定采样"}
|
||
</span>
|
||
<div className="unit-switch">
|
||
{(["kg", "g", "lb"] as Unit[]).map((item) => (
|
||
<button className={unit === item ? "active" : ""} onClick={() => setUnit(item)} key={item}>{item}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="weight-number">
|
||
<span>{formatWeight(displayedWeight, unit)}</span><b>{unit}</b>
|
||
</div>
|
||
<div className="stability-track"><span style={{ width: `${stableProgress}%` }} /></div>
|
||
<div className="display-details">
|
||
<span>净重 <b>{formatWeight(netWeight, unit)} {unit}</b></span>
|
||
<span>累计 <b>{formatWeight(totalKg, unit)} {unit}</b></span>
|
||
<span>皮重 <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>
|
||
|
||
<div className={`selected-material-summary ${selectedMaterial.tone}`}>
|
||
<span className="material-icon">{selectedMaterial.icon}</span>
|
||
<span>
|
||
<small>本次废料类型</small>
|
||
<b>{selectedMaterial.name}</b>
|
||
<em>TYPE ID · {selectedMaterial.id}</em>
|
||
</span>
|
||
<i>✓ 已在首页确认</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>
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{step === "result" && (
|
||
<div className="result-view">
|
||
<div className="success-ring"><span>✓</span></div>
|
||
<span className="step-kicker">称重完成</span>
|
||
<h2>数据已保存</h2>
|
||
<p>本次称重记录已进入上传队列</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-weight">
|
||
<span><small>{selectedMaterial.name}</small><b>{formatWeight(displayedWeight, unit)} <em>{unit}</em></b></span>
|
||
<i>{selectedMaterial.icon}</i>
|
||
</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>
|
||
</dl>
|
||
</div>
|
||
<button className="full-primary" onClick={resetToHome}>返回首页 <span>→</span></button>
|
||
<div className="countdown"><span style={{ width: `${countdown * 10}%` }} />{countdown} 秒后自动返回首页</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>
|
||
|
||
<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>
|
||
)}
|
||
</div>
|
||
|
||
<footer><span>沃尔核材 · 数字化生产</span><span>{appSettings.deviceName} · {appSettings.deviceId} · v1.1</span></footer>
|
||
</section>
|
||
|
||
{passwordOpen && (
|
||
<div className="modal-backdrop" onMouseDown={() => setPasswordOpen(false)}>
|
||
<div className="modal" onMouseDown={(event) => event.stopPropagation()}>
|
||
<button className="modal-close" onClick={() => setPasswordOpen(false)}>×</button>
|
||
<span className="modal-icon">⌁</span>
|
||
<h2>管理员登录</h2>
|
||
<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>
|
||
</div>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|