feat: 初始化星元智灵人脸识别回收箱项目
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.wrangler
|
||||
.git
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.vinext/
|
||||
/out/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
next-env.d.ts
|
||||
/dist/
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"d1": "DB",
|
||||
"r2": null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app ./
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# 智能回收运营平台
|
||||
|
||||
面向工厂内网部署的称重与设备管理后台,按公开演示后台的业务范围重新实现,不复用对方代码或视觉素材。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 工作台:投放量、称重次数、待核对数据、设备在线率、最近流水
|
||||
- 废料称重:记录查询、统计、CSV 导出
|
||||
- 事件列表:按投放事件聚合原子称重记录
|
||||
- 地磅核对:台秤与地磅偏差判定
|
||||
- 导体追溯:RFID、批次、皮重、毛重、净重、MES 状态
|
||||
- 人员设备:人员、人脸版本、车间、设备心跳、满溢率
|
||||
- 告警中心:接单、关闭、阈值配置
|
||||
- 系统设置:角色、车间、设备监控、MES、审计日志
|
||||
- 终端模拟:废料投放与导体称重流程演示
|
||||
- 设备接口:Token、投放、心跳、状态、告警、人员拉取、人脸查询、同步回执
|
||||
|
||||
## 本机开发
|
||||
|
||||
需要 Node.js 22 或更高版本:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:3000`。
|
||||
|
||||
## Ubuntu 24.04 部署
|
||||
|
||||
推荐使用 Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
后台地址:`http://服务器IP:3000`。数据库持久化在 Docker 卷 `console-data` 中。
|
||||
|
||||
首次部署后建议在路由器或防火墙中只允许内网访问,并通过 Nginx 配置 HTTPS。
|
||||
|
||||
## 设备接口示例
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/tokens
|
||||
POST /api/v1/devices/{deviceId}/deliveries
|
||||
POST /api/v1/devices/{deviceId}/heartbeat
|
||||
POST /api/v1/devices/{deviceId}/status
|
||||
POST /api/v1/devices/{deviceId}/alerts
|
||||
GET /api/v1/devices/{deviceId}/people/incremental?cursor=0
|
||||
GET /api/v1/devices/{deviceId}/faces/{personId}
|
||||
POST /api/v1/devices/{deviceId}/sync-acks
|
||||
GET /api/v1/deliveries/{recordId}
|
||||
```
|
||||
|
||||
当前版本用于本地联调。正式投产前还需要补充设备密钥、签名校验、管理员登录和自动备份。
|
||||
@@ -0,0 +1,820 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Row = Record<string, string | number | null>;
|
||||
type ConsoleData = {
|
||||
people: Row[];
|
||||
workshops: Row[];
|
||||
devices: Row[];
|
||||
weighings: Row[];
|
||||
events: Row[];
|
||||
reconciliations: Row[];
|
||||
traces: Row[];
|
||||
tags: Row[];
|
||||
alerts: Row[];
|
||||
settings: Record<string, string>;
|
||||
logs: Row[];
|
||||
serverTime: number;
|
||||
};
|
||||
|
||||
type PageKey =
|
||||
| "dashboard"
|
||||
| "weighings"
|
||||
| "events"
|
||||
| "reconcile"
|
||||
| "trace"
|
||||
| "assets"
|
||||
| "alerts"
|
||||
| "settings"
|
||||
| "simulator";
|
||||
|
||||
type RoleKey = "warehouse" | "worker" | "admin";
|
||||
|
||||
const pageMeta: Record<PageKey, { name: string; en: string; icon: string }> = {
|
||||
dashboard: { name: "工作台", en: "Dashboard", icon: "◫" },
|
||||
weighings: { name: "废料称重", en: "Weighing records", icon: "▦" },
|
||||
events: { name: "事件列表", en: "Events", icon: "◎" },
|
||||
reconcile: { name: "地磅核对", en: "Reconciliation", icon: "⚖" },
|
||||
trace: { name: "导体追溯", en: "Cable trace", icon: "⌁" },
|
||||
assets: { name: "人员设备", en: "People & devices", icon: "♙" },
|
||||
alerts: { name: "告警中心", en: "Alerts", icon: "!" },
|
||||
settings: { name: "系统设置", en: "Settings", icon: "⌘" },
|
||||
simulator: { name: "终端模拟", en: "Terminal simulator", icon: "▣" },
|
||||
};
|
||||
|
||||
const roleMeta: Record<RoleKey, { name: string; person: string; pages: PageKey[] }> = {
|
||||
warehouse: {
|
||||
name: "仓管员",
|
||||
person: "张三",
|
||||
pages: ["dashboard", "weighings", "events", "reconcile", "assets", "simulator"],
|
||||
},
|
||||
worker: {
|
||||
name: "产线工人",
|
||||
person: "李四",
|
||||
pages: ["dashboard", "trace", "assets", "simulator"],
|
||||
},
|
||||
admin: {
|
||||
name: "管理员",
|
||||
person: "王工",
|
||||
pages: Object.keys(pageMeta) as PageKey[],
|
||||
},
|
||||
};
|
||||
|
||||
const navGroups: Array<{ title: string; pages: PageKey[] }> = [
|
||||
{ title: "总览", pages: ["dashboard"] },
|
||||
{ title: "废料库回收", pages: ["weighings", "events", "reconcile"] },
|
||||
{ title: "导体称重", pages: ["trace"] },
|
||||
{ title: "运营管理", pages: ["assets", "alerts"] },
|
||||
{ title: "系统", pages: ["settings"] },
|
||||
{ title: "演示工具", pages: ["simulator"] },
|
||||
];
|
||||
|
||||
const n = (row: Row, key: string) => Number(row[key] ?? 0);
|
||||
const s = (row: Row, key: string) => String(row[key] ?? "");
|
||||
|
||||
function formatTime(value: string | number | null, includeDate = false) {
|
||||
const date = new Date(Number(value ?? 0));
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
...(includeDate ? { month: "2-digit", day: "2-digit" } : {}),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function Status({ value }: { value: string }) {
|
||||
const level =
|
||||
/超标|严重|失败|离线|停用|损坏|异常/.test(value)
|
||||
? "bad"
|
||||
: /警告|预警|处理中|待推送|需更新|未用/.test(value)
|
||||
? "warn"
|
||||
: /在线|正常|成功|已同步|已确认|已录入|在用|已自动|已推送/.test(value)
|
||||
? "ok"
|
||||
: "muted";
|
||||
return <span className={`status ${level}`}>{value}</span>;
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
tools,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
tools?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`card ${className}`}>
|
||||
{(title || tools) && (
|
||||
<div className="card-head">
|
||||
<div>
|
||||
{title && <h3>{title}</h3>}
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
{tools && <div className="card-tools">{tools}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
}: {
|
||||
columns: Array<{ key: string; label: string; render?: (row: Row) => ReactNode }>;
|
||||
rows: Row[];
|
||||
}) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column.key}>{column.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={columns.length} className="empty">没有符合条件的数据</td></tr>
|
||||
) : rows.map((row, index) => (
|
||||
<tr key={String(row.id ?? row.record_id ?? row.code ?? index)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{column.render ? column.render(row) : String(row[column.key] ?? "—")}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBox({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索…",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="search-box">
|
||||
<span>⌕</span>
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function exportCsv(filename: string, rows: Row[]) {
|
||||
if (!rows.length) return;
|
||||
const headers = Object.keys(rows[0]);
|
||||
const escape = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
|
||||
const csv = "\ufeff" + [headers.map(escape).join(","), ...rows.map((row) => headers.map((key) => escape(row[key])).join(","))].join("\n");
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function AdminConsole() {
|
||||
const [data, setData] = useState<ConsoleData | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState<PageKey>("dashboard");
|
||||
const [role, setRole] = useState<RoleKey>("admin");
|
||||
const [query, setQuery] = useState("");
|
||||
const [tab, setTab] = useState("records");
|
||||
const [toast, setToast] = useState("");
|
||||
const [clock, setClock] = useState(new Date());
|
||||
const [modal, setModal] = useState<"workshop" | "tag" | "threshold" | null>(null);
|
||||
const [simStep, setSimStep] = useState(0);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/console", { cache: "no-store" });
|
||||
const payload = await response.json() as { code: number; message?: string; data?: ConsoleData };
|
||||
if (!response.ok || !payload.data) throw new Error(payload.message || "数据加载失败");
|
||||
setData(payload.data);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(() => setClock(new Date()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
if (page === "trace") setTab("trace");
|
||||
else if (page === "assets") setTab("people");
|
||||
else if (page === "settings") setTab("accounts");
|
||||
else if (page === "weighings") setTab("records");
|
||||
}, [page]);
|
||||
|
||||
async function action(name: string, payload: Record<string, unknown> = {}, message = "操作成功") {
|
||||
try {
|
||||
const response = await fetch("/api/console", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: name, payload }),
|
||||
});
|
||||
const result = await response.json() as { code: number; message?: string; data?: ConsoleData };
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "操作失败");
|
||||
if (result.data) setData(result.data);
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(""), 2_800);
|
||||
} catch (cause) {
|
||||
setToast(cause instanceof Error ? cause.message : "操作失败");
|
||||
window.setTimeout(() => setToast(""), 3_600);
|
||||
}
|
||||
}
|
||||
|
||||
function switchRole(nextRole: RoleKey) {
|
||||
setRole(nextRole);
|
||||
if (!roleMeta[nextRole].pages.includes(page)) setPage("dashboard");
|
||||
setToast(`已切换为${roleMeta[nextRole].name}视角`);
|
||||
window.setTimeout(() => setToast(""), 2_200);
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<main className="loading-screen">
|
||||
<div className="loading-mark">智</div>
|
||||
<h1>智能回收运营平台</h1>
|
||||
<p>正在连接本地数据服务…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<main className="loading-screen error-screen">
|
||||
<div className="loading-mark">!</div>
|
||||
<h1>数据服务暂不可用</h1>
|
||||
<p>{error}</p>
|
||||
<button className="primary-button" onClick={load}>重新连接</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const visiblePages = roleMeta[role].pages;
|
||||
const openAlerts = data.alerts.filter((item) => s(item, "status") !== "已关闭").length;
|
||||
const onlineDevices = data.devices.filter((item) => s(item, "status") === "在线").length;
|
||||
const title = pageMeta[page];
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-symbol">智</div>
|
||||
<div>
|
||||
<strong>智能回收</strong>
|
||||
<small>OPERATIONS HUB</small>
|
||||
</div>
|
||||
<span className="version">V1.0</span>
|
||||
</div>
|
||||
<nav>
|
||||
{navGroups.map((group) => {
|
||||
const pages = group.pages.filter((item) => visiblePages.includes(item));
|
||||
if (!pages.length) return null;
|
||||
return (
|
||||
<div className="nav-group" key={group.title}>
|
||||
<p>{group.title}</p>
|
||||
{pages.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={page === item ? "active" : ""}
|
||||
onClick={() => setPage(item)}
|
||||
>
|
||||
<span className="nav-icon">{pageMeta[item].icon}</span>
|
||||
{pageMeta[item].name}
|
||||
{item === "alerts" && openAlerts > 0 && <b>{openAlerts}</b>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<div><i className="online-dot" />设备在线 <strong>{onlineDevices}/{data.devices.length}</strong></div>
|
||||
<div><i className="online-dot" />本地数据库正常</div>
|
||||
<small>内网服务 · 数据不出厂区</small>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="page-title">
|
||||
<h1>{title.name}</h1>
|
||||
<span>{title.en}</span>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<div className="connection"><i className="online-dot" /> 服务正常</div>
|
||||
<div className="clock">
|
||||
<strong>{clock.toLocaleTimeString("zh-CN", { hour12: false })}</strong>
|
||||
<span>{clock.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" })}</span>
|
||||
</div>
|
||||
<button className="alert-button" onClick={() => setPage("alerts")}>
|
||||
! {openAlerts > 0 && <b>{openAlerts}</b>}
|
||||
</button>
|
||||
<div className="role-switch">
|
||||
{(Object.keys(roleMeta) as RoleKey[]).map((key) => (
|
||||
<button key={key} className={role === key ? "active" : ""} onClick={() => switchRole(key)}>
|
||||
{roleMeta[key].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{page === "dashboard" && <Dashboard data={data} role={role} setPage={setPage} />}
|
||||
{page === "weighings" && (
|
||||
<Weighings data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} />
|
||||
)}
|
||||
{page === "events" && <EventsPage data={data} query={query} setQuery={setQuery} />}
|
||||
{page === "reconcile" && <Reconcile data={data} />}
|
||||
{page === "trace" && (
|
||||
<TracePage data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} action={action} openTag={() => setModal("tag")} />
|
||||
)}
|
||||
{page === "assets" && (
|
||||
<Assets data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} role={role} action={action} />
|
||||
)}
|
||||
{page === "alerts" && <AlertsPage data={data} role={role} action={action} openThreshold={() => setModal("threshold")} />}
|
||||
{page === "settings" && (
|
||||
<SettingsPage data={data} tab={tab} setTab={setTab} action={action} openWorkshop={() => setModal("workshop")} />
|
||||
)}
|
||||
{page === "simulator" && <Simulator step={simStep} setStep={setSimStep} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<Modal
|
||||
type={modal}
|
||||
settings={data.settings}
|
||||
onClose={() => setModal(null)}
|
||||
onSubmit={async (values) => {
|
||||
if (modal === "workshop") await action("workshop.add", values, "车间已新增");
|
||||
if (modal === "tag") await action("tag.add", values, "RFID 标签已新增");
|
||||
if (modal === "threshold") await action("settings.save", values, "告警阈值已保存");
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ data, role, setPage }: { data: ConsoleData; role: RoleKey; setPage: (page: PageKey) => void }) {
|
||||
const todayTotal = data.weighings.reduce((total, row) => total + n(row, "weight"), 0);
|
||||
const netTotal = data.traces.reduce((total, row) => total + n(row, "net"), 0);
|
||||
const pending = data.reconciliations.filter((row) => s(row, "verdict") !== "正常").length;
|
||||
const deviceOnline = data.devices.filter((row) => s(row, "status") === "在线").length;
|
||||
const showWaste = role !== "worker";
|
||||
const kpis = showWaste
|
||||
? [
|
||||
["今日废料投放", `${todayTotal.toFixed(1)} kg`, "按已接收记录实时汇总", "orange"],
|
||||
["称重次数", `${data.weighings.length} 次`, "原子称重记录", "blue"],
|
||||
["待核对", `${pending} 条`, "警告与超标记录", "amber"],
|
||||
["设备在线率", `${deviceOnline}/${data.devices.length}`, "最近心跳状态", "green"],
|
||||
]
|
||||
: [
|
||||
["今日称重轴数", `${data.traces.length} 轴`, "导体追溯记录", "blue"],
|
||||
["净重累计", `${netTotal.toFixed(1)} kg`, "毛重减皮重", "green"],
|
||||
["MES 待推送", `${data.traces.filter((row) => s(row, "mes_status") !== "已推送").length} 条`, "自动重试队列", "amber"],
|
||||
["RFID 标签", `${data.tags.length} 个`, "在用与备用标签", "orange"],
|
||||
];
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="greeting">
|
||||
<div>
|
||||
<span>你好,{roleMeta[role].person}</span>
|
||||
<h2>{roleMeta[role].name}视角 · 今日运营概览</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => setPage(showWaste ? "weighings" : "trace")}>查看明细 →</button>
|
||||
</div>
|
||||
<div className="kpi-grid">
|
||||
{kpis.map(([label, value, note, color]) => (
|
||||
<div className={`kpi ${color}`} key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{note}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="two-columns wide-left">
|
||||
<Card title={showWaste ? "实时称重 · SB-01" : "最近导体称重任务"} subtitle={showWaste ? "数据稳定后自动生成称重记录" : "RFID 与生产批次全程关联"}>
|
||||
{showWaste ? (
|
||||
<div className="live-scale">
|
||||
<div>
|
||||
<strong>12.48</strong><span>kg</span>
|
||||
<p><i className="online-dot" /> 采样稳定 · 精度 ±10g</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>操作人</dt><dd>张三</dd></div>
|
||||
<div><dt>机台</dt><dd>MC-101</dd></div>
|
||||
<div><dt>类别</dt><dd>废铜</dd></div>
|
||||
<div><dt>来源</dt><dd>小秤台</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data.traces.slice(0, 4)}
|
||||
columns={[
|
||||
{ key: "task_id", label: "任务号" },
|
||||
{ key: "rfid", label: "RFID" },
|
||||
{ key: "batch", label: "批次" },
|
||||
{ key: "net", label: "净重", render: (row) => `${n(row, "net").toFixed(1)} kg` },
|
||||
{ key: "mes_status", label: "MES", render: (row) => <Status value={s(row, "mes_status")} /> },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Card title="设备状态" subtitle={`${deviceOnline} 台在线`}>
|
||||
<div className="device-list">
|
||||
{data.devices.slice(0, 6).map((device) => (
|
||||
<div key={s(device, "id")}>
|
||||
<i className={s(device, "status") === "在线" ? "online-dot" : "offline-dot"} />
|
||||
<span><strong>{s(device, "id")}</strong>{s(device, "type")}</span>
|
||||
<small>{formatTime(device.last_seen)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="two-columns">
|
||||
<Card title="近 7 日投放趋势" subtitle="公斤">
|
||||
<div className="bar-chart">
|
||||
{[54, 72, 61, 86, 66, 78, 93].map((height, index) => (
|
||||
<div key={index}><i style={{ height: `${height}%` }} /><span>{["一", "二", "三", "四", "五", "六", "日"][index]}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="最近操作流水" subtitle="自动刷新">
|
||||
<div className="activity-list">
|
||||
{data.weighings.slice(0, 5).map((row) => (
|
||||
<div key={s(row, "id")}>
|
||||
<time>{formatTime(row.created_at)}</time>
|
||||
<span>{s(row, "operator")} · {s(row, "machine")} · {s(row, "category")}</span>
|
||||
<strong>{n(row, "weight").toFixed(2)} kg</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Weighings({ data, query, setQuery, tab, setTab }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void }) {
|
||||
const rows = useMemo(() => {
|
||||
const key = query.toLowerCase();
|
||||
return data.weighings.filter((row) => !key || [row.operator, row.machine, row.category, row.event_id].some((value) => String(value ?? "").toLowerCase().includes(key)));
|
||||
}, [data.weighings, query]);
|
||||
const categoryTotals = ["废铜", "废电线", "废铝", "废锡"].map((category) => ({
|
||||
category,
|
||||
value: rows.filter((row) => s(row, "category") === category).reduce((total, row) => total + n(row, "weight"), 0),
|
||||
}));
|
||||
return (
|
||||
<Card
|
||||
title="废料称重数据"
|
||||
subtitle="原子称重记录与统计分析"
|
||||
tools={<button className="ghost-button" onClick={() => exportCsv("废料称重记录.csv", rows)}>导出 CSV</button>}
|
||||
>
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "records" ? "active" : ""} onClick={() => setTab("records")}>记录列表</button>
|
||||
<button className={tab === "statistics" ? "active" : ""} onClick={() => setTab("statistics")}>统计视图</button>
|
||||
</div>
|
||||
<SearchBox value={query} onChange={setQuery} placeholder="搜索操作人、机台或事件…" />
|
||||
</div>
|
||||
{tab === "records" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "operator", label: "操作人" },
|
||||
{ key: "machine", label: "关联机台" },
|
||||
{ key: "category", label: "废料类别" },
|
||||
{ key: "source", label: "来源设备" },
|
||||
{ key: "weight", label: "精确重量", render: (row) => <strong>{n(row, "weight").toFixed(2)} kg</strong> },
|
||||
{ key: "event_id", label: "所属事件" },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="statistics-grid">
|
||||
{categoryTotals.map((item) => (
|
||||
<div key={item.category}>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.value.toFixed(1)} kg</strong>
|
||||
<i style={{ width: `${Math.min(100, item.value * 4)}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsPage({ data, query, setQuery }: { data: ConsoleData; query: string; setQuery: (value: string) => void }) {
|
||||
const rows = data.events.filter((row) => !query || `${s(row, "id")}${s(row, "operator")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="废料投放事件" subtitle="按进门到离门聚合原子称重记录" tools={<button className="ghost-button" onClick={() => exportCsv("事件列表.csv", rows)}>导出 CSV</button>}>
|
||||
<div className="toolbar"><SearchBox value={query} onChange={setQuery} placeholder="搜索事件 ID 或操作人…" /></div>
|
||||
<DataTable rows={rows} columns={[
|
||||
{ key: "id", label: "事件 ID" },
|
||||
{ key: "operator", label: "操作人" },
|
||||
{ key: "entered_at", label: "进门时间", render: (row) => formatTime(row.entered_at, true) },
|
||||
{ key: "left_at", label: "离门时间", render: (row) => formatTime(row.left_at) },
|
||||
{ key: "duration", label: "历时", render: (row) => `${Math.max(1, Math.round((n(row, "left_at") - n(row, "entered_at")) / 60_000))} 分钟` },
|
||||
{ key: "atomic_count", label: "原子笔数" },
|
||||
{ key: "scale_total", label: "台秤总重", render: (row) => `${n(row, "scale_total").toFixed(2)} kg` },
|
||||
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Reconcile({ data }: { data: ConsoleData }) {
|
||||
return (
|
||||
<Card title="双秤数据核对" subtitle="正常 ≤2% · 警告 2–5% · 超标 >5%" tools={<button className="ghost-button" onClick={() => exportCsv("地磅核对.csv", data.reconciliations)}>导出 CSV</button>}>
|
||||
<DataTable rows={data.reconciliations} columns={[
|
||||
{ key: "event_id", label: "事件 ID" },
|
||||
{ key: "category", label: "废料类别" },
|
||||
{ key: "small_scale", label: "台秤汇总", render: (row) => `${n(row, "small_scale").toFixed(2)} kg` },
|
||||
{ key: "floor_scale", label: "地磅增量", render: (row) => `${n(row, "floor_scale").toFixed(2)} kg` },
|
||||
{ key: "difference", label: "偏差值", render: (row) => `${n(row, "difference").toFixed(2)} kg` },
|
||||
{ key: "difference_rate", label: "偏差率", render: (row) => `${n(row, "difference_rate").toFixed(2)}%` },
|
||||
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
|
||||
{ key: "storage_status", label: "入库状态", render: (row) => <Status value={s(row, "storage_status")} /> },
|
||||
]} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TracePage({ data, query, setQuery, tab, setTab, action, openTag }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openTag: () => void }) {
|
||||
const rows = data.traces.filter((row) => !query || `${s(row, "task_id")}${s(row, "rfid")}${s(row, "batch")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="导体称重追溯" subtitle="RFID、生产批次与两次称重完整关联">
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "trace" ? "active" : ""} onClick={() => setTab("trace")}>称重溯源</button>
|
||||
<button className={tab === "tags" ? "active" : ""} onClick={() => setTab("tags")}>RFID 标签</button>
|
||||
</div>
|
||||
{tab === "trace" ? <SearchBox value={query} onChange={setQuery} placeholder="搜索任务、RFID 或批次…" /> : <button className="primary-button" onClick={openTag}>添加标签</button>}
|
||||
</div>
|
||||
{tab === "trace" ? <DataTable rows={rows} columns={[
|
||||
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "workshop", label: "所属车间" },
|
||||
{ key: "task_id", label: "任务号" },
|
||||
{ key: "rfid", label: "RFID 标签" },
|
||||
{ key: "tare", label: "皮重", render: (row) => `${n(row, "tare").toFixed(2)} kg` },
|
||||
{ key: "gross", label: "毛重", render: (row) => `${n(row, "gross").toFixed(2)} kg` },
|
||||
{ key: "net", label: "净重", render: (row) => <strong>{n(row, "net").toFixed(2)} kg</strong> },
|
||||
{ key: "machine", label: "机台" },
|
||||
{ key: "batch", label: "批次号" },
|
||||
{ key: "mes_status", label: "MES 推送", render: (row) => <Status value={s(row, "mes_status")} /> },
|
||||
]} /> : <DataTable rows={data.tags} columns={[
|
||||
{ key: "id", label: "标签 ID" },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "last_checked", label: "最后检测", render: (row) => row.last_checked ? formatTime(row.last_checked, true) : "—" },
|
||||
{ key: "actions", label: "操作", render: (row) => (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("tag.status", { id: row.id, status: "在用" }, "标签已写入")}>写入</button>
|
||||
<button onClick={() => action("tag.status", { id: row.id, status: "损坏" }, "标签已标记损坏")}>报损</button>
|
||||
</div>
|
||||
) },
|
||||
]} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Assets({ data, query, setQuery, tab, setTab, role, action }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
|
||||
const people = data.people.filter((row) => !query || `${s(row, "name")}${s(row, "id")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<Card title="人员与设备" subtitle={role === "admin" ? "人员主数据由服务端统一维护并向设备下发" : "当前为只读视图"}>
|
||||
<div className="toolbar">
|
||||
<div className="tabs">
|
||||
<button className={tab === "people" ? "active" : ""} onClick={() => setTab("people")}>人员</button>
|
||||
<button className={tab === "machines" ? "active" : ""} onClick={() => setTab("machines")}>机台设备</button>
|
||||
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}>回收设备</button>
|
||||
</div>
|
||||
{tab === "people" && <SearchBox value={query} onChange={setQuery} placeholder="搜索姓名或员工 ID…" />}
|
||||
{tab === "people" && role === "admin" && <button className="primary-button" onClick={() => action("mes.sync", {}, "MES 人员同步完成")}>同步 MES</button>}
|
||||
</div>
|
||||
{tab === "people" && <DataTable rows={people} columns={[
|
||||
{ key: "name", label: "姓名" },
|
||||
{ key: "id", label: "员工 ID" },
|
||||
{ key: "workshop", label: "所属车间", render: (row) => role === "admin" ? (
|
||||
<select value={s(row, "workshop")} onChange={(event) => action("person.workshop", { id: row.id, workshop: event.target.value }, "所属车间已更新")}>
|
||||
{data.workshops.map((workshop) => <option key={s(workshop, "code")}>{s(workshop, "name")}</option>)}
|
||||
</select>
|
||||
) : s(row, "workshop") },
|
||||
{ key: "role", label: "角色" },
|
||||
{ key: "face_status", label: "人脸状态", render: (row) => <Status value={s(row, "face_status")} /> },
|
||||
{ key: "sync_status", label: "同步状态", render: (row) => <Status value={s(row, "sync_status")} /> },
|
||||
{ key: "active", label: "账号状态", render: (row) => <Status value={n(row, "active") ? "启用" : "停用"} /> },
|
||||
{ key: "actions", label: "操作", render: (row) => role === "admin" ? (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("person.face", { id: row.id }, "人脸版本已更新")}>更新人脸</button>
|
||||
<button onClick={() => action("person.toggle", { id: row.id }, "人员状态已切换")}>{n(row, "active") ? "停用" : "启用"}</button>
|
||||
</div>
|
||||
) : "只读" },
|
||||
]} />}
|
||||
{tab === "machines" && <DataTable rows={[
|
||||
{ id: "MC-101", name: "1# 切割机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
|
||||
{ id: "MC-105", name: "2# 剥线机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
|
||||
{ id: "MC-201", name: "8# 绞线机", type: "导体机台", workshop: "二楼导体车间", status: "维护中", mes: "正常" },
|
||||
]} columns={[
|
||||
{ key: "id", label: "机台编号" }, { key: "name", label: "机台名称" }, { key: "type", label: "类型" },
|
||||
{ key: "workshop", label: "所属车间" }, { key: "status", label: "运行状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
{ key: "mes", label: "MES 同步", render: (row) => <Status value={s(row, "mes")} /> },
|
||||
]} />}
|
||||
{tab === "devices" && (
|
||||
<div className="device-grid">
|
||||
{data.devices.map((device) => (
|
||||
<article key={s(device, "id")}>
|
||||
<div><strong>{s(device, "id")}</strong><Status value={s(device, "status")} /></div>
|
||||
<h4>{s(device, "type")}</h4>
|
||||
<p>{s(device, "ip")} · 心跳 {s(device, "heartbeat_seconds")}s</p>
|
||||
{n(device, "fill_percent") > 0 && (
|
||||
<>
|
||||
<div className="meter"><i style={{ width: `${n(device, "fill_percent")}%` }} /></div>
|
||||
<small>满溢率 {n(device, "fill_percent")}% · {s(device, "category")}</small>
|
||||
</>
|
||||
)}
|
||||
<footer>最后心跳 {formatTime(device.last_seen)}</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage({ data, role, action, openThreshold }: { data: ConsoleData; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openThreshold: () => void }) {
|
||||
return (
|
||||
<Card title="设备与业务告警" subtitle="满溢、设备离线、超时与称重偏差统一管理" tools={role === "admin" && <button className="ghost-button" onClick={openThreshold}>阈值设置</button>}>
|
||||
<div className="alert-list">
|
||||
{data.alerts.map((alert) => (
|
||||
<article key={s(alert, "id")} className={s(alert, "severity") === "严重" ? "critical" : ""}>
|
||||
<div className="alert-sign">!</div>
|
||||
<div>
|
||||
<div className="alert-title"><h3>{s(alert, "type")}</h3><Status value={s(alert, "status")} /></div>
|
||||
<p>{s(alert, "description")}</p>
|
||||
<small>{formatTime(alert.created_at, true)} · {s(alert, "source")} · {s(alert, "reference")}</small>
|
||||
</div>
|
||||
{role === "admin" && s(alert, "status") !== "已关闭" && (
|
||||
<div className="row-actions">
|
||||
<button onClick={() => action("alert.status", { id: alert.id, status: "处理中" }, "告警已接单")}>接单</button>
|
||||
<button onClick={() => action("alert.status", { id: alert.id, status: "已关闭" }, "告警已关闭")}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage({ data, tab, setTab, action, openWorkshop }: { data: ConsoleData; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openWorkshop: () => void }) {
|
||||
const tabs = [["accounts", "账号与权限"], ["workshops", "车间字典"], ["monitor", "设备监控"], ["mes", "MES 接口"], ["logs", "操作日志"]];
|
||||
return (
|
||||
<Card title="系统配置" subtitle="管理员专属">
|
||||
<div className="toolbar"><div className="tabs">{tabs.map(([key, label]) => <button key={key} className={tab === key ? "active" : ""} onClick={() => setTab(key)}>{label}</button>)}</div></div>
|
||||
{tab === "accounts" && <DataTable rows={[
|
||||
{ name: "王工", id: "A0001", department: "设备部", role: "管理员", permission: "全部功能与系统配置" },
|
||||
{ name: "张三", id: "P1001", department: "仓储部", role: "仓管员", permission: "废料库、人员设备只读" },
|
||||
{ name: "李四", id: "P1002", department: "生产部", role: "产线工人", permission: "导体追溯、人员设备只读" },
|
||||
]} columns={[
|
||||
{ key: "name", label: "姓名" }, { key: "id", label: "工号" }, { key: "department", label: "部门" },
|
||||
{ key: "role", label: "系统角色" }, { key: "permission", label: "权限说明" },
|
||||
]} />}
|
||||
{tab === "workshops" && (
|
||||
<>
|
||||
<div className="sub-toolbar"><span>车间主数据字典</span><button className="primary-button" onClick={openWorkshop}>新增车间</button></div>
|
||||
<DataTable rows={data.workshops} columns={[
|
||||
{ key: "code", label: "车间编码" }, { key: "name", label: "车间名称" }, { key: "location", label: "位置" }, { key: "note", label: "备注" },
|
||||
{ key: "people", label: "关联人员", render: (row) => `${data.people.filter((person) => s(person, "workshop") === s(row, "name")).length} 人` },
|
||||
{ key: "actions", label: "操作", render: (row) => <div className="row-actions"><button onClick={() => action("workshop.delete", { code: row.code }, "车间已删除")}>删除</button></div> },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
{tab === "monitor" && <DataTable rows={data.devices} columns={[
|
||||
{ key: "id", label: "设备编号" }, { key: "type", label: "设备类型" }, { key: "ip", label: "IP 地址" },
|
||||
{ key: "heartbeat_seconds", label: "心跳周期", render: (row) => `${s(row, "heartbeat_seconds")} 秒` },
|
||||
{ key: "last_seen", label: "最后心跳", render: (row) => formatTime(row.last_seen, true) },
|
||||
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
|
||||
]} />}
|
||||
{tab === "mes" && <MesSettings settings={data.settings} action={action} />}
|
||||
{tab === "logs" && <DataTable rows={data.logs} columns={[
|
||||
{ key: "created_at", label: "时间", render: (row) => formatTime(row.created_at, true) },
|
||||
{ key: "actor", label: "操作人" }, { key: "action", label: "动作" }, { key: "details", label: "详情" },
|
||||
{ key: "level", label: "级别", render: (row) => <Status value={s(row, "level")} /> },
|
||||
]} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MesSettings({ settings, action }: { settings: Record<string, string>; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
|
||||
const [url, setUrl] = useState(settings.mes_url ?? "");
|
||||
const [timeout, setTimeoutValue] = useState(settings.mes_timeout ?? "10");
|
||||
const [testing, setTesting] = useState(false);
|
||||
return (
|
||||
<div className="settings-form">
|
||||
<label><span>接口地址</span><input value={url} onChange={(event) => setUrl(event.target.value)} /></label>
|
||||
<label><span>推送超时</span><input type="number" value={timeout} onChange={(event) => setTimeoutValue(event.target.value)} /><small>秒</small></label>
|
||||
<label><span>重试策略</span><select defaultValue="3"><option value="3">自动重试 3 次 · 间隔 5 秒</option><option value="0">仅人工重试</option></select></label>
|
||||
<div className="form-actions">
|
||||
<button className="ghost-button" onClick={() => { setTesting(true); window.setTimeout(() => setTesting(false), 900); }}>{testing ? "连接中…" : "测试连接"}</button>
|
||||
<button className="primary-button" onClick={() => action("settings.save", { mes_url: url, mes_timeout: timeout }, "MES 配置已保存")}>保存配置</button>
|
||||
{!testing && <Status value="连接正常" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Simulator({ step, setStep }: { step: number; setStep: (value: number) => void }) {
|
||||
const wasteSteps = [
|
||||
["身份验证", "请将面部对准摄像头,也可输入工号", "模拟识别成功 · 张三"],
|
||||
["门锁已开启", "请进入投放,最长停留 05:00", "模拟投放完成并关门"],
|
||||
["投放完成", "台秤 38.50kg · 地磅 39.00kg · 偏差 1.3%", "重新开始"],
|
||||
];
|
||||
const cableSteps = [
|
||||
["导体轴称重", "RFID 全程关联生产任务与批次", "开始一次称重"],
|
||||
["一次称重完成", "RFID E280-6102-0345 · 皮重 152.30kg", "模拟二次称重"],
|
||||
["MES 上报成功", "净重 115.80kg · 条码已打印", "重新开始"],
|
||||
];
|
||||
return (
|
||||
<div className="simulator-grid">
|
||||
{[["废料库入库终端", wasteSteps, "orange"], ["导体称重终端", cableSteps, "blue"]].map(([name, steps, color], index) => {
|
||||
const items = steps as string[][];
|
||||
const current = index === 0 ? step % 3 : Math.floor(step / 3) % 3;
|
||||
return (
|
||||
<Card title={String(name)} subtitle="点击屏幕推进业务流程" className="sim-card" key={String(name)}>
|
||||
<button className={`terminal ${color}`} onClick={() => setStep(index === 0 ? (current + 1) % 3 : ((current + 1) % 3) * 3)}>
|
||||
<span className="terminal-camera" />
|
||||
<small>LOCAL TERMINAL · ONLINE</small>
|
||||
<div className="terminal-icon">{current === 0 ? "◉" : current === 1 ? "⇄" : "✓"}</div>
|
||||
<h3>{items[current][0]}</h3>
|
||||
<p>{items[current][1]}</p>
|
||||
<strong>{items[current][2]} →</strong>
|
||||
<div className="step-dots">{items.map((_, itemIndex) => <i className={itemIndex === current ? "active" : ""} key={itemIndex} />)}</div>
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({ type, settings, onClose, onSubmit }: { type: "workshop" | "tag" | "threshold"; settings: Record<string, string>; onClose: () => void; onSubmit: (values: Record<string, string>) => Promise<void> }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
const values = Object.fromEntries(new FormData(event.currentTarget).entries()) as Record<string, string>;
|
||||
await onSubmit(values);
|
||||
setSaving(false);
|
||||
}
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
|
||||
<form className="modal" onSubmit={submit}>
|
||||
<header><h2>{type === "workshop" ? "新增车间" : type === "tag" ? "添加 RFID 标签" : "告警阈值设置"}</h2><button type="button" onClick={onClose}>×</button></header>
|
||||
<div className="modal-body">
|
||||
{type === "workshop" && <>
|
||||
<label><span>车间名称</span><input name="name" required placeholder="例如:三楼精密车间" /></label>
|
||||
<label><span>位置</span><input name="location" placeholder="例如:3F · 北侧" /></label>
|
||||
<label><span>备注</span><input name="note" placeholder="选填" /></label>
|
||||
</>}
|
||||
{type === "tag" && <label><span>标签名称</span><input name="name" required placeholder="例如:8# 导体轴标签" /></label>}
|
||||
{type === "threshold" && <>
|
||||
<label><span>偏差警告阈值 (%)</span><input name="deviation_warn" type="number" defaultValue={settings.deviation_warn} /></label>
|
||||
<label><span>偏差严重阈值 (%)</span><input name="deviation_critical" type="number" defaultValue={settings.deviation_critical} /></label>
|
||||
<label><span>满溢预警阈值 (%)</span><input name="fill_warn" type="number" defaultValue={settings.fill_warn} /></label>
|
||||
<label><span>满溢严重阈值 (%)</span><input name="fill_critical" type="number" defaultValue={settings.fill_critical} /></label>
|
||||
</>}
|
||||
</div>
|
||||
<footer><button type="button" className="ghost-button" onClick={onClose}>取消</button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "确认保存"}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminConsole } from "./AdminConsole";
|
||||
|
||||
export default function Home() {
|
||||
return <AdminConsole />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export function getDb() {
|
||||
if (!env.DB) {
|
||||
throw new Error(
|
||||
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
|
||||
);
|
||||
}
|
||||
|
||||
return drizzle(env.DB, { schema });
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const people = sqliteTable("people", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
workshop: text("workshop").notNull(),
|
||||
role: text("role").notNull(),
|
||||
faceStatus: text("face_status").notNull(),
|
||||
faceVersion: integer("face_version").notNull().default(1),
|
||||
syncStatus: text("sync_status").notNull(),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const workshops = sqliteTable("workshops", {
|
||||
code: text("code").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
location: text("location").notNull(),
|
||||
note: text("note").notNull(),
|
||||
});
|
||||
|
||||
export const devices = sqliteTable("devices", {
|
||||
id: text("id").primaryKey(),
|
||||
type: text("type").notNull(),
|
||||
ip: text("ip").notNull(),
|
||||
status: text("status").notNull(),
|
||||
lastSeen: integer("last_seen").notNull(),
|
||||
heartbeatSeconds: integer("heartbeat_seconds").notNull(),
|
||||
fillPercent: integer("fill_percent").notNull().default(0),
|
||||
category: text("category").notNull().default(""),
|
||||
});
|
||||
|
||||
export const weighings = sqliteTable("weighings", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
operator: text("operator").notNull(),
|
||||
machine: text("machine").notNull(),
|
||||
category: text("category").notNull(),
|
||||
source: text("source").notNull(),
|
||||
weight: real("weight").notNull(),
|
||||
eventId: text("event_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
});
|
||||
|
||||
export const events = sqliteTable("events", {
|
||||
id: text("id").primaryKey(),
|
||||
operator: text("operator").notNull(),
|
||||
enteredAt: integer("entered_at").notNull(),
|
||||
leftAt: integer("left_at").notNull(),
|
||||
atomicCount: integer("atomic_count").notNull(),
|
||||
scaleTotal: real("scale_total").notNull(),
|
||||
verdict: text("verdict").notNull(),
|
||||
});
|
||||
|
||||
export const reconciliations = sqliteTable("reconciliations", {
|
||||
id: text("id").primaryKey(),
|
||||
eventId: text("event_id").notNull(),
|
||||
category: text("category").notNull(),
|
||||
smallScale: real("small_scale").notNull(),
|
||||
floorScale: real("floor_scale").notNull(),
|
||||
difference: real("difference").notNull(),
|
||||
differenceRate: real("difference_rate").notNull(),
|
||||
verdict: text("verdict").notNull(),
|
||||
storageStatus: text("storage_status").notNull(),
|
||||
});
|
||||
|
||||
export const traces = sqliteTable("traces", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
workshop: text("workshop").notNull(),
|
||||
taskId: text("task_id").notNull(),
|
||||
rfid: text("rfid").notNull(),
|
||||
batch: text("batch").notNull(),
|
||||
tare: real("tare").notNull(),
|
||||
gross: real("gross").notNull(),
|
||||
net: real("net").notNull(),
|
||||
machine: text("machine").notNull(),
|
||||
operator: text("operator").notNull(),
|
||||
mesStatus: text("mes_status").notNull(),
|
||||
});
|
||||
|
||||
export const rfidTags = sqliteTable("rfid_tags", {
|
||||
id: text("id").primaryKey(),
|
||||
status: text("status").notNull(),
|
||||
name: text("name").notNull(),
|
||||
lastChecked: integer("last_checked"),
|
||||
});
|
||||
|
||||
export const alerts = sqliteTable("alerts", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
type: text("type").notNull(),
|
||||
severity: text("severity").notNull(),
|
||||
source: text("source").notNull(),
|
||||
reference: text("reference").notNull(),
|
||||
description: text("description").notNull(),
|
||||
status: text("status").notNull(),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const auditLogs = sqliteTable("audit_logs", {
|
||||
id: text("id").primaryKey(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
actor: text("actor").notNull(),
|
||||
action: text("action").notNull(),
|
||||
details: text("details").notNull(),
|
||||
level: text("level").notNull(),
|
||||
});
|
||||
|
||||
export const deliveries = sqliteTable("deliveries", {
|
||||
recordId: text("record_id").primaryKey(),
|
||||
deviceId: text("device_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
employeeName: text("employee_name").notNull(),
|
||||
employeeNo: text("employee_no").notNull(),
|
||||
machineNo: text("machine_no").notNull(),
|
||||
productionBatch: text("production_batch").notNull(),
|
||||
garbageType: integer("garbage_type").notNull(),
|
||||
weight: real("weight").notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
status: text("status").notNull(),
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
const schemaStatements = [
|
||||
`CREATE TABLE IF NOT EXISTS people (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, workshop TEXT NOT NULL, role TEXT NOT NULL,
|
||||
face_status TEXT NOT NULL, face_version INTEGER NOT NULL DEFAULT 1,
|
||||
sync_status TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS workshops (
|
||||
code TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, location TEXT NOT NULL, note TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, ip TEXT NOT NULL, status TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL, heartbeat_seconds INTEGER NOT NULL,
|
||||
fill_percent INTEGER NOT NULL DEFAULT 0, category TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS weighings (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, operator TEXT NOT NULL,
|
||||
machine TEXT NOT NULL, category TEXT NOT NULL, source TEXT NOT NULL,
|
||||
weight REAL NOT NULL, event_id TEXT NOT NULL, status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY, operator TEXT NOT NULL, entered_at INTEGER NOT NULL,
|
||||
left_at INTEGER NOT NULL, atomic_count INTEGER NOT NULL,
|
||||
scale_total REAL NOT NULL, verdict TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS reconciliations (
|
||||
id TEXT PRIMARY KEY, event_id TEXT NOT NULL, category TEXT NOT NULL,
|
||||
small_scale REAL NOT NULL, floor_scale REAL NOT NULL, difference REAL NOT NULL,
|
||||
difference_rate REAL NOT NULL, verdict TEXT NOT NULL, storage_status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS traces (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, workshop TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL, rfid TEXT NOT NULL, batch TEXT NOT NULL,
|
||||
tare REAL NOT NULL, gross REAL NOT NULL, net REAL NOT NULL,
|
||||
machine TEXT NOT NULL, operator TEXT NOT NULL, mes_status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS rfid_tags (
|
||||
id TEXT PRIMARY KEY, status TEXT NOT NULL, name TEXT NOT NULL, last_checked INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, type TEXT NOT NULL,
|
||||
severity TEXT NOT NULL, source TEXT NOT NULL, reference TEXT NOT NULL,
|
||||
description TEXT NOT NULL, status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL, details TEXT NOT NULL, level TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS deliveries (
|
||||
record_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, created_at INTEGER NOT NULL,
|
||||
employee_name TEXT NOT NULL, employee_no TEXT NOT NULL, machine_no TEXT NOT NULL,
|
||||
production_batch TEXT NOT NULL, garbage_type INTEGER NOT NULL, weight REAL NOT NULL,
|
||||
payload TEXT NOT NULL, status TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS weighings_created_at_idx ON weighings(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS alerts_status_idx ON alerts(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS people_updated_at_idx ON people(updated_at)`,
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
const minute = 60_000;
|
||||
|
||||
const seedStatements: Array<[string, unknown[]]> = [
|
||||
["INSERT OR IGNORE INTO workshops(code,name,location,note) VALUES(?,?,?,?)", ["WS-01", "一楼新能源车间", "1F · 东侧", "废料回收主区域"]],
|
||||
["INSERT OR IGNORE INTO workshops(code,name,location,note) VALUES(?,?,?,?)", ["WS-02", "二楼导体车间", "2F · 南侧", "导体称重与追溯"]],
|
||||
["INSERT OR IGNORE INTO workshops(code,name,location,note) VALUES(?,?,?,?)", ["WS-03", "仓储中心", "1F · 西侧", "地磅与成品暂存"]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1001", "张三", "一楼新能源车间", "仓管员", "已录入", 3, "已同步", 1, now - 3 * minute]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1002", "李四", "二楼导体车间", "产线工人", "已录入", 2, "已同步", 1, now - 8 * minute]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1003", "赵六", "一楼新能源车间", "仓管员", "需更新", 4, "同步失败", 1, now - 12 * minute]],
|
||||
["INSERT OR IGNORE INTO people(id,name,workshop,role,face_status,face_version,sync_status,active,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ["P1004", "王工", "仓储中心", "管理员", "已录入", 1, "已同步", 1, now - 20 * minute]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["WKS-01", "入库触摸屏", "192.168.10.11", "在线", now - 2_000, 30, 0, "终端"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["SB-01", "小秤台", "192.168.10.12", "在线", now - 3_000, 30, 0, "称重"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["IB-01", "智能回收箱 A", "192.168.10.13", "在线", now - 5_000, 30, 72, "废铜"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["IB-02", "智能回收箱 B", "192.168.10.14", "在线", now - 4_000, 30, 46, "废电线"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["FS-01", "3 吨地磅", "192.168.10.15", "在线", now - 1_000, 30, 0, "称重"]],
|
||||
["INSERT OR IGNORE INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,?,?,?,?,?)", ["RFID-01", "RFID 读写器", "192.168.10.16", "在线", now - 6_000, 30, 0, "追溯"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0001", now - 26 * minute, "张三", "MC-101", "废铜", "SB-01", 12.48, "EV-0728-001", "已确认"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0002", now - 24 * minute, "张三", "MC-101", "废铜", "SB-01", 9.82, "EV-0728-001", "已确认"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0003", now - 18 * minute, "赵六", "MC-108", "废电线", "IB-02", 14.50, "EV-0728-002", "偏差超阈"]],
|
||||
["INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)", ["W-0004", now - 9 * minute, "李四", "MC-105", "废锡", "IB-01", 19.50, "EV-0728-003", "已确认"]],
|
||||
["INSERT OR IGNORE INTO events(id,operator,entered_at,left_at,atomic_count,scale_total,verdict) VALUES(?,?,?,?,?,?,?)", ["EV-0728-001", "张三", now - 30 * minute, now - 22 * minute, 2, 22.30, "正常"]],
|
||||
["INSERT OR IGNORE INTO events(id,operator,entered_at,left_at,atomic_count,scale_total,verdict) VALUES(?,?,?,?,?,?,?)", ["EV-0728-002", "赵六", now - 21 * minute, now - 14 * minute, 1, 14.50, "超标"]],
|
||||
["INSERT OR IGNORE INTO events(id,operator,entered_at,left_at,atomic_count,scale_total,verdict) VALUES(?,?,?,?,?,?,?)", ["EV-0728-003", "李四", now - 12 * minute, now - 6 * minute, 1, 19.50, "警告"]],
|
||||
["INSERT OR IGNORE INTO reconciliations(id,event_id,category,small_scale,floor_scale,difference,difference_rate,verdict,storage_status) VALUES(?,?,?,?,?,?,?,?,?)", ["RC-001", "EV-0728-001", "废铜", 22.30, 22.60, 0.30, 1.35, "正常", "已自动入库"]],
|
||||
["INSERT OR IGNORE INTO reconciliations(id,event_id,category,small_scale,floor_scale,difference,difference_rate,verdict,storage_status) VALUES(?,?,?,?,?,?,?,?,?)", ["RC-002", "EV-0728-002", "废电线", 14.50, 15.40, 0.90, 6.21, "超标", "已入库 · 异常"]],
|
||||
["INSERT OR IGNORE INTO reconciliations(id,event_id,category,small_scale,floor_scale,difference,difference_rate,verdict,storage_status) VALUES(?,?,?,?,?,?,?,?,?)", ["RC-003", "EV-0728-003", "废锡", 19.50, 20.10, 0.60, 3.08, "警告", "已入库 · 预警"]],
|
||||
["INSERT OR IGNORE INTO traces(id,created_at,workshop,task_id,rfid,batch,tare,gross,net,machine,operator,mes_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", ["TR-001", now - 48 * minute, "二楼导体车间", "WT-260728-03", "E280-6102-0345", "B2607-018", 152.30, 268.10, 115.80, "MC-201", "李四", "已推送"]],
|
||||
["INSERT OR IGNORE INTO traces(id,created_at,workshop,task_id,rfid,batch,tare,gross,net,machine,operator,mes_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", ["TR-002", now - 90 * minute, "二楼导体车间", "WT-260728-02", "E280-6102-0288", "B2607-017", 148.20, 252.70, 104.50, "MC-203", "周七", "待推送"]],
|
||||
["INSERT OR IGNORE INTO rfid_tags(id,status,name,last_checked) VALUES(?,?,?,?)", ["E280-6102-0345", "在用", "8# 导体轴标签", now - 48 * minute]],
|
||||
["INSERT OR IGNORE INTO rfid_tags(id,status,name,last_checked) VALUES(?,?,?,?)", ["E280-6102-0288", "在用", "7# 导体轴标签", now - 90 * minute]],
|
||||
["INSERT OR IGNORE INTO rfid_tags(id,status,name,last_checked) VALUES(?,?,?,?)", ["E280-6102-0411", "未用", "备用标签", null]],
|
||||
["INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,?)", ["AL-001", now - 16 * minute, "满溢预警", "警告", "废料库", "IB-01", "满溢率达到 72%,建议安排清运", "待处理"]],
|
||||
["INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,?)", ["AL-002", now - 42 * minute, "双秤偏差", "严重", "地磅核对", "EV-0728-002", "地磅增量与台秤汇总偏差 6.21%", "处理中"]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["mes_url", "http://mes.local/api/v2", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["mes_timeout", "10", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["deviation_warn", "2", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["deviation_critical", "5", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["fill_warn", "70", now]],
|
||||
["INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)", ["fill_critical", "90", now]],
|
||||
["INSERT OR IGNORE INTO audit_logs(id,created_at,actor,action,details,level) VALUES(?,?,?,?,?,?)", ["LOG-001", now - 15 * minute, "系统", "设备心跳", "WKS-01 状态恢复为在线", "信息"]],
|
||||
["INSERT OR IGNORE INTO audit_logs(id,created_at,actor,action,details,level) VALUES(?,?,?,?,?,?)", ["LOG-002", now - 38 * minute, "王工", "处理告警", "AL-002 标记为处理中", "重要"]],
|
||||
];
|
||||
|
||||
export function getD1(): D1Database {
|
||||
if (!env.DB) {
|
||||
throw new Error("D1 数据库未绑定,请将 .openai/hosting.json 的 d1 设置为 DB。");
|
||||
}
|
||||
return env.DB;
|
||||
}
|
||||
|
||||
export async function ensureDatabase() {
|
||||
const db = getD1();
|
||||
await db.batch(schemaStatements.map((statement) => db.prepare(statement)));
|
||||
await db.batch(
|
||||
seedStatements.map(([statement, bindings]) =>
|
||||
db.prepare(statement).bind(...bindings),
|
||||
),
|
||||
);
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function addAudit(
|
||||
actor: string,
|
||||
action: string,
|
||||
details: string,
|
||||
level = "信息",
|
||||
) {
|
||||
const db = getD1();
|
||||
await db
|
||||
.prepare(
|
||||
"INSERT INTO audit_logs(id,created_at,actor,action,details,level) VALUES(?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(crypto.randomUUID(), Date.now(), actor, action, details, level)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function loadConsoleData() {
|
||||
const db = await ensureDatabase();
|
||||
const [
|
||||
peopleResult,
|
||||
workshopResult,
|
||||
deviceResult,
|
||||
weighingResult,
|
||||
eventResult,
|
||||
reconciliationResult,
|
||||
traceResult,
|
||||
tagResult,
|
||||
alertResult,
|
||||
settingResult,
|
||||
logResult,
|
||||
] = await Promise.all([
|
||||
db.prepare("SELECT * FROM people ORDER BY active DESC, name").all(),
|
||||
db.prepare("SELECT * FROM workshops ORDER BY code").all(),
|
||||
db.prepare("SELECT * FROM devices ORDER BY id").all(),
|
||||
db.prepare("SELECT * FROM weighings ORDER BY created_at DESC LIMIT 200").all(),
|
||||
db.prepare("SELECT * FROM events ORDER BY entered_at DESC LIMIT 100").all(),
|
||||
db.prepare("SELECT * FROM reconciliations ORDER BY event_id DESC LIMIT 100").all(),
|
||||
db.prepare("SELECT * FROM traces ORDER BY created_at DESC LIMIT 200").all(),
|
||||
db.prepare("SELECT * FROM rfid_tags ORDER BY status, id").all(),
|
||||
db.prepare("SELECT * FROM alerts ORDER BY created_at DESC LIMIT 200").all(),
|
||||
db.prepare("SELECT * FROM settings ORDER BY key").all(),
|
||||
db.prepare("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 200").all(),
|
||||
]);
|
||||
return {
|
||||
people: peopleResult.results,
|
||||
workshops: workshopResult.results,
|
||||
devices: deviceResult.results,
|
||||
weighings: weighingResult.results,
|
||||
events: eventResult.results,
|
||||
reconciliations: reconciliationResult.results,
|
||||
traces: traceResult.results,
|
||||
tags: tagResult.results,
|
||||
alerts: alertResult.results,
|
||||
settings: Object.fromEntries(
|
||||
settingResult.results.map((row) => [String(row.key), String(row.value)]),
|
||||
),
|
||||
logs: logResult.results,
|
||||
serverTime: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
management-console:
|
||||
build: .
|
||||
container_name: smart-recycling-console
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- console-data:/app/.wrangler/state
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
volumes:
|
||||
console-data:
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./db/schema.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
CREATE TABLE `alerts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`severity` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`reference` text NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `audit_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`actor` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`details` text NOT NULL,
|
||||
`level` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `deliveries` (
|
||||
`record_id` text PRIMARY KEY NOT NULL,
|
||||
`device_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`employee_name` text NOT NULL,
|
||||
`employee_no` text NOT NULL,
|
||||
`machine_no` text NOT NULL,
|
||||
`production_batch` text NOT NULL,
|
||||
`garbage_type` integer NOT NULL,
|
||||
`weight` real NOT NULL,
|
||||
`payload` text NOT NULL,
|
||||
`status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `devices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`ip` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`last_seen` integer NOT NULL,
|
||||
`heartbeat_seconds` integer NOT NULL,
|
||||
`fill_percent` integer DEFAULT 0 NOT NULL,
|
||||
`category` text DEFAULT '' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`operator` text NOT NULL,
|
||||
`entered_at` integer NOT NULL,
|
||||
`left_at` integer NOT NULL,
|
||||
`atomic_count` integer NOT NULL,
|
||||
`scale_total` real NOT NULL,
|
||||
`verdict` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `people` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`workshop` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`face_status` text NOT NULL,
|
||||
`face_version` integer DEFAULT 1 NOT NULL,
|
||||
`sync_status` text NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `reconciliations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`event_id` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`small_scale` real NOT NULL,
|
||||
`floor_scale` real NOT NULL,
|
||||
`difference` real NOT NULL,
|
||||
`difference_rate` real NOT NULL,
|
||||
`verdict` text NOT NULL,
|
||||
`storage_status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `rfid_tags` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`last_checked` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `traces` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`workshop` text NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`rfid` text NOT NULL,
|
||||
`batch` text NOT NULL,
|
||||
`tare` real NOT NULL,
|
||||
`gross` real NOT NULL,
|
||||
`net` real NOT NULL,
|
||||
`machine` text NOT NULL,
|
||||
`operator` text NOT NULL,
|
||||
`mes_status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `weighings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`operator` text NOT NULL,
|
||||
`machine` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`weight` real NOT NULL,
|
||||
`event_id` text NOT NULL,
|
||||
`status` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `workshops` (
|
||||
`code` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`location` text NOT NULL,
|
||||
`note` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `workshops_name_unique` ON `workshops` (`name`);
|
||||
@@ -0,0 +1,780 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "086a5b90-1490-42d6-a241-39b8df513cd8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"alerts": {
|
||||
"name": "alerts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"severity": {
|
||||
"name": "severity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"audit_logs": {
|
||||
"name": "audit_logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"actor": {
|
||||
"name": "actor",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"deliveries": {
|
||||
"name": "deliveries",
|
||||
"columns": {
|
||||
"record_id": {
|
||||
"name": "record_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"employee_name": {
|
||||
"name": "employee_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"employee_no": {
|
||||
"name": "employee_no",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"machine_no": {
|
||||
"name": "machine_no",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"production_batch": {
|
||||
"name": "production_batch",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"garbage_type": {
|
||||
"name": "garbage_type",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"devices": {
|
||||
"name": "devices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"heartbeat_seconds": {
|
||||
"name": "heartbeat_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fill_percent": {
|
||||
"name": "fill_percent",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"events": {
|
||||
"name": "events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator": {
|
||||
"name": "operator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entered_at": {
|
||||
"name": "entered_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"left_at": {
|
||||
"name": "left_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"atomic_count": {
|
||||
"name": "atomic_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scale_total": {
|
||||
"name": "scale_total",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verdict": {
|
||||
"name": "verdict",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"people": {
|
||||
"name": "people",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workshop": {
|
||||
"name": "workshop",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"face_status": {
|
||||
"name": "face_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"face_version": {
|
||||
"name": "face_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"sync_status": {
|
||||
"name": "sync_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"reconciliations": {
|
||||
"name": "reconciliations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_scale": {
|
||||
"name": "small_scale",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"floor_scale": {
|
||||
"name": "floor_scale",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"difference": {
|
||||
"name": "difference",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"difference_rate": {
|
||||
"name": "difference_rate",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verdict": {
|
||||
"name": "verdict",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"storage_status": {
|
||||
"name": "storage_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rfid_tags": {
|
||||
"name": "rfid_tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"traces": {
|
||||
"name": "traces",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"workshop": {
|
||||
"name": "workshop",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rfid": {
|
||||
"name": "rfid",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"batch": {
|
||||
"name": "batch",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tare": {
|
||||
"name": "tare",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"gross": {
|
||||
"name": "gross",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"net": {
|
||||
"name": "net",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"machine": {
|
||||
"name": "machine",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator": {
|
||||
"name": "operator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mes_status": {
|
||||
"name": "mes_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"weighings": {
|
||||
"name": "weighings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator": {
|
||||
"name": "operator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"machine": {
|
||||
"name": "machine",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"workshops": {
|
||||
"name": "workshops",
|
||||
"columns": {
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"workshops_name_unique": {
|
||||
"name": "workshops_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1785201529692,
|
||||
"tag": "0000_colorful_carmella_unuscione",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import { getDb } from "../../../../../db";
|
||||
import { notes } from "../../../db/schema";
|
||||
|
||||
function toRouteErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
const detail =
|
||||
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
|
||||
const combined = `${message}\n${detail}`;
|
||||
|
||||
if (combined.includes("no such table") || combined.includes('from "notes"')) {
|
||||
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(desc(notes.createdAt), desc(notes.id))
|
||||
.limit(20);
|
||||
|
||||
return Response.json({ notes: rows });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
const title = payload.title?.trim() ?? "";
|
||||
const content = payload.content?.trim() ?? "";
|
||||
|
||||
if (!title) {
|
||||
return Response.json({ error: "title is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [note] = await db.insert(notes).values({ title, content }).returning();
|
||||
return Response.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const notes = sqliteTable("notes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+11169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "smart-recycling-operations-console",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
||||
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
||||
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log wrangler dev --config dist/server/wrangler.json --ip 0.0.0.0 --port 3000",
|
||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "0.45.2",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitejs/plugin-rsc": "0.5.26",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"react-server-dom-webpack": "19.2.6",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"vinext": "0.0.50",
|
||||
"vite": "8.0.13",
|
||||
"wrangler": "4.92.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("builds the finished operations console assets", async () => {
|
||||
const [page, layout, clientFiles, hosting] = await Promise.all([
|
||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||
readdir(new URL("../dist/client/assets/", import.meta.url)),
|
||||
readFile(new URL("../dist/.openai/hosting.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(page, /AdminConsole/);
|
||||
assert.match(layout, /智能回收运营平台/);
|
||||
assert.match(layout, /og\.png/);
|
||||
assert.ok(clientFiles.some((file) => file.startsWith("AdminConsole-") && file.endsWith(".js")));
|
||||
assert.equal(JSON.parse(hosting).d1, "DB");
|
||||
await access(new URL("../dist/client/og.png", import.meta.url));
|
||||
});
|
||||
|
||||
test("removes starter preview and includes persistent API routes", async () => {
|
||||
const [client, consoleApi, deviceApi, packageJson, readme] = await Promise.all([
|
||||
readFile(new URL("../app/AdminConsole.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/console/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/api/v1/[...path]/route.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../README.md", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(client, /废料称重/);
|
||||
assert.match(client, /地磅核对/);
|
||||
assert.match(client, /导体追溯/);
|
||||
assert.match(client, /人员设备/);
|
||||
assert.match(client, /终端模拟/);
|
||||
assert.match(consoleApi, /person\.face/);
|
||||
assert.match(consoleApi, /alert\.status/);
|
||||
assert.match(deviceApi, /heartbeat/);
|
||||
assert.match(deviceApi, /deliveries/);
|
||||
assert.match(deviceApi, /people/);
|
||||
assert.doesNotMatch(packageJson, /react-loading-skeleton/);
|
||||
assert.match(readme, /Docker Compose/);
|
||||
|
||||
await assert.rejects(access(new URL("../app/_sites-preview", import.meta.url)));
|
||||
await access(new URL("../drizzle/", import.meta.url));
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import vinext from "vinext";
|
||||
import { defineConfig } from "vite";
|
||||
import hostingConfig from "./.openai/hosting.json";
|
||||
import { sites } from "./build/sites-vite-plugin";
|
||||
|
||||
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
||||
"00000000-0000-4000-8000-000000000000";
|
||||
|
||||
const { d1, r2 } = hostingConfig;
|
||||
|
||||
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||
|
||||
const localBindingConfig = {
|
||||
main: "./worker/index.ts",
|
||||
compatibility_flags: ["nodejs_compat"],
|
||||
d1_databases: d1
|
||||
? [
|
||||
{
|
||||
binding: d1,
|
||||
database_name: "site-creator-d1",
|
||||
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
r2_buckets: r2
|
||||
? [
|
||||
{
|
||||
binding: r2,
|
||||
bucket_name: "site-creator-r2",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
export default defineConfig(async () => {
|
||||
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
|
||||
// settings; application environment belongs in ignored `.env*` files.
|
||||
process.env.WRANGLER_WRITE_LOGS ??= "false";
|
||||
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
|
||||
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
|
||||
|
||||
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||
|
||||
return {
|
||||
server: isCodexSeatbeltSandbox
|
||||
? { watch: { useFsEvents: false, usePolling: true } }
|
||||
: undefined,
|
||||
plugins: [
|
||||
vinext(),
|
||||
sites(),
|
||||
cloudflare({
|
||||
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|
||||
config: localBindingConfig,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Cloudflare Worker entry point for the vinext-starter template. */
|
||||
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||
import handler from "vinext/server/app-router-entry";
|
||||
|
||||
interface Env {
|
||||
ASSETS: Fetcher;
|
||||
DB: D1Database;
|
||||
IMAGES: {
|
||||
input(stream: ReadableStream): {
|
||||
transform(options: Record<string, unknown>): {
|
||||
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
// Image security config. SVG sources with .svg extension auto-skip the
|
||||
// optimization endpoint on the client side (served directly, no proxy).
|
||||
// To route SVGs through the optimizer (with security headers), set
|
||||
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
|
||||
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
|
||||
|
||||
const worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/_vinext/image") {
|
||||
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||
return handleImageOptimization(request, {
|
||||
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||
transformImage: async (body, { width, format, quality }) => {
|
||||
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||
return result.response();
|
||||
},
|
||||
}, allowedWidths);
|
||||
}
|
||||
|
||||
return handler.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export default worker;
|
||||
Reference in New Issue
Block a user