Files
management-platform/app/page.tsx
T

806 lines
47 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, useState } from "react";
type PageKey =
| "dashboard"
| "waste"
| "events"
| "floor"
| "trace"
| "assets"
| "alerts"
| "settings"
| "terminal";
type RoleKey = "admin" | "warehouse" | "operator";
const pageMeta: Record<PageKey, { title: string; sub: string }> = {
dashboard: { title: "运营驾驶舱", sub: "全厂设备与称重业务总览" },
waste: { title: "废料称重", sub: "电子台秤与人脸回收箱原始记录" },
events: { title: "回收事件", sub: "人员进出、投放与视频事件归档" },
floor: { title: "地磅核对", sub: "台秤汇总与地磅增量自动比对" },
trace: { title: "RFID 追溯", sub: "导体称重、批次、标签与 MES 推送" },
assets: { title: "人员与设备", sub: "人员、产线、回收设备统一管理" },
alerts: { title: "告警中心", sub: "偏差、满溢、离线与接口异常" },
settings: { title: "系统设置", sub: "设备接入、权限、字典、MES 与审计" },
terminal: { title: "终端模拟", sub: "回收箱与导体称重操作流程" },
};
const navGroups: Array<{
label: string;
items: Array<{ key: PageKey; label: string; icon: string; roles: RoleKey[] }>;
}> = [
{
label: "总览",
items: [
{ key: "dashboard", label: "运营驾驶舱", icon: "⌂", roles: ["admin", "warehouse", "operator"] },
],
},
{
label: "废料回收",
items: [
{ key: "waste", label: "废料称重", icon: "◫", roles: ["admin", "warehouse"] },
{ key: "events", label: "回收事件", icon: "◎", roles: ["admin", "warehouse"] },
{ key: "floor", label: "地磅核对", icon: "⚖", roles: ["admin", "warehouse"] },
],
},
{
label: "导体称重",
items: [{ key: "trace", label: "RFID 追溯", icon: "◇", roles: ["admin", "operator"] }],
},
{
label: "运营管理",
items: [
{ key: "assets", label: "人员与设备", icon: "◉", roles: ["admin", "warehouse", "operator"] },
{ key: "alerts", label: "告警中心", icon: "!", roles: ["admin"] },
],
},
{
label: "系统",
items: [
{ key: "settings", label: "系统设置", icon: "≡", roles: ["admin"] },
{ key: "terminal", label: "终端模拟", icon: "▣", roles: ["admin", "warehouse", "operator"] },
],
},
];
const wasteRows = [
{ time: "13:33:05", name: "李四", machine: "MC-108", category: "废锡", device: "智能回收箱 B", weight: 19.5, event: "EVT-0722-006", status: "已确认" },
{ time: "13:31:12", name: "李四", machine: "MC-105", category: "废铜", device: "电子台秤 CU-01", weight: 9.9, event: "EVT-0722-006", status: "已确认" },
{ time: "11:08:59", name: "赵六", machine: "MC-112", category: "废电线", device: "电子台秤 WR-01", weight: 14.5, event: "EVT-0722-005", status: "已确认" },
{ time: "11:07:44", name: "赵六", machine: "MC-101", category: "废铝", device: "电子台秤 AL-01", weight: 13.3, event: "EVT-0722-005", status: "已确认" },
{ time: "10:25:07", name: "张三", machine: "MC-105", category: "废电线", device: "电子台秤 WR-01", weight: 11.5, event: "EVT-0722-004", status: "偏差预警" },
{ time: "09:46:20", name: "王五", machine: "MC-112", category: "废铜", device: "电子台秤 CU-01", weight: 21.2, event: "EVT-0722-003", status: "偏差预警" },
{ time: "09:44:03", name: "王五", machine: "MC-112", category: "废铜", device: "电子台秤 CU-01", weight: 11.4, event: "EVT-0722-003", status: "偏差预警" },
{ time: "08:16:35", name: "张三", machine: "MC-103", category: "废锡", device: "人脸回收箱 A", weight: 8.7, event: "EVT-0722-001", status: "已确认" },
];
const eventRows = [
{ id: "EVT-20260722-006", name: "李四", span: "13:30:26 → 13:34:02", duration: "3分36秒", count: 2, total: 29.4, result: "正常" },
{ id: "EVT-20260722-005", name: "赵六", span: "11:05:12 → 11:09:48", duration: "4分36秒", count: 3, total: 44.6, result: "正常" },
{ id: "EVT-20260722-004", name: "张三", span: "10:22:45 → 10:26:10", duration: "3分25秒", count: 2, total: 17.9, result: "超标" },
{ id: "EVT-20260722-003", name: "王五", span: "09:41:07 → 09:47:33", duration: "6分26秒", count: 3, total: 51.2, result: "警告" },
{ id: "EVT-20260722-002", name: "李四", span: "09:02:18 → 09:05:52", duration: "3分34秒", count: 2, total: 22.8, result: "正常" },
];
const floorRows = [
{ event: "EVT-0722-001", category: "废铜", bench: 19.8, floor: 20.0, diff: 0.2, rate: 1.0, result: "正常", stored: "自动入库" },
{ event: "EVT-0722-002", category: "废锡", bench: 8.3, floor: 8.4, diff: 0.1, rate: 1.2, result: "正常", stored: "自动入库" },
{ event: "EVT-0722-003", category: "废铜", bench: 51.2, floor: 53.6, diff: 2.4, rate: 4.7, result: "警告", stored: "已入库·预警" },
{ event: "EVT-0722-004", category: "废电线", bench: 17.9, floor: 19.35, diff: 1.45, rate: 8.1, result: "超标", stored: "已入库·异常" },
{ event: "EVT-0722-005", category: "废铝", bench: 13.3, floor: 13.5, diff: 0.2, rate: 1.5, result: "正常", stored: "自动入库" },
{ event: "EVT-0722-006", category: "废锡", bench: 19.5, floor: 19.75, diff: 0.25, rate: 1.3, result: "正常", stored: "自动入库" },
];
const traceRows = [
{ time: "13:32:08", workshop: "六楼车间", task: "WT-0722-07", rfid: "E280-6102-0351", tare: 149.8, gross: 270.5, machine: "MC-205", batch: "B2607-020", name: "赵六", push: "已推送" },
{ time: "11:07:52", workshop: "六楼车间", task: "WT-0722-06", rfid: "E280-6102-0318", tare: 143.6, gross: 232.2, machine: "MC-205", batch: "B2607-020", name: "赵六", push: "重试中" },
{ time: "10:20:36", workshop: "一楼新能源车间", task: "WT-0722-05", rfid: "E280-6102-0345", tare: 158.4, gross: 289.9, machine: "MC-201", batch: "B2607-018", name: "李四", push: "已推送" },
{ time: "09:48:15", workshop: "一楼新能源车间", task: "WT-0722-04", rfid: "E280-6102-0340", tare: 151.9, gross: 247.6, machine: "MC-203", batch: "B2607-019", name: "王五", push: "待推送" },
{ time: "09:04:41", workshop: "一楼新能源车间", task: "WT-0722-03", rfid: "E280-6102-0335", tare: 165.2, gross: 301.8, machine: "MC-203", batch: "B2607-019", name: "王五", push: "已推送" },
];
const tagSeed = [
{ id: "E280-6102-0331", name: "1# 导体轴", state: "在用", last: "今天 08:15" },
{ id: "E280-6102-0335", name: "3# 导体轴", state: "在用", last: "今天 09:04" },
{ id: "E280-6102-0345", name: "5# 导体轴", state: "在用", last: "今天 10:20" },
{ id: "E280-6102-0360", name: "备用标签 A", state: "未用", last: "—" },
{ id: "E280-6102-0318", name: "7# 导体轴", state: "损坏", last: "昨天 15:03" },
];
const people = [
{ name: "张三", id: "MES-0117", workshop: "一楼新能源车间", role: "仓管员", face: "已录入", sync: "已同步", last: "今日 10:26" },
{ name: "李四", id: "MES-0132", workshop: "一楼新能源车间", role: "产线工人", face: "需更新", sync: "已同步", last: "今日 13:34" },
{ name: "王五", id: "MES-0098", workshop: "一楼新能源车间", role: "产线工人", face: "未录入", sync: "已同步", last: "今日 09:47" },
{ name: "赵六", id: "MES-0201", workshop: "六楼车间", role: "产线工人", face: "已录入", sync: "已同步", last: "今日 11:09" },
{ name: "王工", id: "MES-0007", workshop: "一楼新能源车间", role: "管理员", face: "已录入", sync: "已同步", last: "今日 13:40" },
];
const machines = [
{ id: "MC-101", name: "拉丝机 1#", type: "拉丝机", workshop: "一楼新能源车间", state: "运行中", mes: "正常" },
{ id: "MC-105", name: "绞线机 1#", type: "绞线机", workshop: "一楼新能源车间", state: "运行中", mes: "正常" },
{ id: "MC-112", name: "挤塑机 1#", type: "挤塑机", workshop: "六楼车间", state: "维护中", mes: "正常" },
{ id: "MC-201", name: "导体加工线 1#", type: "导体加工线", workshop: "一楼新能源车间", state: "运行中", mes: "正常" },
{ id: "MC-205", name: "导体加工线 3#", type: "导体加工线", workshop: "六楼车间", state: "停机", mes: "断连" },
];
const devices = [
{ id: "BIN-FACE-01", name: "人脸识别回收箱 A", type: "智能回收箱", place: "一楼废料库", protocol: "HTTPS / WebSocket", state: "在线", heartbeat: "刚刚", value: "62%" },
{ id: "BIN-FACE-02", name: "人脸识别回收箱 B", type: "智能回收箱", place: "六楼车间", protocol: "HTTPS / WebSocket", state: "在线", heartbeat: "2 秒前", value: "86%" },
{ id: "SCALE-B-01", name: "废铜电子台秤", type: "电子台秤", place: "废铜区", protocol: "RS232 → 边缘网关", state: "在线", heartbeat: "1 秒前", value: "12.61 kg" },
{ id: "SCALE-F-01", name: "3 吨地磅", type: "地磅系统", place: "一楼废料库", protocol: "Modbus TCP", state: "在线", heartbeat: "刚刚", value: "1843.2 kg" },
{ id: "RFID-UHF-01", name: "UHF RFID 读写器", type: "RFID 系统", place: "导体称重线", protocol: "TCP / LLRP", state: "在线", heartbeat: "3 秒前", value: "99.1%" },
{ id: "EDGE-GW-01", name: "车间边缘网关", type: "边缘网关", place: "工控机房", protocol: "MQTT over TLS", state: "在线", heartbeat: "刚刚", value: "18 ms" },
];
const alerts = [
{ level: "紧急", time: "10:26", type: "双秤偏差", source: "地磅核对", ref: "EVT-20260722-004 · 废电线", detail: "偏差率 8.1%,超过红色阈值 5%", state: "待处理" },
{ level: "警告", time: "09:47", type: "双秤偏差", source: "地磅核对", ref: "EVT-20260722-003 · 废铜", detail: "偏差率 4.7%,建议人工复核", state: "待处理" },
{ level: "警告", time: "09:12", type: "满溢预警", source: "人脸回收箱 B", ref: "BIN-FACE-02", detail: "满溢率 86%,已通知清运", state: "处理中" },
{ level: "提示", time: "07:55", type: "设备离线", source: "电子台秤", ref: "SCALE-B-01", detail: "心跳中断 42 秒后自动恢复", state: "已关闭" },
];
const logs = [
["13:40:12", "王工", "修改告警阈值", "双秤偏差警告阈值 3% → 2%", "敏感"],
["11:02:33", "王工", "同步 MES 人员", "增量更新 3 条人员数据", "正常"],
["10:28:01", "王工", "审核告警", "EVT-20260722-003 转人工复核", "正常"],
["09:15:47", "王工", "导出报表", "废料称重记录 · 今日全部", "正常"],
["08:50:22", "王工", "更新人脸", "李四 MES-0132 · 旧特征已归档", "敏感"],
];
const integrationCards = [
{ icon: "▦", title: "人脸识别回收箱", protocol: "HTTPS / WebSocket", detail: "人脸身份、门磁、满溢、投放重量、抓拍事件", device: "2 台", status: "待现场联调" },
{ icon: "⚖", title: "电子台秤", protocol: "RS232 / Modbus-RTU", detail: "稳定值、毛重/皮重、精度、去皮、设备心跳", device: "4 台", status: "待协议参数" },
{ icon: "▰", title: "地磅系统", protocol: "RS485 / Modbus TCP", detail: "进场重量、离场重量、净重、车辆/事件关联", device: "1 套", status: "待协议参数" },
{ icon: "◇", title: "RFID 系统", protocol: "TCP / LLRP / 串口", detail: "EPC、读写、标签状态、称重任务和批次绑定", device: "1 套", status: "待协议参数" },
];
function Badge({ value }: { value: string }) {
const kind =
/正常|在线|已确认|已同步|已录入|已推送|自动入库|运行中|在用/.test(value)
? "ok"
: /超标|紧急|断连|损坏|未录入/.test(value)
? "danger"
: /警告|预警|重试|待|维护|需更新|处理中/.test(value)
? "warn"
: "muted";
return <span className={`badge badge-${kind}`}>{value}</span>;
}
function Empty({ children }: { children: React.ReactNode }) {
return <div className="empty">{children}</div>;
}
function PageHeaderActions({
onExport,
onRefresh,
}: {
onExport?: () => void;
onRefresh?: () => void;
}) {
return (
<div className="page-actions">
{onRefresh && (
<button className="button secondary" onClick={onRefresh}>
↻ 刷新
</button>
)}
{onExport && (
<button className="button primary" onClick={onExport}>
↓ 导出报表
</button>
)}
</div>
);
}
function Dashboard({
liveWeight,
notify,
}: {
liveWeight: number;
notify: (text: string) => void;
}) {
const metric = [
["今日回收总量", "386.4", "kg", "+12.6% 较昨日", "mint"],
["今日回收事件", "42", "次", "6 个完整进出事件", "blue"],
["在线设备", "10 / 10", "台", "边缘网关链路正常", "violet"],
["待处理告警", "2", "条", "含 1 条红色告警", "orange"],
];
return (
<>
<section className="hero-panel">
<div>
<div className="eyebrow">SMART RECYCLING CONTROL CENTER</div>
<h2>今天的设备与业务都在正常运转</h2>
<p>所有称重记录、人员身份、RFID 标签和 MES 推送在同一条数据链路上完成追溯。</p>
</div>
<div className="hero-status">
<span className="pulse-dot" />
<div>
<b>边缘网关在线</b>
<small>最近心跳:刚刚 · 延迟 18ms</small>
</div>
</div>
</section>
<section className="metric-grid">
{metric.map(([label, value, unit, hint, tone]) => (
<article className={`metric tone-${tone}`} key={label}>
<span>{label}</span>
<div>
<strong>{value}</strong>
<em>{unit}</em>
</div>
<small>{hint}</small>
</article>
))}
</section>
<section className="dashboard-grid">
<article className="card live-card">
<div className="card-head">
<div>
<span className="section-kicker">LIVE SIGNAL</span>
<h3>实时称重</h3>
</div>
<Badge value="电子台秤在线" />
</div>
<div className="live-readout">
<span>{liveWeight.toFixed(2)}</span>
<small>kg</small>
</div>
<div className="stability">
<span className="stability-bar" />
稳定采样中 · 精度 ±10g
</div>
<div className="live-meta">
<div><small>当前操作人</small><b>王工</b></div>
<div><small>来源机台</small><b>MC-101</b></div>
<div><small>废料类别</small><b>废铜</b></div>
</div>
<button className="button primary block" onClick={() => notify("已模拟生成一条稳定称重记录")}>
生成稳定值记录
</button>
</article>
<article className="card trend-card">
<div className="card-head">
<div>
<span className="section-kicker">7 DAY TREND</span>
<h3>近 7 日回收趋势</h3>
</div>
<span className="quiet">单位:kg</span>
</div>
<div className="bars">
{[68, 45, 82, 56, 92, 74, 88].map((height, index) => (
<div className="bar-wrap" key={height + index}>
<div className="bar-value">{[301, 246, 362, 278, 418, 344, 386][index]}</div>
<div className="bar" style={{ height: `${height}%` }} />
<small>{["一", "二", "三", "四", "五", "六", "日"][index]}</small>
</div>
))}
</div>
<div className="trend-legend">
<span><i className="legend-dot copper" />废铜 42%</span>
<span><i className="legend-dot wire" />废电线 28%</span>
<span><i className="legend-dot aluminum" />废铝 19%</span>
<span><i className="legend-dot tin" />废锡 11%</span>
</div>
</article>
<article className="card device-health">
<div className="card-head">
<div>
<span className="section-kicker">DEVICE HEALTH</span>
<h3>设备健康</h3>
</div>
<Badge value="全部在线" />
</div>
{[
["人脸回收箱", "2 / 2", "98%"],
["电子台秤", "4 / 4", "99%"],
["地磅系统", "1 / 1", "97%"],
["RFID 读写器", "1 / 1", "99%"],
["边缘网关", "1 / 1", "100%"],
].map(([name, count, health]) => (
<div className="health-row" key={name}>
<span className="online-dot" />
<div><b>{name}</b><small>{count} 在线</small></div>
<strong>{health}</strong>
</div>
))}
</article>
</section>
<section className="split-grid">
<article className="card">
<div className="card-head">
<div>
<span className="section-kicker">RECENT RECORDS</span>
<h3>最近称重流水</h3>
</div>
</div>
<div className="compact-list">
{wasteRows.slice(0, 5).map((row) => (
<div className="compact-row" key={row.time + row.machine}>
<span className="category-mark" data-category={row.category} />
<div><b>{row.name} · {row.machine}</b><small>{row.category} · {row.device}</small></div>
<strong>{row.weight.toFixed(2)} kg</strong>
<span>{row.time}</span>
</div>
))}
</div>
</article>
<article className="card">
<div className="card-head">
<div>
<span className="section-kicker">INTEGRATION FLOW</span>
<h3>统一设备链路</h3>
</div>
</div>
<div className="flow">
<div><span>01</span><b>现场设备</b><small>人脸箱 · 台秤 · 地磅 · RFID</small></div>
<i>→</i>
<div><span>02</span><b>边缘网关</b><small>协议适配 · 断网缓存</small></div>
<i>→</i>
<div><span>03</span><b>管理平台</b><small>业务规则 · 告警 · 追溯</small></div>
<i>→</i>
<div><span>04</span><b>MES</b><small>人员 · 机台 · 批次 · 上报</small></div>
</div>
</article>
</section>
</>
);
}
function WastePage({ notify }: { notify: (text: string) => void }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("全部");
const [view, setView] = useState<"list" | "stats">("list");
const filtered = wasteRows.filter(
(row) =>
(category === "全部" || row.category === category) &&
[row.name, row.machine, row.event, row.device].join(" ").toLowerCase().includes(query.toLowerCase()),
);
return (
<section className="card">
<div className="toolbar">
<div className="segmented">
{["全部", "废铜", "废电线", "废铝", "废锡"].map((item) => (
<button className={category === item ? "active" : ""} onClick={() => setCategory(item)} key={item}>{item}</button>
))}
</div>
<input className="search-input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索操作人、机台、事件 ID…" />
<PageHeaderActions onRefresh={() => notify("称重记录已刷新")} onExport={() => exportCsv("废料称重记录", filtered)} />
</div>
<div className="tabs">
<button className={view === "list" ? "active" : ""} onClick={() => setView("list")}>记录列表</button>
<button className={view === "stats" ? "active" : ""} onClick={() => setView("stats")}>统计视图</button>
</div>
{view === "list" ? (
filtered.length ? (
<div className="table-scroll">
<table>
<thead><tr><th>称重时间</th><th>操作人</th><th>关联机台</th><th>废料类别</th><th>来源设备</th><th className="number">精确重量</th><th>事件 ID</th><th>状态</th></tr></thead>
<tbody>
{filtered.map((row) => (
<tr key={row.time + row.machine}><td>{row.time}</td><td>{row.name}</td><td className="mono">{row.machine}</td><td><span className="category-cell"><i className="category-mark" data-category={row.category} />{row.category}</span></td><td>{row.device}</td><td className="number strong">{row.weight.toFixed(2)} kg</td><td className="mono">{row.event}</td><td><Badge value={row.status} /></td></tr>
))}
</tbody>
</table>
</div>
) : <Empty>没有符合筛选条件的称重记录</Empty>
) : (
<div className="stats-view">
<div className="stats-numbers">
<article><span>今日总量</span><b>386.4 kg</b><small>较昨日 +12.6%</small></article>
<article><span>称重次数</span><b>42 次</b><small>原子记录 42 条</small></article>
<article><span>本月累计</span><b>8.52 t</b><small>自动入库率 100%</small></article>
<article><span>日均投放</span><b>392 kg</b><small>近 30 日均值</small></article>
</div>
<div className="rank-list">
{[["MC-112", 89], ["MC-101", 78], ["MC-108", 63], ["MC-105", 51]].map(([name, width], index) => (
<div key={name}><span>{index + 1}</span><b>{name}</b><i><em style={{ width: `${width}%` }} /></i><strong>{width} kg</strong></div>
))}
</div>
</div>
)}
</section>
);
}
function EventsPage({ notify }: { notify: (text: string) => void }) {
const [query, setQuery] = useState("");
const [result, setResult] = useState("全部");
const filtered = eventRows.filter((row) => (result === "全部" || row.result === result) && `${row.id}${row.name}`.toLowerCase().includes(query.toLowerCase()));
return (
<section className="card">
<div className="toolbar">
<div className="segmented">
{["全部", "正常", "警告", "超标"].map((item) => <button key={item} className={result === item ? "active" : ""} onClick={() => setResult(item)}>{item}</button>)}
</div>
<input className="search-input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索事件 ID 或操作人…" />
<PageHeaderActions onRefresh={() => notify("事件列表已刷新")} onExport={() => exportCsv("回收事件", filtered)} />
</div>
<div className="table-scroll">
<table>
<thead><tr><th>事件 ID</th><th>操作人</th><th>进入 → 离开</th><th>历时</th><th>原子笔数</th><th className="number">台秤总重</th><th>判定</th><th>视频</th></tr></thead>
<tbody>
{filtered.map((row) => <tr key={row.id}><td className="mono">{row.id}</td><td>{row.name}</td><td className="mono">{row.span}</td><td>{row.duration}</td><td>{row.count} 条</td><td className="number strong">{row.total.toFixed(2)} kg</td><td><Badge value={row.result} /></td><td><button className="text-button" onClick={() => notify(`${row.id} 的监控录像已定位`)}>查看录像</button></td></tr>)}
</tbody>
</table>
</div>
</section>
);
}
function FloorPage({ notify }: { notify: (text: string) => void }) {
const [result, setResult] = useState("全部");
const filtered = floorRows.filter((row) => result === "全部" || row.result === result);
return (
<>
<section className="rule-strip">
<div><span>自动比对规则</span><b>正常 ≤ 2%</b><b className="warn-text">警告 2%—5%</b><b className="danger-text">超标 &gt; 5%</b></div>
<button className="button secondary" onClick={() => notify("阈值设置已打开,正式版将写入审计日志")}>调整阈值</button>
</section>
<section className="card">
<div className="toolbar">
<div className="segmented">
{["全部", "正常", "警告", "超标"].map((item) => <button key={item} className={result === item ? "active" : ""} onClick={() => setResult(item)}>{item}</button>)}
</div>
<PageHeaderActions onExport={() => exportCsv("地磅核对", filtered)} />
</div>
<div className="table-scroll">
<table>
<thead><tr><th>事件 ID</th><th>废料类别</th><th className="number">台秤汇总</th><th className="number">地磅增量</th><th className="number">偏差值</th><th className="number">偏差率</th><th>判定</th><th>入库状态</th><th>操作</th></tr></thead>
<tbody>
{filtered.map((row) => <tr key={row.event + row.category}><td className="mono">{row.event}</td><td>{row.category}</td><td className="number">{row.bench.toFixed(2)} kg</td><td className="number">{row.floor.toFixed(2)} kg</td><td className="number strong">{row.diff.toFixed(2)} kg</td><td className={`number strong ${row.result === "超标" ? "danger-text" : row.result === "警告" ? "warn-text" : ""}`}>{row.rate.toFixed(1)}%</td><td><Badge value={row.result} /></td><td><Badge value={row.stored} /></td><td><button className="text-button" onClick={() => notify(`${row.event} 已进入人工复核流程`)}>人工复核</button></td></tr>)}
</tbody>
</table>
</div>
</section>
</>
);
}
function TracePage({ notify }: { notify: (text: string) => void }) {
const [tab, setTab] = useState<"trace" | "tags">("trace");
const [query, setQuery] = useState("");
const [tags, setTags] = useState(tagSeed);
const rows = traceRows.filter((row) => Object.values(row).join(" ").toLowerCase().includes(query.toLowerCase()));
const addTag = () => {
const next = { id: `E280-6102-${String(370 + tags.length)}`, name: `备用标签 ${String.fromCharCode(65 + tags.length)}`, state: "未用", last: "—" };
setTags((current) => [...current, next]);
notify(`RFID 标签 ${next.id} 已添加`);
};
return (
<section className="card">
<div className="tabs">
<button className={tab === "trace" ? "active" : ""} onClick={() => setTab("trace")}>称重溯源</button>
<button className={tab === "tags" ? "active" : ""} onClick={() => setTab("tags")}>RFID 标签</button>
</div>
{tab === "trace" ? (
<>
<div className="toolbar">
<input className="search-input wide" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务号、RFID、批次号、机台…" />
<PageHeaderActions onRefresh={() => notify("RFID 数据已刷新")} onExport={() => exportCsv("RFID称重追溯", rows)} />
</div>
<div className="table-scroll">
<table>
<thead><tr><th>时间</th><th>所属车间</th><th>任务号</th><th>RFID 标签</th><th className="number">皮重</th><th className="number">毛重</th><th className="number">净重</th><th>机台</th><th>批次</th><th>操作人</th><th>MES</th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.task}><td>{row.time}</td><td>{row.workshop}</td><td className="mono">{row.task}</td><td className="mono">{row.rfid}</td><td className="number">{row.tare.toFixed(2)}</td><td className="number">{row.gross.toFixed(2)}</td><td className="number strong">{(row.gross - row.tare).toFixed(2)} kg</td><td className="mono">{row.machine}</td><td className="mono">{row.batch}</td><td>{row.name}</td><td><Badge value={row.push} /></td></tr>)}</tbody>
</table>
</div>
</>
) : (
<>
<div className="toolbar">
<span className="quiet">标签写入、检测、报废与称重任务绑定</span>
<button className="button primary push-right" onClick={addTag}>+ 添加标签</button>
</div>
<div className="table-scroll">
<table>
<thead><tr><th>标签 EPC</th><th>名称</th><th>状态</th><th>最后检测</th><th>操作</th></tr></thead>
<tbody>{tags.map((tag) => <tr key={tag.id}><td className="mono">{tag.id}</td><td>{tag.name}</td><td><Badge value={tag.state} /></td><td>{tag.last}</td><td><button className="text-button" onClick={() => notify(`${tag.id} 检测完成:信号正常`)}>检测</button><button className="text-button danger-text" onClick={() => setTags((current) => current.map((item) => item.id === tag.id ? { ...item, state: "损坏" } : item))}>标记损坏</button></td></tr>)}</tbody>
</table>
</div>
</>
)}
</section>
);
}
function AssetsPage({ notify }: { notify: (text: string) => void }) {
const [tab, setTab] = useState<"people" | "machines" | "devices">("people");
return (
<section className="card">
<div className="tabs">
<button className={tab === "people" ? "active" : ""} onClick={() => setTab("people")}>人员</button>
<button className={tab === "machines" ? "active" : ""} onClick={() => setTab("machines")}>机台设备</button>
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}>回收设备</button>
</div>
{tab === "people" && (
<>
<div className="toolbar"><span className="quiet">人员主数据以 MES 为准,本系统维护人脸与使用权限</span><button className="button primary push-right" onClick={() => notify("MES 人员增量同步完成:更新 3 条")}>↻ 同步 MES</button></div>
<div className="table-scroll"><table><thead><tr><th>姓名</th><th>MES 员工 ID</th><th>所属车间</th><th>角色</th><th>人脸状态</th><th>同步状态</th><th>最近操作</th><th>操作</th></tr></thead><tbody>{people.map((person) => <tr key={person.id}><td className="strong">{person.name}</td><td className="mono">{person.id}</td><td>{person.workshop}</td><td>{person.role}</td><td><Badge value={person.face} /></td><td><Badge value={person.sync} /></td><td>{person.last}</td><td><button className="text-button" onClick={() => notify(`${person.name} 的人脸特征管理已打开`)}>人脸管理</button><button className="text-button">编辑</button></td></tr>)}</tbody></table></div>
</>
)}
{tab === "machines" && (
<div className="table-scroll"><table><thead><tr><th>机台编号</th><th>机台名称</th><th>类型</th><th>所属车间</th><th>运行状态</th><th>MES 链路</th></tr></thead><tbody>{machines.map((machine) => <tr key={machine.id}><td className="mono">{machine.id}</td><td className="strong">{machine.name}</td><td>{machine.type}</td><td>{machine.workshop}</td><td><Badge value={machine.state} /></td><td><Badge value={machine.mes} /></td></tr>)}</tbody></table></div>
)}
{tab === "devices" && (
<div className="device-grid">
{devices.map((device) => <article className="device-card" key={device.id}><div className="device-card-top"><span className="online-dot" /><Badge value={device.state} /></div><h3>{device.name}</h3><p>{device.id} · {device.place}</p><div className="device-value">{device.value}</div><dl><div><dt>设备类型</dt><dd>{device.type}</dd></div><div><dt>通信方式</dt><dd>{device.protocol}</dd></div><div><dt>最后心跳</dt><dd>{device.heartbeat}</dd></div></dl><button className="button secondary block" onClick={() => notify(`${device.name} 诊断完成:通信正常`)}>运行诊断</button></article>)}
</div>
)}
</section>
);
}
function AlertsPage({ notify }: { notify: (text: string) => void }) {
const [items, setItems] = useState(alerts);
return (
<>
<section className="alert-summary">
<div><span>紧急</span><b>1</b></div><div><span>警告</span><b>2</b></div><div><span>处理中</span><b>1</b></div><div><span>今日已关闭</span><b>8</b></div>
<button className="button secondary push-right" onClick={() => notify("告警阈值设置已打开")}>阈值设置</button>
</section>
<section className="alert-list">
{items.map((item) => (
<article className={`alert-card alert-${item.level}`} key={item.time + item.ref}>
<div className="alert-level"><span>{item.level}</span><b>{item.time}</b></div>
<div className="alert-main"><div><h3>{item.type}</h3><Badge value={item.state} /></div><p>{item.ref}</p><small>{item.detail}</small></div>
<div className="alert-actions"><button className="button secondary" onClick={() => notify(`${item.ref} 的详情已定位`)}>查看详情</button>{item.state !== "已关闭" && <button className="button primary" onClick={() => setItems((current) => current.map((alert) => alert.ref === item.ref ? { ...alert, state: "已关闭" } : alert))}>关闭告警</button>}</div>
</article>
))}
</section>
</>
);
}
function SettingsPage({ notify }: { notify: (text: string) => void }) {
const [tab, setTab] = useState<"integration" | "accounts" | "workshops" | "mes" | "logs">("integration");
const [testing, setTesting] = useState("");
const test = (title: string) => {
setTesting(title);
window.setTimeout(() => {
setTesting("");
notify(`${title} 接入测试完成:等待现场网络和协议参数`);
}, 700);
};
return (
<section className="card settings-card">
<div className="tabs scroll-tabs">
<button className={tab === "integration" ? "active" : ""} onClick={() => setTab("integration")}>设备接入中心</button>
<button className={tab === "accounts" ? "active" : ""} onClick={() => setTab("accounts")}>账号与权限</button>
<button className={tab === "workshops" ? "active" : ""} onClick={() => setTab("workshops")}>车间字典</button>
<button className={tab === "mes" ? "active" : ""} onClick={() => setTab("mes")}>MES 接口</button>
<button className={tab === "logs" ? "active" : ""} onClick={() => setTab("logs")}>操作日志</button>
</div>
{tab === "integration" && (
<div className="integration-page">
<div className="integration-intro">
<div><span className="section-kicker">UNIFIED DEVICE ACCESS</span><h3>四类硬件统一接入</h3><p>现场串口、RS485 和局域网设备先连接边缘网关,再通过加密 MQTT/HTTPS 与管理平台通信。断网时本地缓存,恢复后自动补传。</p></div>
<div className="gateway-chip"><span className="pulse-dot" /><b>EDGE-GW-01</b><small>网关服务在线 · 演示配置</small></div>
</div>
<div className="integration-grid">
{integrationCards.map((item) => (
<article className="integration-card" key={item.title}>
<div className="integration-icon">{item.icon}</div>
<div className="integration-title"><div><h3>{item.title}</h3><span>{item.device}</span></div><Badge value={item.status} /></div>
<p>{item.detail}</p>
<dl><div><dt>推荐协议</dt><dd>{item.protocol}</dd></div><div><dt>接入方式</dt><dd>车间边缘网关</dd></div><div><dt>数据保障</dt><dd>签名校验 · 离线补传 · 去重</dd></div></dl>
<button className="button secondary block" disabled={testing === item.title} onClick={() => test(item.title)}>{testing === item.title ? "正在检测…" : "测试接入"}</button>
</article>
))}
</div>
<div className="protocol-form">
<div className="card-head"><div><span className="section-kicker">GATEWAY PROFILE</span><h3>边缘网关配置</h3></div><Badge value="配置未下发" /></div>
<div className="form-grid">
<label><span>网关编号</span><input defaultValue="EDGE-GW-01" /></label>
<label><span>车间位置</span><select defaultValue="一楼废料库"><option>一楼废料库</option><option>六楼车间</option></select></label>
<label><span>上报地址</span><input defaultValue="mqtts://gateway.example.com:8883" /></label>
<label><span>心跳频率</span><select defaultValue="15 秒"><option>5 秒</option><option>15 秒</option><option>30 秒</option></select></label>
</div>
<div className="form-actions"><span>正式接入前需提供每台设备的品牌型号、通信协议、寄存器/数据字典和现场网络条件。</span><button className="button primary" onClick={() => notify("网关配置已保存为草稿")}>保存配置草稿</button></div>
</div>
</div>
)}
{tab === "accounts" && (
<div className="table-scroll"><table><thead><tr><th>姓名</th><th>工号</th><th>部门</th><th>系统角色</th><th>权限范围</th><th>状态</th></tr></thead><tbody>{people.map((person) => <tr key={person.id}><td>{person.name}</td><td className="mono">{person.id}</td><td>{person.workshop}</td><td>{person.role}</td><td>{person.role === "管理员" ? "全部模块与系统配置" : person.role === "仓管员" ? "废料回收与设备查看" : "导体称重与 RFID 追溯"}</td><td><Badge value="已启用" /></td></tr>)}</tbody></table></div>
)}
{tab === "workshops" && (
<div className="dictionary"><div className="toolbar"><span className="quiet">人员和机台统一引用车间主数据</span><button className="button primary push-right" onClick={() => notify("新增车间表单已打开")}>+ 新增车间</button></div>{[["WS-01", "一楼新能源车间", "1F · 东侧", "5 人", "5 台"], ["WS-02", "六楼车间", "6F · 西侧", "2 人", "3 台"]].map((row) => <article key={row[0]}><span className="mono">{row[0]}</span><div><b>{row[1]}</b><small>{row[2]}</small></div><div><small>关联人员</small><b>{row[3]}</b></div><div><small>关联机台</small><b>{row[4]}</b></div><button className="text-button">编辑</button></article>)}</div>
)}
{tab === "mes" && (
<div className="mes-layout">
<div className="protocol-form">
<div className="card-head"><div><span className="section-kicker">MES CONNECTOR</span><h3>接口配置</h3></div><Badge value="链路正常" /></div>
<div className="form-grid one">
<label><span>接口地址</span><input defaultValue="https://mes.example.local/api/v2" /></label>
<label><span>认证方式</span><select><option>OAuth 2.0 Client Credentials</option><option>签名 Token</option></select></label>
<label><span>失败重试</span><select><option>自动重试 3 次 · 间隔 5 秒</option><option>进入人工队列</option></select></label>
<label><span>离线队列</span><select><option>本地排队 · 恢复后按序补推</option><option>仅保留失败记录</option></select></label>
</div>
<div className="form-actions"><span>人员、机台、批次向下同步;称重结果向上推送。</span><button className="button primary" onClick={() => notify("MES 连接测试成功,响应耗时 186ms")}>测试连接</button></div>
</div>
<div className="push-log"><h3>最近推送</h3>{traceRows.slice(0, 4).map((row) => <div key={row.task}><span>{row.time}</span><b>{row.task}</b><Badge value={row.push} /></div>)}</div>
</div>
)}
{tab === "logs" && (
<><div className="toolbar"><span className="quiet">日志不可修改,建议至少保留 180 天</span><PageHeaderActions onExport={() => exportCsv("系统操作日志", logs)} /></div><div className="table-scroll"><table><thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>详情</th><th>级别</th></tr></thead><tbody>{logs.map((row) => <tr key={row.join("-")}><td className="mono">{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td><td>{row[3]}</td><td><Badge value={row[4]} /></td></tr>)}</tbody></table></div></>
)}
</section>
);
}
function TerminalPage({ notify }: { notify: (text: string) => void }) {
const [wasteStep, setWasteStep] = useState(0);
const [rfidStep, setRfidStep] = useState(0);
const wasteFlow = [
{ no: "W1", title: "身份验证", big: "请面向摄像头", detail: "支持人脸、工号、IC 卡三种身份方式", action: "模拟识别:张三" },
{ no: "W2", title: "门锁已开启", big: "04:58", detail: "请进入投放,门磁与地磅正在记录", action: "模拟投放完成" },
{ no: "W3", title: "投放确认", big: "38.50 kg", detail: "台秤汇总 38.50kg · 地磅增量 39.00kg · 偏差 1.3%", action: "确认并上报 MES" },
{ no: "W4", title: "处理完成", big: "数据已归档", detail: "事件 EVT-20260722-001 已生成", action: "重新开始" },
];
const rfidFlow = [
{ no: "C0", title: "导体轴称重", big: "请选择称重类型", detail: "全程追溯并自动上报 MES", action: "一次称重 · 皮重" },
{ no: "C1", title: "RFID 读取成功", big: "152.30 kg", detail: "E280-6102-0345 · 历史皮重偏差 < 0.5kg", action: "模拟二次称重" },
{ no: "C2", title: "净重计算完成", big: "115.80 kg", detail: "毛重 268.10kg − 皮重 152.30kg", action: "上报 MES 并打印" },
{ no: "C3", title: "任务完成", big: "MES I-04 成功", detail: "标签、批次、机台与净重已绑定", action: "重新开始" },
];
const advance = (kind: "waste" | "rfid") => {
if (kind === "waste") {
const next = (wasteStep + 1) % wasteFlow.length;
setWasteStep(next);
if (next === 0) notify("废料回收终端流程已重置");
} else {
const next = (rfidStep + 1) % rfidFlow.length;
setRfidStep(next);
if (next === 0) notify("RFID 称重终端流程已重置");
}
};
return (
<div className="terminal-grid">
{[
{ label: "人脸识别回收箱终端", color: "green", current: wasteFlow[wasteStep], step: wasteStep, click: () => advance("waste") },
{ label: "RFID 导体称重终端", color: "blue", current: rfidFlow[rfidStep], step: rfidStep, click: () => advance("rfid") },
].map((terminal) => (
<article className="terminal-shell" key={terminal.label}>
<div className="terminal-top"><span className={`terminal-light ${terminal.color}`} />{terminal.label}<small>ANDROID INDUSTRIAL HMI</small></div>
<div className={`terminal-screen terminal-${terminal.color}`}>
<div className="terminal-step">{terminal.current.no}</div>
<span>{terminal.current.title}</span>
<h3>{terminal.current.big}</h3>
<p>{terminal.current.detail}</p>
<button onClick={terminal.click}>{terminal.current.action} →</button>
</div>
<div className="step-dots">{[0, 1, 2, 3].map((dot) => <i className={dot === terminal.step ? "active" : ""} key={dot} />)}</div>
</article>
))}
</div>
);
}
function exportCsv(name: string, data: unknown[]) {
const rows = data.map((item) => (Array.isArray(item) ? item : Object.values(item as Record<string, unknown>)));
const headers = data.length && !Array.isArray(data[0]) ? Object.keys(data[0] as Record<string, unknown>) : [];
const csv = [headers, ...rows].map((row) => row.map((cell) => `"${String(cell ?? "").replaceAll('"', '""')}"`).join(",")).join("\n");
const blob = new Blob([`\ufeff${csv}`], { type: "text/csv;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${name}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
export default function Home() {
const [page, setPage] = useState<PageKey>("dashboard");
const [role, setRole] = useState<RoleKey>("admin");
const [clock, setClock] = useState("00:00:00");
const [date, setDate] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [toast, setToast] = useState("");
const [liveWeight, setLiveWeight] = useState(12.61);
useEffect(() => {
const tick = () => {
const now = new Date();
setClock(now.toLocaleTimeString("zh-CN", { hour12: false }));
setDate(now.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" }));
};
tick();
const timer = window.setInterval(tick, 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
const timer = window.setInterval(() => setLiveWeight((value) => Math.max(0, value + (Math.random() - 0.5) * 0.03)), 1500);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
const allowed = navGroups.flatMap((group) => group.items).find((item) => item.key === page)?.roles.includes(role);
if (!allowed) setPage("dashboard");
}, [page, role]);
const notify = (text: string) => {
setToast(text);
window.setTimeout(() => setToast(""), 2600);
};
const current = pageMeta[page];
const roleLabel = { admin: "管理员", warehouse: "仓管员", operator: "产线工人" }[role];
const pageContent = useMemo(() => {
if (page === "dashboard") return <Dashboard liveWeight={liveWeight} notify={notify} />;
if (page === "waste") return <WastePage notify={notify} />;
if (page === "events") return <EventsPage notify={notify} />;
if (page === "floor") return <FloorPage notify={notify} />;
if (page === "trace") return <TracePage notify={notify} />;
if (page === "assets") return <AssetsPage notify={notify} />;
if (page === "alerts") return <AlertsPage notify={notify} />;
if (page === "settings") return <SettingsPage notify={notify} />;
return <TerminalPage notify={notify} />;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, liveWeight]);
return (
<div className="app-shell">
<aside className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="brand">
<div className="brand-symbol"><span>W</span></div>
<div><b>沃尔核材</b><small>智能回收管理平台</small></div>
<em>V1.0</em>
</div>
<nav>
{navGroups.map((group) => {
const visible = group.items.filter((item) => item.roles.includes(role));
if (!visible.length) return null;
return (
<div className="nav-group" key={group.label}>
<span>{group.label}</span>
{visible.map((item) => (
<button className={page === item.key ? "active" : ""} key={item.key} onClick={() => { setPage(item.key); setSidebarOpen(false); }}>
<i>{item.icon}</i>{item.label}
{item.key === "alerts" && <em>3</em>}
</button>
))}
</div>
);
})}
</nav>
<div className="sidebar-foot">
<div><span className="pulse-dot" /><b>10 / 10 设备在线</b></div>
<small>网关 EDGE-GW-01 · 18ms</small>
</div>
</aside>
<div className="main-shell">
<header className="topbar">
<button className="mobile-menu" onClick={() => setSidebarOpen((open) => !open)} aria-label="打开菜单">☰</button>
<div className="page-title"><h1>{current.title}</h1><p>{current.sub}</p></div>
<div className="topbar-right">
<div className="mes-state"><span className="online-dot" />MES 已连接</div>
<div className="clock"><b>{clock}</b><small>{date}</small></div>
<button className="alert-bell" onClick={() => setPage("alerts")} aria-label="告警中心">!<em>3</em></button>
<select value={role} onChange={(event) => setRole(event.target.value as RoleKey)} aria-label="切换角色">
<option value="admin">管理员</option>
<option value="warehouse">仓管员</option>
<option value="operator">产线工人</option>
</select>
<div className="avatar">王</div>
</div>
</header>
<main className="content-shell">
<div className="mobile-role">当前视角:{roleLabel}</div>
{pageContent}
</main>
</div>
{sidebarOpen && <button className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} aria-label="关闭菜单" />}
{toast && <div className="toast"><span>✓</span>{toast}</div>}
</div>
);
}