Files
SmartScale_MODBUS_Woer/app/page.tsx
T

465 lines
20 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 Material = {
id: string;
name: string;
tone: string;
icon: string;
};
const 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 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);
}
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 [apiSettings, setApiSettings] = useState({
employee: "",
machines: "",
weight: "",
upload: "",
});
const stableSamples = useRef<number[]>([]);
const netWeight = Math.max(0, rawWeight - zeroOffset - tare);
const displayedWeight = totalKg + netWeight;
const selectedMaterial = material ?? materials[0];
const filteredMachines = useMemo(
() =>
machines.filter((item) =>
item.toLowerCase().includes(machineQuery.trim().toLowerCase()),
),
[machineQuery],
);
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 = [];
}
async function fetchIdentity(chosenMaterial?: Material) {
const activeMaterial = chosenMaterial ?? material;
if (!activeMaterial) {
setNotice("请先选择本次称重的废料类型");
return;
}
if (chosenMaterial) setMaterial(chosenMaterial);
setLoading(true);
setNotice("");
try {
if (apiSettings.employee) {
const response = await fetch(apiSettings.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("当前使用模拟数据,接口接入后将自动切换");
}
setStep("identity");
} catch {
setNotice("暂时无法连接本地服务器,请点击“重新获取”");
setStep("identity");
} finally {
setLoading(false);
}
}
function enterAdmin() {
if (password === "888888") {
setPasswordOpen(false);
setPassword("");
setPasswordError("");
setStep("settings");
} else {
setPasswordError("密码不正确,演示密码为 888888");
}
}
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(),
};
try {
if (apiSettings.upload) {
const response = await fetch(apiSettings.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));
}
setStep("result");
} catch {
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},可继续放置下一批`);
}
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">
{materials.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().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">
<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>
<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>
</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>请输入六位管理密码进入设置</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="管理员密码" />
{passwordError && <small className="password-error">{passwordError}</small>}
<button className="full-primary" onClick={enterAdmin}>确认登录 <span>→</span></button>
</div>
</div>
)}
</main>
);
}