feat: 初始化星元智灵人脸识别回收箱项目

This commit is contained in:
FinleyHsu
2026-07-28 10:48:03 +08:00
commit 32a31f0909
112 changed files with 28469 additions and 0 deletions
+820
View File
@@ -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% · 警告 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>
);
}
@@ -0,0 +1,93 @@
import { addAudit, ensureDatabase, loadConsoleData } from "@/db/store";
export const dynamic = "force-dynamic";
export async function GET() {
try {
return Response.json({ code: 0, data: await loadConsoleData() });
} catch (error) {
return Response.json(
{ code: 5001, message: error instanceof Error ? error.message : "数据库不可用" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const db = await ensureDatabase();
const body = (await request.json()) as {
action?: string;
payload?: Record<string, unknown>;
};
const payload = body.payload ?? {};
const action = body.action ?? "";
if (action === "person.toggle") {
const id = String(payload.id ?? "");
await db
.prepare("UPDATE people SET active=CASE active WHEN 1 THEN 0 ELSE 1 END, updated_at=? WHERE id=?")
.bind(Date.now(), id)
.run();
await addAudit("管理员", "人员状态变更", `${id} 启用状态已切换`, "重要");
} else if (action === "person.workshop") {
const id = String(payload.id ?? "");
const workshop = String(payload.workshop ?? "");
await db.prepare("UPDATE people SET workshop=?, updated_at=? WHERE id=?").bind(workshop, Date.now(), id).run();
await addAudit("管理员", "修改人员车间", `${id} 调整到 ${workshop}`);
} else if (action === "person.face") {
const id = String(payload.id ?? "");
await db.prepare("UPDATE people SET face_status='已录入', face_version=face_version+1, sync_status='已同步', updated_at=? WHERE id=?").bind(Date.now(), id).run();
await addAudit("管理员", "更新人脸", `${id} 人脸版本已更新`, "重要");
} else if (action === "workshop.add") {
const name = String(payload.name ?? "").trim();
const location = String(payload.location ?? "—").trim();
const note = String(payload.note ?? "—").trim();
if (!name) return Response.json({ code: 1001, message: "车间名称不能为空" }, { status: 400 });
const row = await db.prepare("SELECT COUNT(*) AS total FROM workshops").first<{ total: number }>();
const code = `WS-${String(Number(row?.total ?? 0) + 1).padStart(2, "0")}`;
await db.prepare("INSERT INTO workshops(code,name,location,note) VALUES(?,?,?,?)").bind(code, name, location || "—", note || "—").run();
await addAudit("管理员", "新增车间", `${code} ${name}`, "重要");
} else if (action === "workshop.delete") {
const code = String(payload.code ?? "");
const workshop = await db.prepare("SELECT name FROM workshops WHERE code=?").bind(code).first<{ name: string }>();
if (workshop) {
const used = await db.prepare("SELECT COUNT(*) AS total FROM people WHERE workshop=?").bind(workshop.name).first<{ total: number }>();
if (Number(used?.total ?? 0) > 0) {
return Response.json({ code: 4001, message: "该车间仍有关联人员,不能删除" }, { status: 409 });
}
await db.prepare("DELETE FROM workshops WHERE code=?").bind(code).run();
await addAudit("管理员", "删除车间", `${code} ${workshop.name}`, "重要");
}
} else if (action === "tag.add") {
const name = String(payload.name ?? "未命名标签").trim() || "未命名标签";
const id = `E280-6102-${String(Math.floor(Math.random() * 9000) + 1000)}`;
await db.prepare("INSERT INTO rfid_tags(id,status,name,last_checked) VALUES(?,'未用',?,NULL)").bind(id, name).run();
await addAudit("管理员", "新增 RFID", `${id} ${name}`);
} else if (action === "tag.status") {
const id = String(payload.id ?? "");
const status = String(payload.status ?? "未用");
await db.prepare("UPDATE rfid_tags SET status=?, last_checked=? WHERE id=?").bind(status, Date.now(), id).run();
await addAudit("管理员", "RFID 状态变更", `${id}${status}`);
} else if (action === "alert.status") {
const id = String(payload.id ?? "");
const status = String(payload.status ?? "处理中");
await db.prepare("UPDATE alerts SET status=? WHERE id=?").bind(status, id).run();
await addAudit("管理员", "处理告警", `${id}${status}`, "重要");
} else if (action === "settings.save") {
const entries = Object.entries(payload);
for (const [key, value] of entries) {
await db
.prepare("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at")
.bind(key, String(value), Date.now())
.run();
}
await addAudit("管理员", "更新系统设置", entries.map(([key]) => key).join("、"), "重要");
} else if (action === "mes.sync") {
await db.prepare("UPDATE people SET sync_status='已同步', updated_at=? WHERE sync_status<>'已同步'").bind(Date.now()).run();
await addAudit("管理员", "同步 MES 人员", "增量同步完成");
} else {
return Response.json({ code: 1001, message: "未知操作" }, { status: 400 });
}
return Response.json({ code: 0, message: "操作成功", data: await loadConsoleData() });
}
@@ -0,0 +1,121 @@
import { addAudit, ensureDatabase } from "@/db/store";
export const dynamic = "force-dynamic";
type RouteContext = { params: Promise<{ path: string[] }> };
function ok(data: unknown, requestId = crypto.randomUUID()) {
return Response.json({
code: 0,
message: "success",
requestId,
serverTime: Date.now(),
data,
});
}
function fail(code: number, message: string, status = 400) {
return Response.json(
{ code, message, requestId: crypto.randomUUID(), serverTime: Date.now(), data: null },
{ status },
);
}
export async function GET(request: Request, context: RouteContext) {
const db = await ensureDatabase();
const path = (await context.params).path;
const url = new URL(request.url);
if (path.length === 4 && path[0] === "devices" && path[2] === "people") {
const cursor = Number(url.searchParams.get("cursor") ?? 0);
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200), 500);
const rows = await db
.prepare("SELECT id AS personId,name,workshop,role,active,face_status AS faceStatus,face_version AS faceVersion,updated_at AS updatedAt FROM people WHERE updated_at>? ORDER BY updated_at LIMIT ?")
.bind(cursor, limit)
.all();
const nextCursor = rows.results.reduce((value, row) => Math.max(value, Number(row.updatedAt ?? 0)), cursor);
return ok({ items: rows.results, nextCursor, hasMore: rows.results.length === limit });
}
if (path.length === 4 && path[0] === "devices" && path[2] === "faces") {
const person = await db
.prepare("SELECT id AS personId,name,face_status AS faceStatus,face_version AS faceVersion,updated_at AS updatedAt FROM people WHERE id=? AND active=1")
.bind(path[3])
.first();
if (!person) return fail(4002, "PERSON_DELETED", 404);
return ok({ ...person, featureEncoding: "server-managed", feature: null });
}
if (path.length === 2 && path[0] === "deliveries") {
const row = await db.prepare("SELECT * FROM deliveries WHERE record_id=?").bind(path[1]).first();
if (!row) return fail(3004, "DELIVERY_NOT_FOUND", 404);
return ok(row);
}
return fail(1001, "接口不存在", 404);
}
export async function POST(request: Request, context: RouteContext) {
const db = await ensureDatabase();
const path = (await context.params).path;
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
if (path.join("/") === "auth/tokens") {
const deviceId = String(body.deviceId ?? body.deviceNo ?? "");
if (!deviceId) return fail(1001, "deviceId 必填");
return ok({ accessToken: `local-${deviceId}-${Date.now()}`, tokenType: "Bearer", expiresIn: 28_800 });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "heartbeat") {
const deviceId = path[1];
const ip = String(body.ip ?? "0.0.0.0");
await db
.prepare("INSERT INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,'在线',?,30,0,'终端') ON CONFLICT(id) DO UPDATE SET status='在线',last_seen=excluded.last_seen,ip=excluded.ip")
.bind(deviceId, String(body.deviceType ?? "回收终端"), ip, Date.now())
.run();
return ok({ accepted: true, nextHeartbeatIn: 30 });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "status") {
const deviceId = path[1];
const fillPercent = Number(body.fillPercent ?? body.fullRate ?? 0);
await db.prepare("UPDATE devices SET status=?,last_seen=?,fill_percent=? WHERE id=?").bind(String(body.status ?? "在线"), Date.now(), fillPercent, deviceId).run();
return ok({ accepted: true });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "alerts") {
const deviceId = path[1];
const alerts = Array.isArray(body.alerts) ? body.alerts : [body];
for (const item of alerts as Array<Record<string, unknown>>) {
await db
.prepare("INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,'待处理')")
.bind(String(item.alarmId ?? crypto.randomUUID()), Number(item.reportedAt ?? Date.now()), String(item.type ?? "设备告警"), String(item.severity ?? "警告"), "设备上报", deviceId, String(item.message ?? "设备异常"))
.run();
}
return ok({ accepted: alerts.length });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "deliveries") {
const deviceId = path[1];
const recordId = String(body.recordId ?? "");
if (!recordId) return fail(1001, "recordId 必填");
const weight = Number(body.weight ?? body.afterWeight ?? 0);
await db
.prepare("INSERT OR IGNORE INTO deliveries(record_id,device_id,created_at,employee_name,employee_no,machine_no,production_batch,garbage_type,weight,payload,status) VALUES(?,?,?,?,?,?,?,?,?,?,?)")
.bind(recordId, deviceId, Number(body.createdAt ?? Date.now()), String(body.employeeName ?? body.person ?? ""), String(body.employeeNo ?? ""), String(body.machineNo ?? ""), String(body.productionBatch ?? ""), Number(body.garbageType ?? body.typeCode ?? 0), weight, JSON.stringify(body), "已接收")
.run();
await db
.prepare("INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)")
.bind(recordId, Number(body.createdAt ?? Date.now()), String(body.employeeName ?? body.person ?? "未知"), String(body.machineNo ?? "—"), `类型 ${String(body.garbageType ?? body.typeCode ?? 0)}`, deviceId, weight, String(body.eventId ?? recordId), "已确认")
.run();
await addAudit(deviceId, "投放数据上报", `${recordId} · ${weight}kg`);
return ok({ accepted: true, duplicate: false });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "sync-acks") {
await addAudit(path[1], "同步结果上报", JSON.stringify(body).slice(0, 500));
return ok({ accepted: true });
}
return fail(1001, "接口不存在", 404);
}
+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;
}
}
+298
View File
@@ -0,0 +1,298 @@
@import "tailwindcss";
:root {
--bg: #f3f5f8;
--panel: #ffffff;
--panel-soft: #f8fafc;
--ink: #172234;
--muted: #6b778a;
--line: #e2e7ee;
--nav: #142333;
--nav-soft: #1a2e43;
--blue: #1f72e5;
--cyan: #23a9c9;
--green: #21a675;
--orange: #f08a35;
--amber: #d99614;
--red: #dc4f4f;
--shadow: 0 14px 34px rgba(20, 35, 51, .07);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
body {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select { font: inherit; }
button { cursor: pointer; }
.loading-screen {
min-height: 100vh; display: grid; place-content: center; text-align: center;
background: radial-gradient(circle at 50% 30%, #fff, #edf2f8);
}
.loading-mark {
width: 64px; height: 64px; margin: 0 auto 18px; border-radius: 18px;
display: grid; place-items: center; color: white; font-size: 26px; font-weight: 800;
background: linear-gradient(135deg, var(--blue), var(--cyan)); box-shadow: 0 15px 30px rgba(31,114,229,.25);
}
.loading-screen h1 { font-size: 22px; margin: 0; }
.loading-screen p { color: var(--muted); margin: 10px 0 22px; }
.error-screen .loading-mark { background: var(--red); }
.app-shell { min-height: 100vh; }
.sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: 236px; display: flex; flex-direction: column;
color: #d9e4ef; background:
radial-gradient(circle at -10% 0, rgba(35,169,201,.18), transparent 28%),
linear-gradient(180deg, #142536 0%, #101d2a 100%);
border-right: 1px solid #20364b;
}
.brand {
min-height: 76px; padding: 17px 16px; display: flex; align-items: center; gap: 11px;
border-bottom: 1px solid rgba(255,255,255,.08); position: relative;
}
.brand-symbol {
width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px;
background: linear-gradient(135deg, #2686ff, #27b1c5); color: white; font-weight: 900; font-size: 17px;
box-shadow: 0 8px 20px rgba(31,114,229,.25);
}
.brand strong { display: block; font-size: 16px; letter-spacing: .08em; }
.brand small { display: block; margin-top: 3px; color: #71869a; font-size: 8px; letter-spacing: .18em; }
.version { position: absolute; top: 17px; right: 13px; color: #4fc0d8; font: 700 9px monospace; }
.sidebar nav { flex: 1; overflow: auto; padding: 10px 8px; }
.nav-group { margin-bottom: 8px; }
.nav-group p { padding: 8px 11px 5px; margin: 0; color: #647d92; font-size: 10px; letter-spacing: .16em; }
.nav-group button {
width: 100%; height: 39px; padding: 0 11px; display: flex; align-items: center; gap: 10px;
border: 0; border-radius: 8px; background: transparent; color: #9fb1c2; font-size: 13px; text-align: left;
transition: .18s ease; position: relative;
}
.nav-group button:hover { color: #fff; background: rgba(255,255,255,.05); }
.nav-group button.active { color: #fff; background: linear-gradient(90deg, rgba(31,114,229,.35), rgba(35,169,201,.13)); }
.nav-group button.active::before { content: ""; position: absolute; left: 0; width: 3px; height: 20px; border-radius: 4px; background: #47b9e9; }
.nav-icon { width: 19px; color: #5aa7d6; font-weight: 800; text-align: center; }
.nav-group button b {
margin-left: auto; min-width: 19px; height: 19px; display: grid; place-items: center;
color: white; background: var(--red); border-radius: 10px; font-size: 10px;
}
.sidebar-foot { padding: 13px 17px 18px; border-top: 1px solid rgba(255,255,255,.08); font-size: 11px; color: #8fa4b7; }
.sidebar-foot div { display: flex; align-items: center; gap: 7px; margin: 6px 0; }
.sidebar-foot strong { color: #d8e3ec; margin-left: auto; }
.sidebar-foot small { display: block; color: #586f83; margin-top: 8px; }
.online-dot, .offline-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: #3bd29b; box-shadow: 0 0 0 4px rgba(59,210,155,.1); }
.offline-dot { background: var(--red); box-shadow: 0 0 0 4px rgba(220,79,79,.1); }
.workspace { min-height: 100vh; margin-left: 236px; }
.topbar {
height: 76px; padding: 0 24px; display: flex; align-items: center; gap: 20px;
background: rgba(255,255,255,.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 10;
}
.page-title { display: flex; align-items: baseline; gap: 11px; }
.page-title h1 { margin: 0; font-size: 20px; }
.page-title span { color: #9aa5b4; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.connection {
display: flex; align-items: center; gap: 7px; padding: 6px 10px; color: var(--green);
background: #f2fbf7; border: 1px solid #cfefe2; border-radius: 8px; font-size: 11px; font-weight: 700;
}
.clock { padding-right: 4px; text-align: right; }
.clock strong { display: block; font: 700 13px ui-monospace, monospace; }
.clock span { display: block; margin-top: 2px; color: var(--muted); font-size: 9px; }
.alert-button {
width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 9px;
background: white; color: var(--red); font-weight: 900; position: relative;
}
.alert-button b { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; border-radius: 9px; color: white; background: var(--red); font-size: 9px; }
.role-switch { padding: 3px; display: flex; background: #edf1f5; border-radius: 9px; }
.role-switch button { padding: 6px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; }
.role-switch button.active { color: #fff; background: var(--blue); box-shadow: 0 4px 10px rgba(31,114,229,.2); }
.content { max-width: 1500px; margin: 0 auto; padding: 22px 24px 40px; }
.page-stack { display: grid; gap: 16px; }
.greeting {
padding: 18px 21px; display: flex; align-items: center; justify-content: space-between;
background: linear-gradient(125deg, #18334c, #164664 60%, #176079); color: white;
border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; position: relative;
}
.greeting::after { content: ""; position: absolute; right: -60px; width: 280px; height: 280px; border: 54px solid rgba(255,255,255,.05); border-radius: 50%; }
.greeting span { color: #b9deee; font-size: 12px; }
.greeting h2 { margin: 4px 0 0; font-size: 18px; }
.greeting button { position: relative; z-index: 1; color: white; border-color: rgba(255,255,255,.2); background: rgba(255,255,255,.08); }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.kpi {
min-height: 124px; padding: 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 8px 25px rgba(20,35,51,.04); position: relative; overflow: hidden;
}
.kpi::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--blue); }
.kpi::after { content: ""; position: absolute; right: -20px; top: -20px; width: 84px; height: 84px; border-radius: 50%; background: rgba(31,114,229,.06); }
.kpi.orange::before { background: var(--orange); } .kpi.orange::after { background: rgba(240,138,53,.08); }
.kpi.green::before { background: var(--green); } .kpi.green::after { background: rgba(33,166,117,.08); }
.kpi.amber::before { background: var(--amber); } .kpi.amber::after { background: rgba(217,150,20,.08); }
.kpi > span { color: var(--muted); font-size: 12px; }
.kpi > strong { display: block; margin: 11px 0 7px; font-size: 28px; letter-spacing: -.03em; }
.kpi > small { color: #9aa5b4; font-size: 10px; }
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.two-columns.wide-left { grid-template-columns: 1.45fr 1fr; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 26px rgba(20,35,51,.04); overflow: hidden; }
.card-head { min-height: 61px; padding: 14px 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.card-head h3 { margin: 0; font-size: 14px; }
.card-head p { margin: 4px 0 0; color: var(--muted); font-size: 10px; }
.card-tools { display: flex; gap: 8px; }
.live-scale { min-height: 235px; padding: 34px 28px; display: grid; grid-template-columns: 1.2fr 1fr; align-items: center; background: radial-gradient(circle at 20% 50%, #eef8ff, transparent 48%); }
.live-scale > div > strong { color: var(--blue); font-size: 58px; letter-spacing: -.06em; }
.live-scale > div > span { margin-left: 8px; color: var(--muted); font-size: 16px; }
.live-scale p { display: flex; align-items: center; gap: 8px; color: var(--green); font-size: 11px; }
.live-scale dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.live-scale dl div { padding: 10px; background: white; border: 1px solid var(--line); border-radius: 9px; }
.live-scale dt { color: var(--muted); font-size: 9px; }
.live-scale dd { margin: 5px 0 0; font-size: 12px; font-weight: 700; }
.device-list { padding: 8px 17px 13px; }
.device-list > div { min-height: 40px; display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef1f4; }
.device-list > div:last-child { border: 0; }
.device-list span { display: flex; gap: 9px; color: var(--muted); font-size: 11px; }
.device-list span strong { width: 60px; color: var(--ink); }
.device-list small { color: #9aa5b4; font-size: 9px; }
.bar-chart { height: 190px; padding: 25px 25px 15px; display: flex; align-items: end; justify-content: space-around; gap: 13px; }
.bar-chart div { height: 100%; flex: 1; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 8px; }
.bar-chart i { width: min(24px, 65%); min-height: 10px; border-radius: 6px 6px 2px 2px; background: linear-gradient(180deg, #39b8d5, #247ae4); box-shadow: 0 5px 12px rgba(31,114,229,.15); }
.bar-chart span { color: var(--muted); font-size: 9px; }
.activity-list { padding: 7px 17px 13px; }
.activity-list > div { min-height: 38px; display: grid; grid-template-columns: 46px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef1f4; font-size: 11px; }
.activity-list time { color: #97a4b2; font: 600 10px ui-monospace, monospace; }
.activity-list span { color: var(--muted); }
.activity-list strong { font-size: 11px; }
.toolbar { min-height: 58px; padding: 10px 15px; display: flex; align-items: center; gap: 10px; background: var(--panel-soft); border-bottom: 1px solid var(--line); }
.toolbar > :last-child { margin-left: auto; }
.tabs { display: flex; gap: 3px; }
.tabs button { height: 34px; padding: 0 13px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; font-size: 11px; }
.tabs button.active { color: var(--blue); background: #eaf3ff; border-color: #d4e6ff; font-weight: 700; }
.search-box { width: min(310px, 35vw); height: 36px; padding: 0 11px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: white; color: #99a5b3; }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 11px; }
.primary-button, .ghost-button {
min-height: 34px; padding: 0 14px; border-radius: 8px; font-size: 11px; font-weight: 700; transition: .16s;
}
.primary-button { color: white; border: 1px solid var(--blue); background: var(--blue); box-shadow: 0 5px 12px rgba(31,114,229,.15); }
.primary-button:hover { background: #1767d2; }
.primary-button:disabled { opacity: .55; cursor: wait; }
.ghost-button { color: #476079; border: 1px solid var(--line); background: white; }
.ghost-button:hover { border-color: #b7c4d1; color: var(--blue); }
.table-wrap { overflow-x: auto; }
table { width: 100%; min-width: 850px; border-collapse: collapse; }
th { padding: 11px 14px; color: #8794a3; background: #fbfcfd; border-bottom: 1px solid var(--line); text-align: left; font-size: 10px; font-weight: 700; white-space: nowrap; }
td { padding: 12px 14px; border-bottom: 1px solid #edf0f4; color: #4c5a6c; font-size: 11px; white-space: nowrap; }
tbody tr:hover { background: #f9fbfd; }
td strong { color: var(--ink); }
td select { max-width: 150px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; background: white; color: var(--ink); font-size: 10px; }
.empty { height: 180px; text-align: center; color: #9aa5b4; }
.status { display: inline-flex; align-items: center; gap: 4px; padding: 3px 7px; border: 1px solid; border-radius: 12px; font-size: 9px; font-weight: 700; }
.status.ok { color: #17835c; border-color: #bce7d5; background: #effaf5; }
.status.warn { color: #9a6d0d; border-color: #f0d89d; background: #fff9e8; }
.status.bad { color: #b73939; border-color: #f0c3c3; background: #fff3f3; }
.status.muted { color: #6e7c8d; border-color: #dce2e8; background: #f6f8fa; }
.row-actions { display: flex; gap: 6px; }
.row-actions button { padding: 4px 8px; border: 1px solid #d9e1e9; border-radius: 6px; color: #52708f; background: white; font-size: 9px; }
.row-actions button:hover { color: var(--blue); border-color: #a9c8ed; }
.statistics-grid { padding: 28px; display: grid; grid-template-columns: repeat(4,1fr); gap: 15px; }
.statistics-grid > div { padding: 18px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfe; overflow: hidden; }
.statistics-grid span { color: var(--muted); font-size: 11px; }
.statistics-grid strong { display: block; margin: 9px 0 14px; font-size: 22px; }
.statistics-grid i { display: block; height: 5px; border-radius: 4px; background: linear-gradient(90deg, var(--blue), var(--cyan)); }
.device-grid { padding: 15px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.device-grid article { min-height: 160px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: linear-gradient(145deg, #fff, #f7fafc); }
.device-grid article > div:first-child { display: flex; align-items: center; justify-content: space-between; }
.device-grid article h4 { margin: 16px 0 5px; font-size: 14px; }
.device-grid article p, .device-grid article small, .device-grid article footer { color: var(--muted); font-size: 10px; }
.device-grid article footer { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); }
.meter { height: 6px; margin: 16px 0 7px; overflow: hidden; background: #e7ebef; border-radius: 5px; }
.meter i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--green), var(--amber)); }
.alert-list { padding: 9px 15px 15px; display: grid; gap: 9px; }
.alert-list article { min-height: 86px; padding: 13px; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 13px; border: 1px solid #eadfbf; border-left: 3px solid var(--amber); border-radius: 9px; background: #fffdf7; }
.alert-list article.critical { border-color: #ecc7c7; border-left-color: var(--red); background: #fff9f9; }
.alert-sign { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; color: white; background: var(--amber); font-weight: 900; }
.critical .alert-sign { background: var(--red); }
.alert-title { display: flex; align-items: center; gap: 8px; }
.alert-title h3 { margin: 0; font-size: 13px; }
.alert-list p { margin: 5px 0; color: #596879; font-size: 11px; }
.alert-list small { color: #97a3af; font-size: 9px; }
.sub-toolbar { min-height: 50px; padding: 8px 15px; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 10px; border-bottom: 1px solid var(--line); }
.settings-form { max-width: 720px; padding: 24px; display: grid; gap: 14px; }
.settings-form > label, .modal-body label { display: grid; grid-template-columns: 110px 1fr auto; align-items: center; gap: 10px; }
.settings-form label span, .modal-body label span { color: var(--muted); font-size: 11px; }
.settings-form input, .settings-form select, .modal-body input {
height: 38px; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; outline: 0; color: var(--ink); background: white; font-size: 11px;
}
.settings-form input:focus, .modal-body input:focus { border-color: #8bb8ee; box-shadow: 0 0 0 3px rgba(31,114,229,.08); }
.settings-form small { color: var(--muted); font-size: 10px; }
.form-actions { padding-left: 120px; display: flex; align-items: center; gap: 9px; }
.simulator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.sim-card .card-head { background: #fbfcfd; }
.terminal {
width: 100%; min-height: 520px; padding: 48px 38px; display: flex; flex-direction: column; align-items: center; justify-content: center;
border: 18px solid #1e2b38; background: radial-gradient(circle at 50% 42%, #f9fcff, #eaf1f7); color: var(--ink); position: relative;
}
.terminal-camera { position: absolute; top: -12px; width: 7px; height: 7px; border-radius: 50%; background: #090e13; box-shadow: 0 0 0 2px #354758; }
.terminal > small { position: absolute; top: 14px; left: 17px; color: #7d8b99; font: 600 9px ui-monospace, monospace; }
.terminal-icon { width: 100px; height: 100px; display: grid; place-items: center; border: 2px solid var(--orange); border-radius: 50%; color: var(--orange); font-size: 38px; box-shadow: 0 0 0 12px rgba(240,138,53,.08); }
.terminal.blue .terminal-icon { color: var(--blue); border-color: var(--blue); box-shadow: 0 0 0 12px rgba(31,114,229,.08); }
.terminal h3 { margin: 33px 0 10px; font-size: 24px; }
.terminal p { color: var(--muted); font-size: 12px; }
.terminal > strong { margin-top: 25px; padding: 11px 18px; border-radius: 8px; color: white; background: var(--orange); font-size: 11px; }
.terminal.blue > strong { background: var(--blue); }
.step-dots { position: absolute; bottom: 20px; display: flex; gap: 7px; }
.step-dots i { width: 7px; height: 7px; border-radius: 50%; background: #c5ced6; }
.step-dots i.active { width: 22px; border-radius: 5px; background: var(--orange); }
.terminal.blue .step-dots i.active { background: var(--blue); }
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: rgba(12,24,37,.42); backdrop-filter: blur(4px); }
.modal { width: min(500px, calc(100vw - 30px)); border-radius: 14px; background: white; box-shadow: 0 28px 70px rgba(12,24,37,.25); overflow: hidden; }
.modal header { height: 60px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.modal header h2 { margin: 0; font-size: 16px; }
.modal header button { width: 32px; height: 32px; border: 0; border-radius: 8px; color: var(--muted); background: #f3f5f7; font-size: 20px; }
.modal-body { padding: 22px; display: grid; gap: 13px; }
.modal footer { padding: 13px 18px; display: flex; justify-content: end; gap: 8px; border-top: 1px solid var(--line); background: #fbfcfd; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 70; padding: 12px 16px; color: white; background: #17334b; border-radius: 9px; box-shadow: 0 13px 30px rgba(12,24,37,.25); font-size: 11px; animation: toast-in .25s ease; }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
@media (max-width: 1150px) {
.sidebar { width: 205px; }
.workspace { margin-left: 205px; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.device-grid { grid-template-columns: 1fr 1fr; }
.connection, .clock { display: none; }
}
@media (max-width: 820px) {
.sidebar { position: static; width: 100%; height: auto; }
.sidebar nav { display: flex; overflow-x: auto; }
.nav-group { display: contents; }
.nav-group p, .sidebar-foot { display: none; }
.nav-group button { min-width: 110px; justify-content: center; }
.workspace { margin-left: 0; }
.topbar { height: auto; min-height: 72px; align-items: flex-start; padding: 12px 14px; }
.topbar-right { flex-wrap: wrap; justify-content: end; }
.role-switch { order: 3; width: 100%; justify-content: end; }
.content { padding: 14px; }
.two-columns, .two-columns.wide-left, .simulator-grid { grid-template-columns: 1fr; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.device-grid, .statistics-grid { grid-template-columns: 1fr; }
.search-box { width: 100%; }
.toolbar { align-items: stretch; flex-direction: column; }
.toolbar > :last-child { margin-left: 0; }
.tabs { overflow-x: auto; }
.live-scale { grid-template-columns: 1fr; gap: 18px; }
}
@media (max-width: 520px) {
.brand { min-height: 64px; }
.page-title span, .alert-button { display: none; }
.kpi-grid { grid-template-columns: 1fr; }
.greeting { align-items: flex-start; gap: 12px; flex-direction: column; }
.terminal { min-height: 430px; border-width: 11px; }
.modal-body label, .settings-form > label { grid-template-columns: 1fr; }
.form-actions { padding-left: 0; }
}
+44
View File
@@ -0,0 +1,44 @@
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 baseUrl = new URL(`${protocol}://${host}`);
return {
metadataBase: baseUrl,
title: "智能回收运营平台",
description: "面向内网部署的称重、设备、人员、人脸与告警管理后台",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
openGraph: {
title: "智能回收运营平台",
description: "称重、设备、人员与告警的一体化内网管理平台",
type: "website",
images: [{ url: new URL("/og.png", baseUrl).toString(), width: 1672, height: 941 }],
},
twitter: {
card: "summary_large_image",
title: "智能回收运营平台",
description: "称重、设备、人员与告警的一体化内网管理平台",
images: [new URL("/og.png", baseUrl).toString()],
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminConsole } from "./AdminConsole";
export default function Home() {
return <AdminConsole />;
}