Build smart recycling management platform

This commit is contained in:
Codex
2026-07-28 22:58:52 +08:00
commit bb5699d121
30 changed files with 21661 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+2000
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:3000";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const base = new URL(`${protocol}://${host}`);
const title = "沃尔核材智能回收管理平台";
const description = "统一接入人脸识别回收箱、电子台秤、地磅与 RFID 系统的生产回收管理平台。";
return {
metadataBase: base,
title,
description,
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
openGraph: {
title,
description,
type: "website",
images: [{ url: new URL("/og.png", base).toString(), width: 1731, height: 909, alt: title }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [new URL("/og.png", base).toString()],
},
};
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+805
View File
@@ -0,0 +1,805 @@
"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>
);
}