feat: 初始化星元智灵人脸识别回收箱项目
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Row = Record<string, string | number | null>;
|
||||
type ConsoleData = {
|
||||
people: Row[];
|
||||
workshops: Row[];
|
||||
devices: Row[];
|
||||
weighings: Row[];
|
||||
events: Row[];
|
||||
reconciliations: Row[];
|
||||
traces: Row[];
|
||||
tags: Row[];
|
||||
alerts: Row[];
|
||||
settings: Record<string, string>;
|
||||
logs: Row[];
|
||||
serverTime: number;
|
||||
};
|
||||
|
||||
type PageKey =
|
||||
| "dashboard"
|
||||
| "weighings"
|
||||
| "events"
|
||||
| "reconcile"
|
||||
| "trace"
|
||||
| "assets"
|
||||
| "alerts"
|
||||
| "settings"
|
||||
| "simulator";
|
||||
|
||||
type RoleKey = "warehouse" | "worker" | "admin";
|
||||
|
||||
const pageMeta: Record<PageKey, { name: string; en: string; icon: string }> = {
|
||||
dashboard: { name: "工作台", en: "Dashboard", icon: "◫" },
|
||||
weighings: { name: "废料称重", en: "Weighing records", icon: "▦" },
|
||||
events: { name: "事件列表", en: "Events", icon: "◎" },
|
||||
reconcile: { name: "地磅核对", en: "Reconciliation", icon: "⚖" },
|
||||
trace: { name: "导体追溯", en: "Cable trace", icon: "⌁" },
|
||||
assets: { name: "人员设备", en: "People & devices", icon: "♙" },
|
||||
alerts: { name: "告警中心", en: "Alerts", icon: "!" },
|
||||
settings: { name: "系统设置", en: "Settings", icon: "⌘" },
|
||||
simulator: { name: "终端模拟", en: "Terminal simulator", icon: "▣" },
|
||||
};
|
||||
|
||||
const roleMeta: Record<RoleKey, { name: string; person: string; pages: PageKey[] }> = {
|
||||
warehouse: {
|
||||
name: "仓管员",
|
||||
person: "张三",
|
||||
pages: ["dashboard", "weighings", "events", "reconcile", "assets", "simulator"],
|
||||
},
|
||||
worker: {
|
||||
name: "产线工人",
|
||||
person: "李四",
|
||||
pages: ["dashboard", "trace", "assets", "simulator"],
|
||||
},
|
||||
admin: {
|
||||
name: "管理员",
|
||||
person: "王工",
|
||||
pages: Object.keys(pageMeta) as PageKey[],
|
||||
},
|
||||
};
|
||||
|
||||
const navGroups: Array<{ title: string; pages: PageKey[] }> = [
|
||||
{ title: "总览", pages: ["dashboard"] },
|
||||
{ title: "废料库回收", pages: ["weighings", "events", "reconcile"] },
|
||||
{ title: "导体称重", pages: ["trace"] },
|
||||
{ title: "运营管理", pages: ["assets", "alerts"] },
|
||||
{ title: "系统", pages: ["settings"] },
|
||||
{ title: "演示工具", pages: ["simulator"] },
|
||||
];
|
||||
|
||||
const n = (row: Row, key: string) => Number(row[key] ?? 0);
|
||||
const s = (row: Row, key: string) => String(row[key] ?? "");
|
||||
|
||||
function formatTime(value: string | number | null, includeDate = false) {
|
||||
const date = new Date(Number(value ?? 0));
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
...(includeDate ? { month: "2-digit", day: "2-digit" } : {}),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function Status({ value }: { value: string }) {
|
||||
const level =
|
||||
/超标|严重|失败|离线|停用|损坏|异常/.test(value)
|
||||
? "bad"
|
||||
: /警告|预警|处理中|待推送|需更新|未用/.test(value)
|
||||
? "warn"
|
||||
: /在线|正常|成功|已同步|已确认|已录入|在用|已自动|已推送/.test(value)
|
||||
? "ok"
|
||||
: "muted";
|
||||
return <span className={`status ${level}`}>{value}</span>;
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
tools,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
tools?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`card ${className}`}>
|
||||
{(title || tools) && (
|
||||
<div className="card-head">
|
||||
<div>
|
||||
{title && <h3>{title}</h3>}
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
{tools && <div className="card-tools">{tools}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
}: {
|
||||
columns: Array<{ key: string; label: string; render?: (row: Row) => ReactNode }>;
|
||||
rows: Row[];
|
||||
}) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column.key}>{column.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="empty">没有符合条件的数据</td></tr>
|
||||
) : rows.map((row, index) => (
|
||||
<tr key={String(row.id ?? row.record_id ?? row.code ?? index)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{column.render ? column.render(row) : String(row[column.key] ?? "—")}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索…",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="search-box">
|
||||
<span>⌕</span>
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function exportCsv(filename: string, rows: Row[]) {
|
||||
if (!rows.length) return;
|
||||
const headers = Object.keys(rows[0]);
|
||||
const escape = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
|
||||
const csv = "\ufeff" + [headers.map(escape).join(","), ...rows.map((row) => headers.map((key) => escape(row[key])).join(","))].join("\n");
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function AdminConsole() {
|
||||
const [data, setData] = useState<ConsoleData | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState<PageKey>("dashboard");
|
||||
const [role, setRole] = useState<RoleKey>("admin");
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState("records");
|
||||
const [toast, setToast] = useState("");
|
||||
const [clock, setClock] = useState(new Date());
|
||||
const [modal, setModal] = useState<"workshop" | "tag" | "threshold" | null>(null);
|
||||
const [simStep, setSimStep] = useState(0);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/console", { cache: "no-store" });
|
||||
const payload = await response.json() as { code: number; message?: string; data?: ConsoleData };
|
||||
if (!response.ok || !payload.data) throw new Error(payload.message || "数据加载失败");
|
||||
setData(payload.data);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(() => setClock(new Date()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
if (page === "trace") setTab("trace");
|
||||
else if (page === "assets") setTab("people");
|
||||
else if (page === "settings") setTab("accounts");
|
||||
else if (page === "weighings") setTab("records");
|
||||
}, [page]);
|
||||
|
||||
async function action(name: string, payload: Record<string, unknown> = {}, message = "操作成功") {
|
||||
try {
|
||||
const response = await fetch("/api/console", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: name, payload }),
|
||||
});
|
||||
const result = await response.json() as { code: number; message?: string; data?: ConsoleData };
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "操作失败");
|
||||
if (result.data) setData(result.data);
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 2_800);
|
||||
} catch (cause) {
|
||||
setToast(cause instanceof Error ? cause.message : "操作失败");
|
||||
window.setTimeout(() => setToast(""), 3_600);
|
||||
}
|
||||
}
|
||||
|
||||
function switchRole(nextRole: RoleKey) {
|
||||
setRole(nextRole);
|
||||
if (!roleMeta[nextRole].pages.includes(page)) setPage("dashboard");
|
||||
setToast(`已切换为${roleMeta[nextRole].name}视角`);
|
||||
window.setTimeout(() => setToast(""), 2_200);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<main className="loading-screen">
|
||||
<div className="loading-mark">智</div>
|
||||
<h1>智能回收运营平台</h1>
|
||||
<p>正在连接本地数据服务…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<main className="loading-screen error-screen">
|
||||
<div className="loading-mark">!</div>
|
||||
<h1>数据服务暂不可用</h1>
|
||||
<p>{error}</p>
|
||||
<button className="primary-button" onClick={load}>重新连接</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const visiblePages = roleMeta[role].pages;
|
||||
const openAlerts = data.alerts.filter((item) => s(item, "status") !== "已关闭").length;
|
||||
const onlineDevices = data.devices.filter((item) => s(item, "status") === "在线").length;
|
||||
const title = pageMeta[page];
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-symbol">智</div>
|
||||
<div>
|
||||
<strong>智能回收</strong>
|
||||
<small>OPERATIONS HUB</small>
|
||||
</div>
|
||||
<span className="version">V1.0</span>
|
||||
</div>
|
||||
<nav>
|
||||
{navGroups.map((group) => {
|
||||
const pages = group.pages.filter((item) => visiblePages.includes(item));
|
||||
if (!pages.length) return null;
|
||||
return (
|
||||
<div className="nav-group" key={group.title}>
|
||||
<p>{group.title}</p>
|
||||
{pages.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={page === item ? "active" : ""}
|
||||
onClick={() => setPage(item)}
|
||||
>
|
||||
<span className="nav-icon">{pageMeta[item].icon}</span>
|
||||
{pageMeta[item].name}
|
||||
{item === "alerts" && openAlerts > 0 && <b>{openAlerts}</b>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<div><i className="online-dot" />设备在线 <strong>{onlineDevices}/{data.devices.length}</strong></div>
|
||||
<div><i className="online-dot" />本地数据库正常</div>
|
||||
<small>内网服务 · 数据不出厂区</small>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="page-title">
|
||||
<h1>{title.name}</h1>
|
||||
<span>{title.en}</span>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="connection"><i className="online-dot" /> 服务正常</div>
|
||||
<div className="clock">
|
||||
<strong>{clock.toLocaleTimeString("zh-CN", { hour12: false })}</strong>
|
||||
<span>{clock.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" })}</span>
|
||||
</div>
|
||||
<button className="alert-button" onClick={() => setPage("alerts")}>
|
||||
! {openAlerts > 0 && <b>{openAlerts}</b>}
|
||||
</button>
|
||||
<div className="role-switch">
|
||||
{(Object.keys(roleMeta) as RoleKey[]).map((key) => (
|
||||
<button key={key} className={role === key ? "active" : ""} onClick={() => switchRole(key)}>
|
||||
{roleMeta[key].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{page === "dashboard" && <Dashboard data={data} role={role} setPage={setPage} />}
|
||||
{page === "weighings" && (
|
||||
<Weighings data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} />
|
||||
)}
|
||||
{page === "events" && <EventsPage data={data} query={query} setQuery={setQuery} />}
|
||||
{page === "reconcile" && <Reconcile data={data} />}
|
||||
{page === "trace" && (
|
||||
<TracePage data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} action={action} openTag={() => setModal("tag")} />
|
||||
)}
|
||||
{page === "assets" && (
|
||||
<Assets data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} role={role} action={action} />
|
||||
)}
|
||||
{page === "alerts" && <AlertsPage data={data} role={role} action={action} openThreshold={() => setModal("threshold")} />}
|
||||
{page === "settings" && (
|
||||
<SettingsPage data={data} tab={tab} setTab={setTab} action={action} openWorkshop={() => setModal("workshop")} />
|
||||
)}
|
||||
{page === "simulator" && <Simulator step={simStep} setStep={setSimStep} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<Modal
|
||||
type={modal}
|
||||
settings={data.settings}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={async (values) => {
|
||||
if (modal === "workshop") await action("workshop.add", values, "车间已新增");
|
||||
if (modal === "tag") await action("tag.add", values, "RFID 标签已新增");
|
||||
if (modal === "threshold") await action("settings.save", values, "告警阈值已保存");
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ data, role, setPage }: { data: ConsoleData; role: RoleKey; setPage: (page: PageKey) => void }) {
|
||||
const todayTotal = data.weighings.reduce((total, row) => total + n(row, "weight"), 0);
|
||||
const netTotal = data.traces.reduce((total, row) => total + n(row, "net"), 0);
|
||||
const pending = data.reconciliations.filter((row) => s(row, "verdict") !== "正常").length;
|
||||
const deviceOnline = data.devices.filter((row) => s(row, "status") === "在线").length;
|
||||
const showWaste = role !== "worker";
|
||||
const kpis = showWaste
|
||||
? [
|
||||
["今日废料投放", `${todayTotal.toFixed(1)} kg`, "按已接收记录实时汇总", "orange"],
|
||||
["称重次数", `${data.weighings.length} 次`, "原子称重记录", "blue"],
|
||||
["待核对", `${pending} 条`, "警告与超标记录", "amber"],
|
||||
["设备在线率", `${deviceOnline}/${data.devices.length}`, "最近心跳状态", "green"],
|
||||
]
|
||||
: [
|
||||
["今日称重轴数", `${data.traces.length} 轴`, "导体追溯记录", "blue"],
|
||||
["净重累计", `${netTotal.toFixed(1)} kg`, "毛重减皮重", "green"],
|
||||
["MES 待推送", `${data.traces.filter((row) => s(row, "mes_status") !== "已推送").length} 条`, "自动重试队列", "amber"],
|
||||
["RFID 标签", `${data.tags.length} 个`, "在用与备用标签", "orange"],
|
||||
];
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="greeting">
|
||||
<div>
|
||||
<span>你好,{roleMeta[role].person}</span>
|
||||
<h2>{roleMeta[role].name}视角 · 今日运营概览</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => setPage(showWaste ? "weighings" : "trace")}>查看明细 →</button>
|
||||
</div>
|
||||
<div className="kpi-grid">
|
||||
{kpis.map(([label, value, note, color]) => (
|
||||
<div className={`kpi ${color}`} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{note}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="two-columns wide-left">
|
||||
<Card title={showWaste ? "实时称重 · SB-01" : "最近导体称重任务"} subtitle={showWaste ? "数据稳定后自动生成称重记录" : "RFID 与生产批次全程关联"}>
|
||||
{showWaste ? (
|
||||
<div className="live-scale">
|
||||
<div>
|
||||
<strong>12.48</strong><span>kg</span>
|
||||
<p><i className="online-dot" /> 采样稳定 · 精度 ±10g</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>操作人</dt><dd>张三</dd></div>
|
||||
<div><dt>机台</dt><dd>MC-101</dd></div>
|
||||
<div><dt>类别</dt><dd>废铜</dd></div>
|
||||
<div><dt>来源</dt><dd>小秤台</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data.traces.slice(0, 4)}
|
||||
columns={[
|
||||
{ key: "task_id", label: "任务号" },
|
||||
{ key: "rfid", label: "RFID" },
|
||||
{ key: "batch", label: "批次" },
|
||||
{ key: "net", label: "净重", render: (row) => `${n(row, "net").toFixed(1)} kg` },
|
||||
{ key: "mes_status", label: "MES", render: (row) => <Status value={s(row, "mes_status")} /> },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Card title="设备状态" subtitle={`${deviceOnline} 台在线`}>
|
||||
<div className="device-list">
|
||||
{data.devices.slice(0, 6).map((device) => (
|
||||
<div key={s(device, "id")}>
|
||||
<i className={s(device, "status") === "在线" ? "online-dot" : "offline-dot"} />
|
||||
<span><strong>{s(device, "id")}</strong>{s(device, "type")}</span>
|
||||
<small>{formatTime(device.last_seen)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="two-columns">
|
||||
<Card title="近 7 日投放趋势" subtitle="公斤">
|
||||
<div className="bar-chart">
|
||||
{[54, 72, 61, 86, 66, 78, 93].map((height, index) => (
|
||||
<div key={index}><i style={{ height: `${height}%` }} /><span>{["一", "二", "三", "四", "五", "六", "日"][index]}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="最近操作流水" subtitle="自动刷新">
|
||||
<div className="activity-list">
|
||||
{data.weighings.slice(0, 5).map((row) => (
|
||||
<div key={s(row, "id")}>
|
||||
<time>{formatTime(row.created_at)}</time>
|
||||
<span>{s(row, "operator")} · {s(row, "machine")} · {s(row, "category")}</span>
|
||||
<strong>{n(row, "weight").toFixed(2)} kg</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Weighings({ data, query, setQuery, tab, setTab }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void }) {
|
||||
const rows = useMemo(() => {
|
||||
const key = query.toLowerCase();
|
||||
return data.weighings.filter((row) => !key || [row.operator, row.machine, row.category, row.event_id].some((value) => String(value ?? "").toLowerCase().includes(key)));
|
||||
}, [data.weighings, query]);
|
||||
const categoryTotals = ["废铜", "废电线", "废铝", "废锡"].map((category) => ({
|
||||
category,
|
||||
value: rows.filter((row) => s(row, "category") === category).reduce((total, row) => total + n(row, "weight"), 0),
|
||||
}));
|
||||
return (
|
||||
<Card
|
||||
title="废料称重数据"
|
||||
subtitle="原子称重记录与统计分析"
|
||||
tools={<button className="ghost-button" onClick={() => exportCsv("废料称重记录.csv", rows)}>导出 CSV</button>}
|
||||
>
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "records" ? "active" : ""} onClick={() => setTab("records")}>记录列表</button>
|
||||
<button className={tab === "statistics" ? "active" : ""} onClick={() => setTab("statistics")}>统计视图</button>
|
||||
</div>
|
||||
<SearchBox value={query} onChange={setQuery} placeholder="搜索操作人、机台或事件…" />
|
||||
</div>
|
||||
{tab === "records" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "operator", label: "操作人" },
|
||||
{ key: "machine", label: "关联机台" },
|
||||
{ key: "category", label: "废料类别" },
|
||||
{ key: "source", label: "来源设备" },
|
||||
{ key: "weight", label: "精确重量", render: (row) => <strong>{n(row, "weight").toFixed(2)} kg</strong> },
|
||||
{ key: "event_id", label: "所属事件" },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="statistics-grid">
|
||||
{categoryTotals.map((item) => (
|
||||
<div key={item.category}>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.value.toFixed(1)} kg</strong>
|
||||
<i style={{ width: `${Math.min(100, item.value * 4)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsPage({ data, query, setQuery }: { data: ConsoleData; query: string; setQuery: (value: string) => void }) {
|
||||
const rows = data.events.filter((row) => !query || `${s(row, "id")}${s(row, "operator")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="废料投放事件" subtitle="按进门到离门聚合原子称重记录" tools={<button className="ghost-button" onClick={() => exportCsv("事件列表.csv", rows)}>导出 CSV</button>}>
|
||||
<div className="toolbar"><SearchBox value={query} onChange={setQuery} placeholder="搜索事件 ID 或操作人…" /></div>
|
||||
<DataTable rows={rows} columns={[
|
||||
{ key: "id", label: "事件 ID" },
|
||||
{ key: "operator", label: "操作人" },
|
||||
{ key: "entered_at", label: "进门时间", render: (row) => formatTime(row.entered_at, true) },
|
||||
{ key: "left_at", label: "离门时间", render: (row) => formatTime(row.left_at) },
|
||||
{ key: "duration", label: "历时", render: (row) => `${Math.max(1, Math.round((n(row, "left_at") - n(row, "entered_at")) / 60_000))} 分钟` },
|
||||
{ key: "atomic_count", label: "原子笔数" },
|
||||
{ key: "scale_total", label: "台秤总重", render: (row) => `${n(row, "scale_total").toFixed(2)} kg` },
|
||||
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Reconcile({ data }: { data: ConsoleData }) {
|
||||
return (
|
||||
<Card title="双秤数据核对" subtitle="正常 ≤2% · 警告 2–5% · 超标 >5%" tools={<button className="ghost-button" onClick={() => exportCsv("地磅核对.csv", data.reconciliations)}>导出 CSV</button>}>
|
||||
<DataTable rows={data.reconciliations} columns={[
|
||||
{ key: "event_id", label: "事件 ID" },
|
||||
{ key: "category", label: "废料类别" },
|
||||
{ key: "small_scale", label: "台秤汇总", render: (row) => `${n(row, "small_scale").toFixed(2)} kg` },
|
||||
{ key: "floor_scale", label: "地磅增量", render: (row) => `${n(row, "floor_scale").toFixed(2)} kg` },
|
||||
{ key: "difference", label: "偏差值", render: (row) => `${n(row, "difference").toFixed(2)} kg` },
|
||||
{ key: "difference_rate", label: "偏差率", render: (row) => `${n(row, "difference_rate").toFixed(2)}%` },
|
||||
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
|
||||
{ key: "storage_status", label: "入库状态", render: (row) => <Status value={s(row, "storage_status")} /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TracePage({ data, query, setQuery, tab, setTab, action, openTag }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openTag: () => void }) {
|
||||
const rows = data.traces.filter((row) => !query || `${s(row, "task_id")}${s(row, "rfid")}${s(row, "batch")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="导体称重追溯" subtitle="RFID、生产批次与两次称重完整关联">
|
||||
<div className="toolbar">
|
||||
<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" ? <SearchBox value={query} onChange={setQuery} placeholder="搜索任务、RFID 或批次…" /> : <button className="primary-button" onClick={openTag}>添加标签</button>}
|
||||
</div>
|
||||
{tab === "trace" ? <DataTable rows={rows} columns={[
|
||||
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "workshop", label: "所属车间" },
|
||||
{ key: "task_id", label: "任务号" },
|
||||
{ key: "rfid", label: "RFID 标签" },
|
||||
{ key: "tare", label: "皮重", render: (row) => `${n(row, "tare").toFixed(2)} kg` },
|
||||
{ key: "gross", label: "毛重", render: (row) => `${n(row, "gross").toFixed(2)} kg` },
|
||||
{ key: "net", label: "净重", render: (row) => <strong>{n(row, "net").toFixed(2)} kg</strong> },
|
||||
{ key: "machine", label: "机台" },
|
||||
{ key: "batch", label: "批次号" },
|
||||
{ key: "mes_status", label: "MES 推送", render: (row) => <Status value={s(row, "mes_status")} /> },
|
||||
]} /> : <DataTable rows={data.tags} columns={[
|
||||
{ key: "id", label: "标签 ID" },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "last_checked", label: "最后检测", render: (row) => row.last_checked ? formatTime(row.last_checked, true) : "—" },
|
||||
{ key: "actions", label: "操作", render: (row) => (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("tag.status", { id: row.id, status: "在用" }, "标签已写入")}>写入</button>
|
||||
<button onClick={() => action("tag.status", { id: row.id, status: "损坏" }, "标签已标记损坏")}>报损</button>
|
||||
</div>
|
||||
) },
|
||||
]} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Assets({ data, query, setQuery, tab, setTab, role, action }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
|
||||
const people = data.people.filter((row) => !query || `${s(row, "name")}${s(row, "id")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="人员与设备" subtitle={role === "admin" ? "人员主数据由服务端统一维护并向设备下发" : "当前为只读视图"}>
|
||||
<div className="toolbar">
|
||||
<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" && <SearchBox value={query} onChange={setQuery} placeholder="搜索姓名或员工 ID…" />}
|
||||
{tab === "people" && role === "admin" && <button className="primary-button" onClick={() => action("mes.sync", {}, "MES 人员同步完成")}>同步 MES</button>}
|
||||
</div>
|
||||
{tab === "people" && <DataTable rows={people} columns={[
|
||||
{ key: "name", label: "姓名" },
|
||||
{ key: "id", label: "员工 ID" },
|
||||
{ key: "workshop", label: "所属车间", render: (row) => role === "admin" ? (
|
||||
<select value={s(row, "workshop")} onChange={(event) => action("person.workshop", { id: row.id, workshop: event.target.value }, "所属车间已更新")}>
|
||||
{data.workshops.map((workshop) => <option key={s(workshop, "code")}>{s(workshop, "name")}</option>)}
|
||||
</select>
|
||||
) : s(row, "workshop") },
|
||||
{ key: "role", label: "角色" },
|
||||
{ key: "face_status", label: "人脸状态", render: (row) => <Status value={s(row, "face_status")} /> },
|
||||
{ key: "sync_status", label: "同步状态", render: (row) => <Status value={s(row, "sync_status")} /> },
|
||||
{ key: "active", label: "账号状态", render: (row) => <Status value={n(row, "active") ? "启用" : "停用"} /> },
|
||||
{ key: "actions", label: "操作", render: (row) => role === "admin" ? (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("person.face", { id: row.id }, "人脸版本已更新")}>更新人脸</button>
|
||||
<button onClick={() => action("person.toggle", { id: row.id }, "人员状态已切换")}>{n(row, "active") ? "停用" : "启用"}</button>
|
||||
</div>
|
||||
) : "只读" },
|
||||
]} />}
|
||||
{tab === "machines" && <DataTable rows={[
|
||||
{ id: "MC-101", name: "1# 切割机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
|
||||
{ id: "MC-105", name: "2# 剥线机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
|
||||
{ id: "MC-201", name: "8# 绞线机", type: "导体机台", workshop: "二楼导体车间", status: "维护中", mes: "正常" },
|
||||
]} columns={[
|
||||
{ key: "id", label: "机台编号" }, { key: "name", label: "机台名称" }, { key: "type", label: "类型" },
|
||||
{ key: "workshop", label: "所属车间" }, { key: "status", label: "运行状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
{ key: "mes", label: "MES 同步", render: (row) => <Status value={s(row, "mes")} /> },
|
||||
]} />}
|
||||
{tab === "devices" && (
|
||||
<div className="device-grid">
|
||||
{data.devices.map((device) => (
|
||||
<article key={s(device, "id")}>
|
||||
<div><strong>{s(device, "id")}</strong><Status value={s(device, "status")} /></div>
|
||||
<h4>{s(device, "type")}</h4>
|
||||
<p>{s(device, "ip")} · 心跳 {s(device, "heartbeat_seconds")}s</p>
|
||||
{n(device, "fill_percent") > 0 && (
|
||||
<>
|
||||
<div className="meter"><i style={{ width: `${n(device, "fill_percent")}%` }} /></div>
|
||||
<small>满溢率 {n(device, "fill_percent")}% · {s(device, "category")}</small>
|
||||
</>
|
||||
)}
|
||||
<footer>最后心跳 {formatTime(device.last_seen)}</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage({ data, role, action, openThreshold }: { data: ConsoleData; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openThreshold: () => void }) {
|
||||
return (
|
||||
<Card title="设备与业务告警" subtitle="满溢、设备离线、超时与称重偏差统一管理" tools={role === "admin" && <button className="ghost-button" onClick={openThreshold}>阈值设置</button>}>
|
||||
<div className="alert-list">
|
||||
{data.alerts.map((alert) => (
|
||||
<article key={s(alert, "id")} className={s(alert, "severity") === "严重" ? "critical" : ""}>
|
||||
<div className="alert-sign">!</div>
|
||||
<div>
|
||||
<div className="alert-title"><h3>{s(alert, "type")}</h3><Status value={s(alert, "status")} /></div>
|
||||
<p>{s(alert, "description")}</p>
|
||||
<small>{formatTime(alert.created_at, true)} · {s(alert, "source")} · {s(alert, "reference")}</small>
|
||||
</div>
|
||||
{role === "admin" && s(alert, "status") !== "已关闭" && (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("alert.status", { id: alert.id, status: "处理中" }, "告警已接单")}>接单</button>
|
||||
<button onClick={() => action("alert.status", { id: alert.id, status: "已关闭" }, "告警已关闭")}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage({ data, tab, setTab, action, openWorkshop }: { data: ConsoleData; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openWorkshop: () => void }) {
|
||||
const tabs = [["accounts", "账号与权限"], ["workshops", "车间字典"], ["monitor", "设备监控"], ["mes", "MES 接口"], ["logs", "操作日志"]];
|
||||
return (
|
||||
<Card title="系统配置" subtitle="管理员专属">
|
||||
<div className="toolbar"><div className="tabs">{tabs.map(([key, label]) => <button key={key} className={tab === key ? "active" : ""} onClick={() => setTab(key)}>{label}</button>)}</div></div>
|
||||
{tab === "accounts" && <DataTable rows={[
|
||||
{ name: "王工", id: "A0001", department: "设备部", role: "管理员", permission: "全部功能与系统配置" },
|
||||
{ name: "张三", id: "P1001", department: "仓储部", role: "仓管员", permission: "废料库、人员设备只读" },
|
||||
{ name: "李四", id: "P1002", department: "生产部", role: "产线工人", permission: "导体追溯、人员设备只读" },
|
||||
]} columns={[
|
||||
{ key: "name", label: "姓名" }, { key: "id", label: "工号" }, { key: "department", label: "部门" },
|
||||
{ key: "role", label: "系统角色" }, { key: "permission", label: "权限说明" },
|
||||
]} />}
|
||||
{tab === "workshops" && (
|
||||
<>
|
||||
<div className="sub-toolbar"><span>车间主数据字典</span><button className="primary-button" onClick={openWorkshop}>新增车间</button></div>
|
||||
<DataTable rows={data.workshops} columns={[
|
||||
{ key: "code", label: "车间编码" }, { key: "name", label: "车间名称" }, { key: "location", label: "位置" }, { key: "note", label: "备注" },
|
||||
{ key: "people", label: "关联人员", render: (row) => `${data.people.filter((person) => s(person, "workshop") === s(row, "name")).length} 人` },
|
||||
{ key: "actions", label: "操作", render: (row) => <div className="row-actions"><button onClick={() => action("workshop.delete", { code: row.code }, "车间已删除")}>删除</button></div> },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
{tab === "monitor" && <DataTable rows={data.devices} columns={[
|
||||
{ key: "id", label: "设备编号" }, { key: "type", label: "设备类型" }, { key: "ip", label: "IP 地址" },
|
||||
{ key: "heartbeat_seconds", label: "心跳周期", render: (row) => `${s(row, "heartbeat_seconds")} 秒` },
|
||||
{ key: "last_seen", label: "最后心跳", render: (row) => formatTime(row.last_seen, true) },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
]} />}
|
||||
{tab === "mes" && <MesSettings settings={data.settings} action={action} />}
|
||||
{tab === "logs" && <DataTable rows={data.logs} columns={[
|
||||
{ key: "created_at", label: "时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "actor", label: "操作人" }, { key: "action", label: "动作" }, { key: "details", label: "详情" },
|
||||
{ key: "level", label: "级别", render: (row) => <Status value={s(row, "level")} /> },
|
||||
]} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MesSettings({ settings, action }: { settings: Record<string, string>; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
|
||||
const [url, setUrl] = useState(settings.mes_url ?? "");
|
||||
const [timeout, setTimeoutValue] = useState(settings.mes_timeout ?? "10");
|
||||
const [testing, setTesting] = useState(false);
|
||||
return (
|
||||
<div className="settings-form">
|
||||
<label><span>接口地址</span><input value={url} onChange={(event) => setUrl(event.target.value)} /></label>
|
||||
<label><span>推送超时</span><input type="number" value={timeout} onChange={(event) => setTimeoutValue(event.target.value)} /><small>秒</small></label>
|
||||
<label><span>重试策略</span><select defaultValue="3"><option value="3">自动重试 3 次 · 间隔 5 秒</option><option value="0">仅人工重试</option></select></label>
|
||||
<div className="form-actions">
|
||||
<button className="ghost-button" onClick={() => { setTesting(true); window.setTimeout(() => setTesting(false), 900); }}>{testing ? "连接中…" : "测试连接"}</button>
|
||||
<button className="primary-button" onClick={() => action("settings.save", { mes_url: url, mes_timeout: timeout }, "MES 配置已保存")}>保存配置</button>
|
||||
{!testing && <Status value="连接正常" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Simulator({ step, setStep }: { step: number; setStep: (value: number) => void }) {
|
||||
const wasteSteps = [
|
||||
["身份验证", "请将面部对准摄像头,也可输入工号", "模拟识别成功 · 张三"],
|
||||
["门锁已开启", "请进入投放,最长停留 05:00", "模拟投放完成并关门"],
|
||||
["投放完成", "台秤 38.50kg · 地磅 39.00kg · 偏差 1.3%", "重新开始"],
|
||||
];
|
||||
const cableSteps = [
|
||||
["导体轴称重", "RFID 全程关联生产任务与批次", "开始一次称重"],
|
||||
["一次称重完成", "RFID E280-6102-0345 · 皮重 152.30kg", "模拟二次称重"],
|
||||
["MES 上报成功", "净重 115.80kg · 条码已打印", "重新开始"],
|
||||
];
|
||||
return (
|
||||
<div className="simulator-grid">
|
||||
{[["废料库入库终端", wasteSteps, "orange"], ["导体称重终端", cableSteps, "blue"]].map(([name, steps, color], index) => {
|
||||
const items = steps as string[][];
|
||||
const current = index === 0 ? step % 3 : Math.floor(step / 3) % 3;
|
||||
return (
|
||||
<Card title={String(name)} subtitle="点击屏幕推进业务流程" className="sim-card" key={String(name)}>
|
||||
<button className={`terminal ${color}`} onClick={() => setStep(index === 0 ? (current + 1) % 3 : ((current + 1) % 3) * 3)}>
|
||||
<span className="terminal-camera" />
|
||||
<small>LOCAL TERMINAL · ONLINE</small>
|
||||
<div className="terminal-icon">{current === 0 ? "◉" : current === 1 ? "⇄" : "✓"}</div>
|
||||
<h3>{items[current][0]}</h3>
|
||||
<p>{items[current][1]}</p>
|
||||
<strong>{items[current][2]} →</strong>
|
||||
<div className="step-dots">{items.map((_, itemIndex) => <i className={itemIndex === current ? "active" : ""} key={itemIndex} />)}</div>
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ type, settings, onClose, onSubmit }: { type: "workshop" | "tag" | "threshold"; settings: Record<string, string>; onClose: () => void; onSubmit: (values: Record<string, string>) => Promise<void> }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
const values = Object.fromEntries(new FormData(event.currentTarget).entries()) as Record<string, string>;
|
||||
await onSubmit(values);
|
||||
setSaving(false);
|
||||
}
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
|
||||
<form className="modal" onSubmit={submit}>
|
||||
<header><h2>{type === "workshop" ? "新增车间" : type === "tag" ? "添加 RFID 标签" : "告警阈值设置"}</h2><button type="button" onClick={onClose}>×</button></header>
|
||||
<div className="modal-body">
|
||||
{type === "workshop" && <>
|
||||
<label><span>车间名称</span><input name="name" required placeholder="例如:三楼精密车间" /></label>
|
||||
<label><span>位置</span><input name="location" placeholder="例如:3F · 北侧" /></label>
|
||||
<label><span>备注</span><input name="note" placeholder="选填" /></label>
|
||||
</>}
|
||||
{type === "tag" && <label><span>标签名称</span><input name="name" required placeholder="例如:8# 导体轴标签" /></label>}
|
||||
{type === "threshold" && <>
|
||||
<label><span>偏差警告阈值 (%)</span><input name="deviation_warn" type="number" defaultValue={settings.deviation_warn} /></label>
|
||||
<label><span>偏差严重阈值 (%)</span><input name="deviation_critical" type="number" defaultValue={settings.deviation_critical} /></label>
|
||||
<label><span>满溢预警阈值 (%)</span><input name="fill_warn" type="number" defaultValue={settings.fill_warn} /></label>
|
||||
<label><span>满溢严重阈值 (%)</span><input name="fill_critical" type="number" defaultValue={settings.fill_critical} /></label>
|
||||
</>}
|
||||
</div>
|
||||
<footer><button type="button" className="ghost-button" onClick={onClose}>取消</button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "确认保存"}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user