432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
"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>(materials[0]);
|
||
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 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");
|
||
setMachine("");
|
||
setMachineQuery("");
|
||
setTotalKg(0);
|
||
setRawWeight(0);
|
||
setZeroOffset(0);
|
||
setTare(0);
|
||
setStable(false);
|
||
stableSamples.current = [];
|
||
}
|
||
|
||
async function fetchIdentity() {
|
||
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: material.name,
|
||
materialTypeId: material.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="eyebrow"><span /> 安全 · 精准 · 可追溯</div>
|
||
<h1>废料称重,<br /><em>简单一点。</em></h1>
|
||
<p>自动识别员工信息,稳定采样并上传记录。<br />请确认秤台处于空载状态后开始。</p>
|
||
<button className="primary-cta" onClick={fetchIdentity} disabled={loading}>
|
||
<span className="cta-icon">↗</span>
|
||
<span><strong>{loading ? "正在连接…" : "开始称重"}</strong><small>读取门禁人员信息</small></span>
|
||
<b>→</b>
|
||
</button>
|
||
<button className="admin-link" onClick={() => setPasswordOpen(true)}>⚙ 管理员登录</button>
|
||
<div className="home-stats">
|
||
<span><b>0.001 kg</b>显示精度</span>
|
||
<span><b>连续采样</b>稳定判断</span>
|
||
<span><b>4 种</b>废料分类</span>
|
||
</div>
|
||
</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={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>
|
||
<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="material-heading"><span><b>选择废料类型</b><small>点击分类后保存称重记录</small></span><em>必选</em></div>
|
||
<div className="material-grid">
|
||
{materials.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
className={`material-card ${item.tone} ${material.id === item.id ? "selected" : ""}`}
|
||
onClick={() => setMaterial(item)}
|
||
>
|
||
<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>
|
||
{notice && <div className="notice compact">ⓘ {notice}</div>}
|
||
<button className="full-primary upload" disabled={!stable || loading} onClick={submitWeight}>
|
||
{loading ? "正在上传…" : stable ? `保存 ${material.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>{material.name}</small><b>{formatWeight(displayedWeight, unit)} <em>{unit}</em></b></span>
|
||
<i>{material.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>{material.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>
|
||
);
|
||
}
|