Files
GarbageCanCenter_Woer_face/management-console/app/AdminConsole.tsx
T

821 lines
40 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 { 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% · 警告 25% · 超标 >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>
);
}