Files
SmartScale_MODBUS_Woer/app/page.tsx
T

848 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}