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

This commit is contained in:
FinleyHsu
2026-07-28 10:48:03 +08:00
commit 32a31f0909
112 changed files with 28469 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
dist
.wrangler
.git
npm-debug.log
+42
View File
@@ -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/
+4
View File
@@ -0,0 +1,4 @@
{
"d1": "DB",
"r2": null
}
+13
View File
@@ -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"]
+55
View File
@@ -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}
```
当前版本用于本地联调。正式投产前还需要补充设备密钥、签名校验、管理员登录和自动备份。
+820
View File
@@ -0,0 +1,820 @@
"use client";
import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react";
type Row = Record<string, string | number | null>;
type ConsoleData = {
people: Row[];
workshops: Row[];
devices: Row[];
weighings: Row[];
events: Row[];
reconciliations: Row[];
traces: Row[];
tags: Row[];
alerts: Row[];
settings: Record<string, string>;
logs: Row[];
serverTime: number;
};
type PageKey =
| "dashboard"
| "weighings"
| "events"
| "reconcile"
| "trace"
| "assets"
| "alerts"
| "settings"
| "simulator";
type RoleKey = "warehouse" | "worker" | "admin";
const pageMeta: Record<PageKey, { name: string; en: string; icon: string }> = {
dashboard: { name: "工作台", en: "Dashboard", icon: "◫" },
weighings: { name: "废料称重", en: "Weighing records", icon: "▦" },
events: { name: "事件列表", en: "Events", icon: "◎" },
reconcile: { name: "地磅核对", en: "Reconciliation", icon: "⚖" },
trace: { name: "导体追溯", en: "Cable trace", icon: "⌁" },
assets: { name: "人员设备", en: "People & devices", icon: "♙" },
alerts: { name: "告警中心", en: "Alerts", icon: "!" },
settings: { name: "系统设置", en: "Settings", icon: "⌘" },
simulator: { name: "终端模拟", en: "Terminal simulator", icon: "▣" },
};
const roleMeta: Record<RoleKey, { name: string; person: string; pages: PageKey[] }> = {
warehouse: {
name: "仓管员",
person: "张三",
pages: ["dashboard", "weighings", "events", "reconcile", "assets", "simulator"],
},
worker: {
name: "产线工人",
person: "李四",
pages: ["dashboard", "trace", "assets", "simulator"],
},
admin: {
name: "管理员",
person: "王工",
pages: Object.keys(pageMeta) as PageKey[],
},
};
const navGroups: Array<{ title: string; pages: PageKey[] }> = [
{ title: "总览", pages: ["dashboard"] },
{ title: "废料库回收", pages: ["weighings", "events", "reconcile"] },
{ title: "导体称重", pages: ["trace"] },
{ title: "运营管理", pages: ["assets", "alerts"] },
{ title: "系统", pages: ["settings"] },
{ title: "演示工具", pages: ["simulator"] },
];
const n = (row: Row, key: string) => Number(row[key] ?? 0);
const s = (row: Row, key: string) => String(row[key] ?? "");
function formatTime(value: string | number | null, includeDate = false) {
const date = new Date(Number(value ?? 0));
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
...(includeDate ? { month: "2-digit", day: "2-digit" } : {}),
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
function Status({ value }: { value: string }) {
const level =
/超标|严重|失败|离线|停用|损坏|异常/.test(value)
? "bad"
: /警告|预警|处理中|待推送|需更新|未用/.test(value)
? "warn"
: /在线|正常|成功|已同步|已确认|已录入|在用|已自动|已推送/.test(value)
? "ok"
: "muted";
return <span className={`status ${level}`}>{value}</span>;
}
function Card({
title,
subtitle,
tools,
children,
className = "",
}: {
title?: string;
subtitle?: string;
tools?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={`card ${className}`}>
{(title || tools) && (
<div className="card-head">
<div>
{title && <h3>{title}</h3>}
{subtitle && <p>{subtitle}</p>}
</div>
{tools && <div className="card-tools">{tools}</div>}
</div>
)}
{children}
</section>
);
}
function DataTable({
columns,
rows,
}: {
columns: Array<{ key: string; label: string; render?: (row: Row) => ReactNode }>;
rows: Row[];
}) {
return (
<div className="table-wrap">
<table>
<thead>
<tr>{columns.map((column) => <th key={column.key}>{column.label}</th>)}</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={columns.length} className="empty"></td></tr>
) : rows.map((row, index) => (
<tr key={String(row.id ?? row.record_id ?? row.code ?? index)}>
{columns.map((column) => (
<td key={column.key}>{column.render ? column.render(row) : String(row[column.key] ?? "—")}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function SearchBox({
value,
onChange,
placeholder = "搜索…",
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<label className="search-box">
<span></span>
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
</label>
);
}
function exportCsv(filename: string, rows: Row[]) {
if (!rows.length) return;
const headers = Object.keys(rows[0]);
const escape = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const csv = "\ufeff" + [headers.map(escape).join(","), ...rows.map((row) => headers.map((key) => escape(row[key])).join(","))].join("\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function AdminConsole() {
const [data, setData] = useState<ConsoleData | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [page, setPage] = useState<PageKey>("dashboard");
const [role, setRole] = useState<RoleKey>("admin");
const [query, setQuery] = useState("");
const [tab, setTab] = useState("records");
const [toast, setToast] = useState("");
const [clock, setClock] = useState(new Date());
const [modal, setModal] = useState<"workshop" | "tag" | "threshold" | null>(null);
const [simStep, setSimStep] = useState(0);
async function load() {
setLoading(true);
try {
const response = await fetch("/api/console", { cache: "no-store" });
const payload = await response.json() as { code: number; message?: string; data?: ConsoleData };
if (!response.ok || !payload.data) throw new Error(payload.message || "数据加载失败");
setData(payload.data);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "数据加载失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
const timer = window.setInterval(() => setClock(new Date()), 1_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
setQuery("");
if (page === "trace") setTab("trace");
else if (page === "assets") setTab("people");
else if (page === "settings") setTab("accounts");
else if (page === "weighings") setTab("records");
}, [page]);
async function action(name: string, payload: Record<string, unknown> = {}, message = "操作成功") {
try {
const response = await fetch("/api/console", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: name, payload }),
});
const result = await response.json() as { code: number; message?: string; data?: ConsoleData };
if (!response.ok || result.code !== 0) throw new Error(result.message || "操作失败");
if (result.data) setData(result.data);
setToast(message);
window.setTimeout(() => setToast(""), 2_800);
} catch (cause) {
setToast(cause instanceof Error ? cause.message : "操作失败");
window.setTimeout(() => setToast(""), 3_600);
}
}
function switchRole(nextRole: RoleKey) {
setRole(nextRole);
if (!roleMeta[nextRole].pages.includes(page)) setPage("dashboard");
setToast(`已切换为${roleMeta[nextRole].name}视角`);
window.setTimeout(() => setToast(""), 2_200);
}
if (loading && !data) {
return (
<main className="loading-screen">
<div className="loading-mark"></div>
<h1></h1>
<p></p>
</main>
);
}
if (!data) {
return (
<main className="loading-screen error-screen">
<div className="loading-mark">!</div>
<h1></h1>
<p>{error}</p>
<button className="primary-button" onClick={load}></button>
</main>
);
}
const visiblePages = roleMeta[role].pages;
const openAlerts = data.alerts.filter((item) => s(item, "status") !== "已关闭").length;
const onlineDevices = data.devices.filter((item) => s(item, "status") === "在线").length;
const title = pageMeta[page];
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-symbol"></div>
<div>
<strong></strong>
<small>OPERATIONS HUB</small>
</div>
<span className="version">V1.0</span>
</div>
<nav>
{navGroups.map((group) => {
const pages = group.pages.filter((item) => visiblePages.includes(item));
if (!pages.length) return null;
return (
<div className="nav-group" key={group.title}>
<p>{group.title}</p>
{pages.map((item) => (
<button
key={item}
className={page === item ? "active" : ""}
onClick={() => setPage(item)}
>
<span className="nav-icon">{pageMeta[item].icon}</span>
{pageMeta[item].name}
{item === "alerts" && openAlerts > 0 && <b>{openAlerts}</b>}
</button>
))}
</div>
);
})}
</nav>
<div className="sidebar-foot">
<div><i className="online-dot" />线 <strong>{onlineDevices}/{data.devices.length}</strong></div>
<div><i className="online-dot" /></div>
<small> · </small>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div className="page-title">
<h1>{title.name}</h1>
<span>{title.en}</span>
</div>
<div className="topbar-right">
<div className="connection"><i className="online-dot" /> </div>
<div className="clock">
<strong>{clock.toLocaleTimeString("zh-CN", { hour12: false })}</strong>
<span>{clock.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" })}</span>
</div>
<button className="alert-button" onClick={() => setPage("alerts")}>
! {openAlerts > 0 && <b>{openAlerts}</b>}
</button>
<div className="role-switch">
{(Object.keys(roleMeta) as RoleKey[]).map((key) => (
<button key={key} className={role === key ? "active" : ""} onClick={() => switchRole(key)}>
{roleMeta[key].name}
</button>
))}
</div>
</div>
</header>
<main className="content">
{page === "dashboard" && <Dashboard data={data} role={role} setPage={setPage} />}
{page === "weighings" && (
<Weighings data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} />
)}
{page === "events" && <EventsPage data={data} query={query} setQuery={setQuery} />}
{page === "reconcile" && <Reconcile data={data} />}
{page === "trace" && (
<TracePage data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} action={action} openTag={() => setModal("tag")} />
)}
{page === "assets" && (
<Assets data={data} query={query} setQuery={setQuery} tab={tab} setTab={setTab} role={role} action={action} />
)}
{page === "alerts" && <AlertsPage data={data} role={role} action={action} openThreshold={() => setModal("threshold")} />}
{page === "settings" && (
<SettingsPage data={data} tab={tab} setTab={setTab} action={action} openWorkshop={() => setModal("workshop")} />
)}
{page === "simulator" && <Simulator step={simStep} setStep={setSimStep} />}
</main>
</div>
{modal && (
<Modal
type={modal}
settings={data.settings}
onClose={() => setModal(null)}
onSubmit={async (values) => {
if (modal === "workshop") await action("workshop.add", values, "车间已新增");
if (modal === "tag") await action("tag.add", values, "RFID 标签已新增");
if (modal === "threshold") await action("settings.save", values, "告警阈值已保存");
setModal(null);
}}
/>
)}
{toast && <div className="toast">{toast}</div>}
</div>
);
}
function Dashboard({ data, role, setPage }: { data: ConsoleData; role: RoleKey; setPage: (page: PageKey) => void }) {
const todayTotal = data.weighings.reduce((total, row) => total + n(row, "weight"), 0);
const netTotal = data.traces.reduce((total, row) => total + n(row, "net"), 0);
const pending = data.reconciliations.filter((row) => s(row, "verdict") !== "正常").length;
const deviceOnline = data.devices.filter((row) => s(row, "status") === "在线").length;
const showWaste = role !== "worker";
const kpis = showWaste
? [
["今日废料投放", `${todayTotal.toFixed(1)} kg`, "按已接收记录实时汇总", "orange"],
["称重次数", `${data.weighings.length}`, "原子称重记录", "blue"],
["待核对", `${pending}`, "警告与超标记录", "amber"],
["设备在线率", `${deviceOnline}/${data.devices.length}`, "最近心跳状态", "green"],
]
: [
["今日称重轴数", `${data.traces.length}`, "导体追溯记录", "blue"],
["净重累计", `${netTotal.toFixed(1)} kg`, "毛重减皮重", "green"],
["MES 待推送", `${data.traces.filter((row) => s(row, "mes_status") !== "已推送").length}`, "自动重试队列", "amber"],
["RFID 标签", `${data.tags.length}`, "在用与备用标签", "orange"],
];
return (
<div className="page-stack">
<div className="greeting">
<div>
<span>{roleMeta[role].person}</span>
<h2>{roleMeta[role].name} · </h2>
</div>
<button className="ghost-button" onClick={() => setPage(showWaste ? "weighings" : "trace")}> </button>
</div>
<div className="kpi-grid">
{kpis.map(([label, value, note, color]) => (
<div className={`kpi ${color}`} key={label}>
<span>{label}</span>
<strong>{value}</strong>
<small>{note}</small>
</div>
))}
</div>
<div className="two-columns wide-left">
<Card title={showWaste ? "实时称重 · SB-01" : "最近导体称重任务"} subtitle={showWaste ? "数据稳定后自动生成称重记录" : "RFID 与生产批次全程关联"}>
{showWaste ? (
<div className="live-scale">
<div>
<strong>12.48</strong><span>kg</span>
<p><i className="online-dot" /> · ±10g</p>
</div>
<dl>
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd>MC-101</dd></div>
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd></dd></div>
</dl>
</div>
) : (
<DataTable
rows={data.traces.slice(0, 4)}
columns={[
{ key: "task_id", label: "任务号" },
{ key: "rfid", label: "RFID" },
{ key: "batch", label: "批次" },
{ key: "net", label: "净重", render: (row) => `${n(row, "net").toFixed(1)} kg` },
{ key: "mes_status", label: "MES", render: (row) => <Status value={s(row, "mes_status")} /> },
]}
/>
)}
</Card>
<Card title="设备状态" subtitle={`${deviceOnline} 台在线`}>
<div className="device-list">
{data.devices.slice(0, 6).map((device) => (
<div key={s(device, "id")}>
<i className={s(device, "status") === "在线" ? "online-dot" : "offline-dot"} />
<span><strong>{s(device, "id")}</strong>{s(device, "type")}</span>
<small>{formatTime(device.last_seen)}</small>
</div>
))}
</div>
</Card>
</div>
<div className="two-columns">
<Card title="近 7 日投放趋势" subtitle="公斤">
<div className="bar-chart">
{[54, 72, 61, 86, 66, 78, 93].map((height, index) => (
<div key={index}><i style={{ height: `${height}%` }} /><span>{["一", "二", "三", "四", "五", "六", "日"][index]}</span></div>
))}
</div>
</Card>
<Card title="最近操作流水" subtitle="自动刷新">
<div className="activity-list">
{data.weighings.slice(0, 5).map((row) => (
<div key={s(row, "id")}>
<time>{formatTime(row.created_at)}</time>
<span>{s(row, "operator")} · {s(row, "machine")} · {s(row, "category")}</span>
<strong>{n(row, "weight").toFixed(2)} kg</strong>
</div>
))}
</div>
</Card>
</div>
</div>
);
}
function Weighings({ data, query, setQuery, tab, setTab }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void }) {
const rows = useMemo(() => {
const key = query.toLowerCase();
return data.weighings.filter((row) => !key || [row.operator, row.machine, row.category, row.event_id].some((value) => String(value ?? "").toLowerCase().includes(key)));
}, [data.weighings, query]);
const categoryTotals = ["废铜", "废电线", "废铝", "废锡"].map((category) => ({
category,
value: rows.filter((row) => s(row, "category") === category).reduce((total, row) => total + n(row, "weight"), 0),
}));
return (
<Card
title="废料称重数据"
subtitle="原子称重记录与统计分析"
tools={<button className="ghost-button" onClick={() => exportCsv("废料称重记录.csv", rows)}> CSV</button>}
>
<div className="toolbar">
<div className="tabs">
<button className={tab === "records" ? "active" : ""} onClick={() => setTab("records")}></button>
<button className={tab === "statistics" ? "active" : ""} onClick={() => setTab("statistics")}></button>
</div>
<SearchBox value={query} onChange={setQuery} placeholder="搜索操作人、机台或事件…" />
</div>
{tab === "records" ? (
<DataTable
rows={rows}
columns={[
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
{ key: "operator", label: "操作人" },
{ key: "machine", label: "关联机台" },
{ key: "category", label: "废料类别" },
{ key: "source", label: "来源设备" },
{ key: "weight", label: "精确重量", render: (row) => <strong>{n(row, "weight").toFixed(2)} kg</strong> },
{ key: "event_id", label: "所属事件" },
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
]}
/>
) : (
<div className="statistics-grid">
{categoryTotals.map((item) => (
<div key={item.category}>
<span>{item.category}</span>
<strong>{item.value.toFixed(1)} kg</strong>
<i style={{ width: `${Math.min(100, item.value * 4)}%` }} />
</div>
))}
</div>
)}
</Card>
);
}
function EventsPage({ data, query, setQuery }: { data: ConsoleData; query: string; setQuery: (value: string) => void }) {
const rows = data.events.filter((row) => !query || `${s(row, "id")}${s(row, "operator")}`.toLowerCase().includes(query.toLowerCase()));
return (
<Card title="废料投放事件" subtitle="按进门到离门聚合原子称重记录" tools={<button className="ghost-button" onClick={() => exportCsv("事件列表.csv", rows)}> CSV</button>}>
<div className="toolbar"><SearchBox value={query} onChange={setQuery} placeholder="搜索事件 ID 或操作人…" /></div>
<DataTable rows={rows} columns={[
{ key: "id", label: "事件 ID" },
{ key: "operator", label: "操作人" },
{ key: "entered_at", label: "进门时间", render: (row) => formatTime(row.entered_at, true) },
{ key: "left_at", label: "离门时间", render: (row) => formatTime(row.left_at) },
{ key: "duration", label: "历时", render: (row) => `${Math.max(1, Math.round((n(row, "left_at") - n(row, "entered_at")) / 60_000))} 分钟` },
{ key: "atomic_count", label: "原子笔数" },
{ key: "scale_total", label: "台秤总重", render: (row) => `${n(row, "scale_total").toFixed(2)} kg` },
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
]} />
</Card>
);
}
function Reconcile({ data }: { data: ConsoleData }) {
return (
<Card title="双秤数据核对" subtitle="正常 ≤2% · 警告 25% · 超标 >5%" tools={<button className="ghost-button" onClick={() => exportCsv("地磅核对.csv", data.reconciliations)}> CSV</button>}>
<DataTable rows={data.reconciliations} columns={[
{ key: "event_id", label: "事件 ID" },
{ key: "category", label: "废料类别" },
{ key: "small_scale", label: "台秤汇总", render: (row) => `${n(row, "small_scale").toFixed(2)} kg` },
{ key: "floor_scale", label: "地磅增量", render: (row) => `${n(row, "floor_scale").toFixed(2)} kg` },
{ key: "difference", label: "偏差值", render: (row) => `${n(row, "difference").toFixed(2)} kg` },
{ key: "difference_rate", label: "偏差率", render: (row) => `${n(row, "difference_rate").toFixed(2)}%` },
{ key: "verdict", label: "判定", render: (row) => <Status value={s(row, "verdict")} /> },
{ key: "storage_status", label: "入库状态", render: (row) => <Status value={s(row, "storage_status")} /> },
]} />
</Card>
);
}
function TracePage({ data, query, setQuery, tab, setTab, action, openTag }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openTag: () => void }) {
const rows = data.traces.filter((row) => !query || `${s(row, "task_id")}${s(row, "rfid")}${s(row, "batch")}`.toLowerCase().includes(query.toLowerCase()));
return (
<Card title="导体称重追溯" subtitle="RFID、生产批次与两次称重完整关联">
<div className="toolbar">
<div className="tabs">
<button className={tab === "trace" ? "active" : ""} onClick={() => setTab("trace")}></button>
<button className={tab === "tags" ? "active" : ""} onClick={() => setTab("tags")}>RFID </button>
</div>
{tab === "trace" ? <SearchBox value={query} onChange={setQuery} placeholder="搜索任务、RFID 或批次…" /> : <button className="primary-button" onClick={openTag}></button>}
</div>
{tab === "trace" ? <DataTable rows={rows} columns={[
{ key: "created_at", label: "称重时间", render: (row) => formatTime(row.created_at, true) },
{ key: "workshop", label: "所属车间" },
{ key: "task_id", label: "任务号" },
{ key: "rfid", label: "RFID 标签" },
{ key: "tare", label: "皮重", render: (row) => `${n(row, "tare").toFixed(2)} kg` },
{ key: "gross", label: "毛重", render: (row) => `${n(row, "gross").toFixed(2)} kg` },
{ key: "net", label: "净重", render: (row) => <strong>{n(row, "net").toFixed(2)} kg</strong> },
{ key: "machine", label: "机台" },
{ key: "batch", label: "批次号" },
{ key: "mes_status", label: "MES 推送", render: (row) => <Status value={s(row, "mes_status")} /> },
]} /> : <DataTable rows={data.tags} columns={[
{ key: "id", label: "标签 ID" },
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
{ key: "name", label: "名称" },
{ key: "last_checked", label: "最后检测", render: (row) => row.last_checked ? formatTime(row.last_checked, true) : "—" },
{ key: "actions", label: "操作", render: (row) => (
<div className="row-actions">
<button onClick={() => action("tag.status", { id: row.id, status: "在用" }, "标签已写入")}></button>
<button onClick={() => action("tag.status", { id: row.id, status: "损坏" }, "标签已标记损坏")}></button>
</div>
) },
]} />}
</Card>
);
}
function Assets({ data, query, setQuery, tab, setTab, role, action }: { data: ConsoleData; query: string; setQuery: (value: string) => void; tab: string; setTab: (value: string) => void; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
const people = data.people.filter((row) => !query || `${s(row, "name")}${s(row, "id")}`.toLowerCase().includes(query.toLowerCase()));
return (
<Card title="人员与设备" subtitle={role === "admin" ? "人员主数据由服务端统一维护并向设备下发" : "当前为只读视图"}>
<div className="toolbar">
<div className="tabs">
<button className={tab === "people" ? "active" : ""} onClick={() => setTab("people")}></button>
<button className={tab === "machines" ? "active" : ""} onClick={() => setTab("machines")}></button>
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}></button>
</div>
{tab === "people" && <SearchBox value={query} onChange={setQuery} placeholder="搜索姓名或员工 ID…" />}
{tab === "people" && role === "admin" && <button className="primary-button" onClick={() => action("mes.sync", {}, "MES 人员同步完成")}> MES</button>}
</div>
{tab === "people" && <DataTable rows={people} columns={[
{ key: "name", label: "姓名" },
{ key: "id", label: "员工 ID" },
{ key: "workshop", label: "所属车间", render: (row) => role === "admin" ? (
<select value={s(row, "workshop")} onChange={(event) => action("person.workshop", { id: row.id, workshop: event.target.value }, "所属车间已更新")}>
{data.workshops.map((workshop) => <option key={s(workshop, "code")}>{s(workshop, "name")}</option>)}
</select>
) : s(row, "workshop") },
{ key: "role", label: "角色" },
{ key: "face_status", label: "人脸状态", render: (row) => <Status value={s(row, "face_status")} /> },
{ key: "sync_status", label: "同步状态", render: (row) => <Status value={s(row, "sync_status")} /> },
{ key: "active", label: "账号状态", render: (row) => <Status value={n(row, "active") ? "启用" : "停用"} /> },
{ key: "actions", label: "操作", render: (row) => role === "admin" ? (
<div className="row-actions">
<button onClick={() => action("person.face", { id: row.id }, "人脸版本已更新")}></button>
<button onClick={() => action("person.toggle", { id: row.id }, "人员状态已切换")}>{n(row, "active") ? "停用" : "启用"}</button>
</div>
) : "只读" },
]} />}
{tab === "machines" && <DataTable rows={[
{ id: "MC-101", name: "1# 切割机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
{ id: "MC-105", name: "2# 剥线机", type: "生产机台", workshop: "一楼新能源车间", status: "运行中", mes: "正常" },
{ id: "MC-201", name: "8# 绞线机", type: "导体机台", workshop: "二楼导体车间", status: "维护中", mes: "正常" },
]} columns={[
{ key: "id", label: "机台编号" }, { key: "name", label: "机台名称" }, { key: "type", label: "类型" },
{ key: "workshop", label: "所属车间" }, { key: "status", label: "运行状态", render: (row) => <Status value={s(row, "status")} /> },
{ key: "mes", label: "MES 同步", render: (row) => <Status value={s(row, "mes")} /> },
]} />}
{tab === "devices" && (
<div className="device-grid">
{data.devices.map((device) => (
<article key={s(device, "id")}>
<div><strong>{s(device, "id")}</strong><Status value={s(device, "status")} /></div>
<h4>{s(device, "type")}</h4>
<p>{s(device, "ip")} · {s(device, "heartbeat_seconds")}s</p>
{n(device, "fill_percent") > 0 && (
<>
<div className="meter"><i style={{ width: `${n(device, "fill_percent")}%` }} /></div>
<small> {n(device, "fill_percent")}% · {s(device, "category")}</small>
</>
)}
<footer> {formatTime(device.last_seen)}</footer>
</article>
))}
</div>
)}
</Card>
);
}
function AlertsPage({ data, role, action, openThreshold }: { data: ConsoleData; role: RoleKey; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openThreshold: () => void }) {
return (
<Card title="设备与业务告警" subtitle="满溢、设备离线、超时与称重偏差统一管理" tools={role === "admin" && <button className="ghost-button" onClick={openThreshold}></button>}>
<div className="alert-list">
{data.alerts.map((alert) => (
<article key={s(alert, "id")} className={s(alert, "severity") === "严重" ? "critical" : ""}>
<div className="alert-sign">!</div>
<div>
<div className="alert-title"><h3>{s(alert, "type")}</h3><Status value={s(alert, "status")} /></div>
<p>{s(alert, "description")}</p>
<small>{formatTime(alert.created_at, true)} · {s(alert, "source")} · {s(alert, "reference")}</small>
</div>
{role === "admin" && s(alert, "status") !== "已关闭" && (
<div className="row-actions">
<button onClick={() => action("alert.status", { id: alert.id, status: "处理中" }, "告警已接单")}></button>
<button onClick={() => action("alert.status", { id: alert.id, status: "已关闭" }, "告警已关闭")}></button>
</div>
)}
</article>
))}
</div>
</Card>
);
}
function SettingsPage({ data, tab, setTab, action, openWorkshop }: { data: ConsoleData; tab: string; setTab: (value: string) => void; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void>; openWorkshop: () => void }) {
const tabs = [["accounts", "账号与权限"], ["workshops", "车间字典"], ["monitor", "设备监控"], ["mes", "MES 接口"], ["logs", "操作日志"]];
return (
<Card title="系统配置" subtitle="管理员专属">
<div className="toolbar"><div className="tabs">{tabs.map(([key, label]) => <button key={key} className={tab === key ? "active" : ""} onClick={() => setTab(key)}>{label}</button>)}</div></div>
{tab === "accounts" && <DataTable rows={[
{ name: "王工", id: "A0001", department: "设备部", role: "管理员", permission: "全部功能与系统配置" },
{ name: "张三", id: "P1001", department: "仓储部", role: "仓管员", permission: "废料库、人员设备只读" },
{ name: "李四", id: "P1002", department: "生产部", role: "产线工人", permission: "导体追溯、人员设备只读" },
]} columns={[
{ key: "name", label: "姓名" }, { key: "id", label: "工号" }, { key: "department", label: "部门" },
{ key: "role", label: "系统角色" }, { key: "permission", label: "权限说明" },
]} />}
{tab === "workshops" && (
<>
<div className="sub-toolbar"><span></span><button className="primary-button" onClick={openWorkshop}></button></div>
<DataTable rows={data.workshops} columns={[
{ key: "code", label: "车间编码" }, { key: "name", label: "车间名称" }, { key: "location", label: "位置" }, { key: "note", label: "备注" },
{ key: "people", label: "关联人员", render: (row) => `${data.people.filter((person) => s(person, "workshop") === s(row, "name")).length}` },
{ key: "actions", label: "操作", render: (row) => <div className="row-actions"><button onClick={() => action("workshop.delete", { code: row.code }, "车间已删除")}></button></div> },
]} />
</>
)}
{tab === "monitor" && <DataTable rows={data.devices} columns={[
{ key: "id", label: "设备编号" }, { key: "type", label: "设备类型" }, { key: "ip", label: "IP 地址" },
{ key: "heartbeat_seconds", label: "心跳周期", render: (row) => `${s(row, "heartbeat_seconds")}` },
{ key: "last_seen", label: "最后心跳", render: (row) => formatTime(row.last_seen, true) },
{ key: "status", label: "状态", render: (row) => <Status value={s(row, "status")} /> },
]} />}
{tab === "mes" && <MesSettings settings={data.settings} action={action} />}
{tab === "logs" && <DataTable rows={data.logs} columns={[
{ key: "created_at", label: "时间", render: (row) => formatTime(row.created_at, true) },
{ key: "actor", label: "操作人" }, { key: "action", label: "动作" }, { key: "details", label: "详情" },
{ key: "level", label: "级别", render: (row) => <Status value={s(row, "level")} /> },
]} />}
</Card>
);
}
function MesSettings({ settings, action }: { settings: Record<string, string>; action: (name: string, payload?: Record<string, unknown>, message?: string) => Promise<void> }) {
const [url, setUrl] = useState(settings.mes_url ?? "");
const [timeout, setTimeoutValue] = useState(settings.mes_timeout ?? "10");
const [testing, setTesting] = useState(false);
return (
<div className="settings-form">
<label><span></span><input value={url} onChange={(event) => setUrl(event.target.value)} /></label>
<label><span></span><input type="number" value={timeout} onChange={(event) => setTimeoutValue(event.target.value)} /><small></small></label>
<label><span></span><select defaultValue="3"><option value="3"> 3 · 5 </option><option value="0"></option></select></label>
<div className="form-actions">
<button className="ghost-button" onClick={() => { setTesting(true); window.setTimeout(() => setTesting(false), 900); }}>{testing ? "连接中…" : "测试连接"}</button>
<button className="primary-button" onClick={() => action("settings.save", { mes_url: url, mes_timeout: timeout }, "MES 配置已保存")}></button>
{!testing && <Status value="连接正常" />}
</div>
</div>
);
}
function Simulator({ step, setStep }: { step: number; setStep: (value: number) => void }) {
const wasteSteps = [
["身份验证", "请将面部对准摄像头,也可输入工号", "模拟识别成功 · 张三"],
["门锁已开启", "请进入投放,最长停留 05:00", "模拟投放完成并关门"],
["投放完成", "台秤 38.50kg · 地磅 39.00kg · 偏差 1.3%", "重新开始"],
];
const cableSteps = [
["导体轴称重", "RFID 全程关联生产任务与批次", "开始一次称重"],
["一次称重完成", "RFID E280-6102-0345 · 皮重 152.30kg", "模拟二次称重"],
["MES 上报成功", "净重 115.80kg · 条码已打印", "重新开始"],
];
return (
<div className="simulator-grid">
{[["废料库入库终端", wasteSteps, "orange"], ["导体称重终端", cableSteps, "blue"]].map(([name, steps, color], index) => {
const items = steps as string[][];
const current = index === 0 ? step % 3 : Math.floor(step / 3) % 3;
return (
<Card title={String(name)} subtitle="点击屏幕推进业务流程" className="sim-card" key={String(name)}>
<button className={`terminal ${color}`} onClick={() => setStep(index === 0 ? (current + 1) % 3 : ((current + 1) % 3) * 3)}>
<span className="terminal-camera" />
<small>LOCAL TERMINAL · ONLINE</small>
<div className="terminal-icon">{current === 0 ? "◉" : current === 1 ? "⇄" : "✓"}</div>
<h3>{items[current][0]}</h3>
<p>{items[current][1]}</p>
<strong>{items[current][2]} </strong>
<div className="step-dots">{items.map((_, itemIndex) => <i className={itemIndex === current ? "active" : ""} key={itemIndex} />)}</div>
</button>
</Card>
);
})}
</div>
);
}
function Modal({ type, settings, onClose, onSubmit }: { type: "workshop" | "tag" | "threshold"; settings: Record<string, string>; onClose: () => void; onSubmit: (values: Record<string, string>) => Promise<void> }) {
const [saving, setSaving] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
const values = Object.fromEntries(new FormData(event.currentTarget).entries()) as Record<string, string>;
await onSubmit(values);
setSaving(false);
}
return (
<div className="modal-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
<form className="modal" onSubmit={submit}>
<header><h2>{type === "workshop" ? "新增车间" : type === "tag" ? "添加 RFID 标签" : "告警阈值设置"}</h2><button type="button" onClick={onClose}>×</button></header>
<div className="modal-body">
{type === "workshop" && <>
<label><span></span><input name="name" required placeholder="例如:三楼精密车间" /></label>
<label><span></span><input name="location" placeholder="例如:3F · 北侧" /></label>
<label><span></span><input name="note" placeholder="选填" /></label>
</>}
{type === "tag" && <label><span></span><input name="name" required placeholder="例如:8# 导体轴标签" /></label>}
{type === "threshold" && <>
<label><span> (%)</span><input name="deviation_warn" type="number" defaultValue={settings.deviation_warn} /></label>
<label><span> (%)</span><input name="deviation_critical" type="number" defaultValue={settings.deviation_critical} /></label>
<label><span> (%)</span><input name="fill_warn" type="number" defaultValue={settings.fill_warn} /></label>
<label><span> (%)</span><input name="fill_critical" type="number" defaultValue={settings.fill_critical} /></label>
</>}
</div>
<footer><button type="button" className="ghost-button" onClick={onClose}></button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "确认保存"}</button></footer>
</form>
</div>
);
}
@@ -0,0 +1,93 @@
import { addAudit, ensureDatabase, loadConsoleData } from "@/db/store";
export const dynamic = "force-dynamic";
export async function GET() {
try {
return Response.json({ code: 0, data: await loadConsoleData() });
} catch (error) {
return Response.json(
{ code: 5001, message: error instanceof Error ? error.message : "数据库不可用" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const db = await ensureDatabase();
const body = (await request.json()) as {
action?: string;
payload?: Record<string, unknown>;
};
const payload = body.payload ?? {};
const action = body.action ?? "";
if (action === "person.toggle") {
const id = String(payload.id ?? "");
await db
.prepare("UPDATE people SET active=CASE active WHEN 1 THEN 0 ELSE 1 END, updated_at=? WHERE id=?")
.bind(Date.now(), id)
.run();
await addAudit("管理员", "人员状态变更", `${id} 启用状态已切换`, "重要");
} else if (action === "person.workshop") {
const id = String(payload.id ?? "");
const workshop = String(payload.workshop ?? "");
await db.prepare("UPDATE people SET workshop=?, updated_at=? WHERE id=?").bind(workshop, Date.now(), id).run();
await addAudit("管理员", "修改人员车间", `${id} 调整到 ${workshop}`);
} else if (action === "person.face") {
const id = String(payload.id ?? "");
await db.prepare("UPDATE people SET face_status='已录入', face_version=face_version+1, sync_status='已同步', updated_at=? WHERE id=?").bind(Date.now(), id).run();
await addAudit("管理员", "更新人脸", `${id} 人脸版本已更新`, "重要");
} else if (action === "workshop.add") {
const name = String(payload.name ?? "").trim();
const location = String(payload.location ?? "—").trim();
const note = String(payload.note ?? "—").trim();
if (!name) return Response.json({ code: 1001, message: "车间名称不能为空" }, { status: 400 });
const row = await db.prepare("SELECT COUNT(*) AS total FROM workshops").first<{ total: number }>();
const code = `WS-${String(Number(row?.total ?? 0) + 1).padStart(2, "0")}`;
await db.prepare("INSERT INTO workshops(code,name,location,note) VALUES(?,?,?,?)").bind(code, name, location || "—", note || "—").run();
await addAudit("管理员", "新增车间", `${code} ${name}`, "重要");
} else if (action === "workshop.delete") {
const code = String(payload.code ?? "");
const workshop = await db.prepare("SELECT name FROM workshops WHERE code=?").bind(code).first<{ name: string }>();
if (workshop) {
const used = await db.prepare("SELECT COUNT(*) AS total FROM people WHERE workshop=?").bind(workshop.name).first<{ total: number }>();
if (Number(used?.total ?? 0) > 0) {
return Response.json({ code: 4001, message: "该车间仍有关联人员,不能删除" }, { status: 409 });
}
await db.prepare("DELETE FROM workshops WHERE code=?").bind(code).run();
await addAudit("管理员", "删除车间", `${code} ${workshop.name}`, "重要");
}
} else if (action === "tag.add") {
const name = String(payload.name ?? "未命名标签").trim() || "未命名标签";
const id = `E280-6102-${String(Math.floor(Math.random() * 9000) + 1000)}`;
await db.prepare("INSERT INTO rfid_tags(id,status,name,last_checked) VALUES(?,'未用',?,NULL)").bind(id, name).run();
await addAudit("管理员", "新增 RFID", `${id} ${name}`);
} else if (action === "tag.status") {
const id = String(payload.id ?? "");
const status = String(payload.status ?? "未用");
await db.prepare("UPDATE rfid_tags SET status=?, last_checked=? WHERE id=?").bind(status, Date.now(), id).run();
await addAudit("管理员", "RFID 状态变更", `${id}${status}`);
} else if (action === "alert.status") {
const id = String(payload.id ?? "");
const status = String(payload.status ?? "处理中");
await db.prepare("UPDATE alerts SET status=? WHERE id=?").bind(status, id).run();
await addAudit("管理员", "处理告警", `${id}${status}`, "重要");
} else if (action === "settings.save") {
const entries = Object.entries(payload);
for (const [key, value] of entries) {
await db
.prepare("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at")
.bind(key, String(value), Date.now())
.run();
}
await addAudit("管理员", "更新系统设置", entries.map(([key]) => key).join("、"), "重要");
} else if (action === "mes.sync") {
await db.prepare("UPDATE people SET sync_status='已同步', updated_at=? WHERE sync_status<>'已同步'").bind(Date.now()).run();
await addAudit("管理员", "同步 MES 人员", "增量同步完成");
} else {
return Response.json({ code: 1001, message: "未知操作" }, { status: 400 });
}
return Response.json({ code: 0, message: "操作成功", data: await loadConsoleData() });
}
@@ -0,0 +1,121 @@
import { addAudit, ensureDatabase } from "@/db/store";
export const dynamic = "force-dynamic";
type RouteContext = { params: Promise<{ path: string[] }> };
function ok(data: unknown, requestId = crypto.randomUUID()) {
return Response.json({
code: 0,
message: "success",
requestId,
serverTime: Date.now(),
data,
});
}
function fail(code: number, message: string, status = 400) {
return Response.json(
{ code, message, requestId: crypto.randomUUID(), serverTime: Date.now(), data: null },
{ status },
);
}
export async function GET(request: Request, context: RouteContext) {
const db = await ensureDatabase();
const path = (await context.params).path;
const url = new URL(request.url);
if (path.length === 4 && path[0] === "devices" && path[2] === "people") {
const cursor = Number(url.searchParams.get("cursor") ?? 0);
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200), 500);
const rows = await db
.prepare("SELECT id AS personId,name,workshop,role,active,face_status AS faceStatus,face_version AS faceVersion,updated_at AS updatedAt FROM people WHERE updated_at>? ORDER BY updated_at LIMIT ?")
.bind(cursor, limit)
.all();
const nextCursor = rows.results.reduce((value, row) => Math.max(value, Number(row.updatedAt ?? 0)), cursor);
return ok({ items: rows.results, nextCursor, hasMore: rows.results.length === limit });
}
if (path.length === 4 && path[0] === "devices" && path[2] === "faces") {
const person = await db
.prepare("SELECT id AS personId,name,face_status AS faceStatus,face_version AS faceVersion,updated_at AS updatedAt FROM people WHERE id=? AND active=1")
.bind(path[3])
.first();
if (!person) return fail(4002, "PERSON_DELETED", 404);
return ok({ ...person, featureEncoding: "server-managed", feature: null });
}
if (path.length === 2 && path[0] === "deliveries") {
const row = await db.prepare("SELECT * FROM deliveries WHERE record_id=?").bind(path[1]).first();
if (!row) return fail(3004, "DELIVERY_NOT_FOUND", 404);
return ok(row);
}
return fail(1001, "接口不存在", 404);
}
export async function POST(request: Request, context: RouteContext) {
const db = await ensureDatabase();
const path = (await context.params).path;
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
if (path.join("/") === "auth/tokens") {
const deviceId = String(body.deviceId ?? body.deviceNo ?? "");
if (!deviceId) return fail(1001, "deviceId 必填");
return ok({ accessToken: `local-${deviceId}-${Date.now()}`, tokenType: "Bearer", expiresIn: 28_800 });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "heartbeat") {
const deviceId = path[1];
const ip = String(body.ip ?? "0.0.0.0");
await db
.prepare("INSERT INTO devices(id,type,ip,status,last_seen,heartbeat_seconds,fill_percent,category) VALUES(?,?,?,'在线',?,30,0,'终端') ON CONFLICT(id) DO UPDATE SET status='在线',last_seen=excluded.last_seen,ip=excluded.ip")
.bind(deviceId, String(body.deviceType ?? "回收终端"), ip, Date.now())
.run();
return ok({ accepted: true, nextHeartbeatIn: 30 });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "status") {
const deviceId = path[1];
const fillPercent = Number(body.fillPercent ?? body.fullRate ?? 0);
await db.prepare("UPDATE devices SET status=?,last_seen=?,fill_percent=? WHERE id=?").bind(String(body.status ?? "在线"), Date.now(), fillPercent, deviceId).run();
return ok({ accepted: true });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "alerts") {
const deviceId = path[1];
const alerts = Array.isArray(body.alerts) ? body.alerts : [body];
for (const item of alerts as Array<Record<string, unknown>>) {
await db
.prepare("INSERT OR IGNORE INTO alerts(id,created_at,type,severity,source,reference,description,status) VALUES(?,?,?,?,?,?,?,'待处理')")
.bind(String(item.alarmId ?? crypto.randomUUID()), Number(item.reportedAt ?? Date.now()), String(item.type ?? "设备告警"), String(item.severity ?? "警告"), "设备上报", deviceId, String(item.message ?? "设备异常"))
.run();
}
return ok({ accepted: alerts.length });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "deliveries") {
const deviceId = path[1];
const recordId = String(body.recordId ?? "");
if (!recordId) return fail(1001, "recordId 必填");
const weight = Number(body.weight ?? body.afterWeight ?? 0);
await db
.prepare("INSERT OR IGNORE INTO deliveries(record_id,device_id,created_at,employee_name,employee_no,machine_no,production_batch,garbage_type,weight,payload,status) VALUES(?,?,?,?,?,?,?,?,?,?,?)")
.bind(recordId, deviceId, Number(body.createdAt ?? Date.now()), String(body.employeeName ?? body.person ?? ""), String(body.employeeNo ?? ""), String(body.machineNo ?? ""), String(body.productionBatch ?? ""), Number(body.garbageType ?? body.typeCode ?? 0), weight, JSON.stringify(body), "已接收")
.run();
await db
.prepare("INSERT OR IGNORE INTO weighings(id,created_at,operator,machine,category,source,weight,event_id,status) VALUES(?,?,?,?,?,?,?,?,?)")
.bind(recordId, Number(body.createdAt ?? Date.now()), String(body.employeeName ?? body.person ?? "未知"), String(body.machineNo ?? "—"), `类型 ${String(body.garbageType ?? body.typeCode ?? 0)}`, deviceId, weight, String(body.eventId ?? recordId), "已确认")
.run();
await addAudit(deviceId, "投放数据上报", `${recordId} · ${weight}kg`);
return ok({ accepted: true, duplicate: false });
}
if (path.length === 3 && path[0] === "devices" && path[2] === "sync-acks") {
await addAudit(path[1], "同步结果上报", JSON.stringify(body).slice(0, 500));
return ok({ accepted: true });
}
return fail(1001, "接口不存在", 404);
}
+86
View File
@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+298
View File
@@ -0,0 +1,298 @@
@import "tailwindcss";
:root {
--bg: #f3f5f8;
--panel: #ffffff;
--panel-soft: #f8fafc;
--ink: #172234;
--muted: #6b778a;
--line: #e2e7ee;
--nav: #142333;
--nav-soft: #1a2e43;
--blue: #1f72e5;
--cyan: #23a9c9;
--green: #21a675;
--orange: #f08a35;
--amber: #d99614;
--red: #dc4f4f;
--shadow: 0 14px 34px rgba(20, 35, 51, .07);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
body {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
button, input, select { font: inherit; }
button { cursor: pointer; }
.loading-screen {
min-height: 100vh; display: grid; place-content: center; text-align: center;
background: radial-gradient(circle at 50% 30%, #fff, #edf2f8);
}
.loading-mark {
width: 64px; height: 64px; margin: 0 auto 18px; border-radius: 18px;
display: grid; place-items: center; color: white; font-size: 26px; font-weight: 800;
background: linear-gradient(135deg, var(--blue), var(--cyan)); box-shadow: 0 15px 30px rgba(31,114,229,.25);
}
.loading-screen h1 { font-size: 22px; margin: 0; }
.loading-screen p { color: var(--muted); margin: 10px 0 22px; }
.error-screen .loading-mark { background: var(--red); }
.app-shell { min-height: 100vh; }
.sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: 236px; display: flex; flex-direction: column;
color: #d9e4ef; background:
radial-gradient(circle at -10% 0, rgba(35,169,201,.18), transparent 28%),
linear-gradient(180deg, #142536 0%, #101d2a 100%);
border-right: 1px solid #20364b;
}
.brand {
min-height: 76px; padding: 17px 16px; display: flex; align-items: center; gap: 11px;
border-bottom: 1px solid rgba(255,255,255,.08); position: relative;
}
.brand-symbol {
width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px;
background: linear-gradient(135deg, #2686ff, #27b1c5); color: white; font-weight: 900; font-size: 17px;
box-shadow: 0 8px 20px rgba(31,114,229,.25);
}
.brand strong { display: block; font-size: 16px; letter-spacing: .08em; }
.brand small { display: block; margin-top: 3px; color: #71869a; font-size: 8px; letter-spacing: .18em; }
.version { position: absolute; top: 17px; right: 13px; color: #4fc0d8; font: 700 9px monospace; }
.sidebar nav { flex: 1; overflow: auto; padding: 10px 8px; }
.nav-group { margin-bottom: 8px; }
.nav-group p { padding: 8px 11px 5px; margin: 0; color: #647d92; font-size: 10px; letter-spacing: .16em; }
.nav-group button {
width: 100%; height: 39px; padding: 0 11px; display: flex; align-items: center; gap: 10px;
border: 0; border-radius: 8px; background: transparent; color: #9fb1c2; font-size: 13px; text-align: left;
transition: .18s ease; position: relative;
}
.nav-group button:hover { color: #fff; background: rgba(255,255,255,.05); }
.nav-group button.active { color: #fff; background: linear-gradient(90deg, rgba(31,114,229,.35), rgba(35,169,201,.13)); }
.nav-group button.active::before { content: ""; position: absolute; left: 0; width: 3px; height: 20px; border-radius: 4px; background: #47b9e9; }
.nav-icon { width: 19px; color: #5aa7d6; font-weight: 800; text-align: center; }
.nav-group button b {
margin-left: auto; min-width: 19px; height: 19px; display: grid; place-items: center;
color: white; background: var(--red); border-radius: 10px; font-size: 10px;
}
.sidebar-foot { padding: 13px 17px 18px; border-top: 1px solid rgba(255,255,255,.08); font-size: 11px; color: #8fa4b7; }
.sidebar-foot div { display: flex; align-items: center; gap: 7px; margin: 6px 0; }
.sidebar-foot strong { color: #d8e3ec; margin-left: auto; }
.sidebar-foot small { display: block; color: #586f83; margin-top: 8px; }
.online-dot, .offline-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: #3bd29b; box-shadow: 0 0 0 4px rgba(59,210,155,.1); }
.offline-dot { background: var(--red); box-shadow: 0 0 0 4px rgba(220,79,79,.1); }
.workspace { min-height: 100vh; margin-left: 236px; }
.topbar {
height: 76px; padding: 0 24px; display: flex; align-items: center; gap: 20px;
background: rgba(255,255,255,.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 10;
}
.page-title { display: flex; align-items: baseline; gap: 11px; }
.page-title h1 { margin: 0; font-size: 20px; }
.page-title span { color: #9aa5b4; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.connection {
display: flex; align-items: center; gap: 7px; padding: 6px 10px; color: var(--green);
background: #f2fbf7; border: 1px solid #cfefe2; border-radius: 8px; font-size: 11px; font-weight: 700;
}
.clock { padding-right: 4px; text-align: right; }
.clock strong { display: block; font: 700 13px ui-monospace, monospace; }
.clock span { display: block; margin-top: 2px; color: var(--muted); font-size: 9px; }
.alert-button {
width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 9px;
background: white; color: var(--red); font-weight: 900; position: relative;
}
.alert-button b { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; border-radius: 9px; color: white; background: var(--red); font-size: 9px; }
.role-switch { padding: 3px; display: flex; background: #edf1f5; border-radius: 9px; }
.role-switch button { padding: 6px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; }
.role-switch button.active { color: #fff; background: var(--blue); box-shadow: 0 4px 10px rgba(31,114,229,.2); }
.content { max-width: 1500px; margin: 0 auto; padding: 22px 24px 40px; }
.page-stack { display: grid; gap: 16px; }
.greeting {
padding: 18px 21px; display: flex; align-items: center; justify-content: space-between;
background: linear-gradient(125deg, #18334c, #164664 60%, #176079); color: white;
border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; position: relative;
}
.greeting::after { content: ""; position: absolute; right: -60px; width: 280px; height: 280px; border: 54px solid rgba(255,255,255,.05); border-radius: 50%; }
.greeting span { color: #b9deee; font-size: 12px; }
.greeting h2 { margin: 4px 0 0; font-size: 18px; }
.greeting button { position: relative; z-index: 1; color: white; border-color: rgba(255,255,255,.2); background: rgba(255,255,255,.08); }
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.kpi {
min-height: 124px; padding: 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 8px 25px rgba(20,35,51,.04); position: relative; overflow: hidden;
}
.kpi::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--blue); }
.kpi::after { content: ""; position: absolute; right: -20px; top: -20px; width: 84px; height: 84px; border-radius: 50%; background: rgba(31,114,229,.06); }
.kpi.orange::before { background: var(--orange); } .kpi.orange::after { background: rgba(240,138,53,.08); }
.kpi.green::before { background: var(--green); } .kpi.green::after { background: rgba(33,166,117,.08); }
.kpi.amber::before { background: var(--amber); } .kpi.amber::after { background: rgba(217,150,20,.08); }
.kpi > span { color: var(--muted); font-size: 12px; }
.kpi > strong { display: block; margin: 11px 0 7px; font-size: 28px; letter-spacing: -.03em; }
.kpi > small { color: #9aa5b4; font-size: 10px; }
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.two-columns.wide-left { grid-template-columns: 1.45fr 1fr; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 26px rgba(20,35,51,.04); overflow: hidden; }
.card-head { min-height: 61px; padding: 14px 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.card-head h3 { margin: 0; font-size: 14px; }
.card-head p { margin: 4px 0 0; color: var(--muted); font-size: 10px; }
.card-tools { display: flex; gap: 8px; }
.live-scale { min-height: 235px; padding: 34px 28px; display: grid; grid-template-columns: 1.2fr 1fr; align-items: center; background: radial-gradient(circle at 20% 50%, #eef8ff, transparent 48%); }
.live-scale > div > strong { color: var(--blue); font-size: 58px; letter-spacing: -.06em; }
.live-scale > div > span { margin-left: 8px; color: var(--muted); font-size: 16px; }
.live-scale p { display: flex; align-items: center; gap: 8px; color: var(--green); font-size: 11px; }
.live-scale dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.live-scale dl div { padding: 10px; background: white; border: 1px solid var(--line); border-radius: 9px; }
.live-scale dt { color: var(--muted); font-size: 9px; }
.live-scale dd { margin: 5px 0 0; font-size: 12px; font-weight: 700; }
.device-list { padding: 8px 17px 13px; }
.device-list > div { min-height: 40px; display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef1f4; }
.device-list > div:last-child { border: 0; }
.device-list span { display: flex; gap: 9px; color: var(--muted); font-size: 11px; }
.device-list span strong { width: 60px; color: var(--ink); }
.device-list small { color: #9aa5b4; font-size: 9px; }
.bar-chart { height: 190px; padding: 25px 25px 15px; display: flex; align-items: end; justify-content: space-around; gap: 13px; }
.bar-chart div { height: 100%; flex: 1; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 8px; }
.bar-chart i { width: min(24px, 65%); min-height: 10px; border-radius: 6px 6px 2px 2px; background: linear-gradient(180deg, #39b8d5, #247ae4); box-shadow: 0 5px 12px rgba(31,114,229,.15); }
.bar-chart span { color: var(--muted); font-size: 9px; }
.activity-list { padding: 7px 17px 13px; }
.activity-list > div { min-height: 38px; display: grid; grid-template-columns: 46px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef1f4; font-size: 11px; }
.activity-list time { color: #97a4b2; font: 600 10px ui-monospace, monospace; }
.activity-list span { color: var(--muted); }
.activity-list strong { font-size: 11px; }
.toolbar { min-height: 58px; padding: 10px 15px; display: flex; align-items: center; gap: 10px; background: var(--panel-soft); border-bottom: 1px solid var(--line); }
.toolbar > :last-child { margin-left: auto; }
.tabs { display: flex; gap: 3px; }
.tabs button { height: 34px; padding: 0 13px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; font-size: 11px; }
.tabs button.active { color: var(--blue); background: #eaf3ff; border-color: #d4e6ff; font-weight: 700; }
.search-box { width: min(310px, 35vw); height: 36px; padding: 0 11px; display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: white; color: #99a5b3; }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 11px; }
.primary-button, .ghost-button {
min-height: 34px; padding: 0 14px; border-radius: 8px; font-size: 11px; font-weight: 700; transition: .16s;
}
.primary-button { color: white; border: 1px solid var(--blue); background: var(--blue); box-shadow: 0 5px 12px rgba(31,114,229,.15); }
.primary-button:hover { background: #1767d2; }
.primary-button:disabled { opacity: .55; cursor: wait; }
.ghost-button { color: #476079; border: 1px solid var(--line); background: white; }
.ghost-button:hover { border-color: #b7c4d1; color: var(--blue); }
.table-wrap { overflow-x: auto; }
table { width: 100%; min-width: 850px; border-collapse: collapse; }
th { padding: 11px 14px; color: #8794a3; background: #fbfcfd; border-bottom: 1px solid var(--line); text-align: left; font-size: 10px; font-weight: 700; white-space: nowrap; }
td { padding: 12px 14px; border-bottom: 1px solid #edf0f4; color: #4c5a6c; font-size: 11px; white-space: nowrap; }
tbody tr:hover { background: #f9fbfd; }
td strong { color: var(--ink); }
td select { max-width: 150px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; background: white; color: var(--ink); font-size: 10px; }
.empty { height: 180px; text-align: center; color: #9aa5b4; }
.status { display: inline-flex; align-items: center; gap: 4px; padding: 3px 7px; border: 1px solid; border-radius: 12px; font-size: 9px; font-weight: 700; }
.status.ok { color: #17835c; border-color: #bce7d5; background: #effaf5; }
.status.warn { color: #9a6d0d; border-color: #f0d89d; background: #fff9e8; }
.status.bad { color: #b73939; border-color: #f0c3c3; background: #fff3f3; }
.status.muted { color: #6e7c8d; border-color: #dce2e8; background: #f6f8fa; }
.row-actions { display: flex; gap: 6px; }
.row-actions button { padding: 4px 8px; border: 1px solid #d9e1e9; border-radius: 6px; color: #52708f; background: white; font-size: 9px; }
.row-actions button:hover { color: var(--blue); border-color: #a9c8ed; }
.statistics-grid { padding: 28px; display: grid; grid-template-columns: repeat(4,1fr); gap: 15px; }
.statistics-grid > div { padding: 18px; border: 1px solid var(--line); border-radius: 10px; background: #fbfcfe; overflow: hidden; }
.statistics-grid span { color: var(--muted); font-size: 11px; }
.statistics-grid strong { display: block; margin: 9px 0 14px; font-size: 22px; }
.statistics-grid i { display: block; height: 5px; border-radius: 4px; background: linear-gradient(90deg, var(--blue), var(--cyan)); }
.device-grid { padding: 15px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.device-grid article { min-height: 160px; padding: 15px; border: 1px solid var(--line); border-radius: 10px; background: linear-gradient(145deg, #fff, #f7fafc); }
.device-grid article > div:first-child { display: flex; align-items: center; justify-content: space-between; }
.device-grid article h4 { margin: 16px 0 5px; font-size: 14px; }
.device-grid article p, .device-grid article small, .device-grid article footer { color: var(--muted); font-size: 10px; }
.device-grid article footer { margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line); }
.meter { height: 6px; margin: 16px 0 7px; overflow: hidden; background: #e7ebef; border-radius: 5px; }
.meter i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--green), var(--amber)); }
.alert-list { padding: 9px 15px 15px; display: grid; gap: 9px; }
.alert-list article { min-height: 86px; padding: 13px; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 13px; border: 1px solid #eadfbf; border-left: 3px solid var(--amber); border-radius: 9px; background: #fffdf7; }
.alert-list article.critical { border-color: #ecc7c7; border-left-color: var(--red); background: #fff9f9; }
.alert-sign { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; color: white; background: var(--amber); font-weight: 900; }
.critical .alert-sign { background: var(--red); }
.alert-title { display: flex; align-items: center; gap: 8px; }
.alert-title h3 { margin: 0; font-size: 13px; }
.alert-list p { margin: 5px 0; color: #596879; font-size: 11px; }
.alert-list small { color: #97a3af; font-size: 9px; }
.sub-toolbar { min-height: 50px; padding: 8px 15px; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 10px; border-bottom: 1px solid var(--line); }
.settings-form { max-width: 720px; padding: 24px; display: grid; gap: 14px; }
.settings-form > label, .modal-body label { display: grid; grid-template-columns: 110px 1fr auto; align-items: center; gap: 10px; }
.settings-form label span, .modal-body label span { color: var(--muted); font-size: 11px; }
.settings-form input, .settings-form select, .modal-body input {
height: 38px; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; outline: 0; color: var(--ink); background: white; font-size: 11px;
}
.settings-form input:focus, .modal-body input:focus { border-color: #8bb8ee; box-shadow: 0 0 0 3px rgba(31,114,229,.08); }
.settings-form small { color: var(--muted); font-size: 10px; }
.form-actions { padding-left: 120px; display: flex; align-items: center; gap: 9px; }
.simulator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.sim-card .card-head { background: #fbfcfd; }
.terminal {
width: 100%; min-height: 520px; padding: 48px 38px; display: flex; flex-direction: column; align-items: center; justify-content: center;
border: 18px solid #1e2b38; background: radial-gradient(circle at 50% 42%, #f9fcff, #eaf1f7); color: var(--ink); position: relative;
}
.terminal-camera { position: absolute; top: -12px; width: 7px; height: 7px; border-radius: 50%; background: #090e13; box-shadow: 0 0 0 2px #354758; }
.terminal > small { position: absolute; top: 14px; left: 17px; color: #7d8b99; font: 600 9px ui-monospace, monospace; }
.terminal-icon { width: 100px; height: 100px; display: grid; place-items: center; border: 2px solid var(--orange); border-radius: 50%; color: var(--orange); font-size: 38px; box-shadow: 0 0 0 12px rgba(240,138,53,.08); }
.terminal.blue .terminal-icon { color: var(--blue); border-color: var(--blue); box-shadow: 0 0 0 12px rgba(31,114,229,.08); }
.terminal h3 { margin: 33px 0 10px; font-size: 24px; }
.terminal p { color: var(--muted); font-size: 12px; }
.terminal > strong { margin-top: 25px; padding: 11px 18px; border-radius: 8px; color: white; background: var(--orange); font-size: 11px; }
.terminal.blue > strong { background: var(--blue); }
.step-dots { position: absolute; bottom: 20px; display: flex; gap: 7px; }
.step-dots i { width: 7px; height: 7px; border-radius: 50%; background: #c5ced6; }
.step-dots i.active { width: 22px; border-radius: 5px; background: var(--orange); }
.terminal.blue .step-dots i.active { background: var(--blue); }
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: rgba(12,24,37,.42); backdrop-filter: blur(4px); }
.modal { width: min(500px, calc(100vw - 30px)); border-radius: 14px; background: white; box-shadow: 0 28px 70px rgba(12,24,37,.25); overflow: hidden; }
.modal header { height: 60px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.modal header h2 { margin: 0; font-size: 16px; }
.modal header button { width: 32px; height: 32px; border: 0; border-radius: 8px; color: var(--muted); background: #f3f5f7; font-size: 20px; }
.modal-body { padding: 22px; display: grid; gap: 13px; }
.modal footer { padding: 13px 18px; display: flex; justify-content: end; gap: 8px; border-top: 1px solid var(--line); background: #fbfcfd; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 70; padding: 12px 16px; color: white; background: #17334b; border-radius: 9px; box-shadow: 0 13px 30px rgba(12,24,37,.25); font-size: 11px; animation: toast-in .25s ease; }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
@media (max-width: 1150px) {
.sidebar { width: 205px; }
.workspace { margin-left: 205px; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.device-grid { grid-template-columns: 1fr 1fr; }
.connection, .clock { display: none; }
}
@media (max-width: 820px) {
.sidebar { position: static; width: 100%; height: auto; }
.sidebar nav { display: flex; overflow-x: auto; }
.nav-group { display: contents; }
.nav-group p, .sidebar-foot { display: none; }
.nav-group button { min-width: 110px; justify-content: center; }
.workspace { margin-left: 0; }
.topbar { height: auto; min-height: 72px; align-items: flex-start; padding: 12px 14px; }
.topbar-right { flex-wrap: wrap; justify-content: end; }
.role-switch { order: 3; width: 100%; justify-content: end; }
.content { padding: 14px; }
.two-columns, .two-columns.wide-left, .simulator-grid { grid-template-columns: 1fr; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
.device-grid, .statistics-grid { grid-template-columns: 1fr; }
.search-box { width: 100%; }
.toolbar { align-items: stretch; flex-direction: column; }
.toolbar > :last-child { margin-left: 0; }
.tabs { overflow-x: auto; }
.live-scale { grid-template-columns: 1fr; gap: 18px; }
}
@media (max-width: 520px) {
.brand { min-height: 64px; }
.page-title span, .alert-button { display: none; }
.kpi-grid { grid-template-columns: 1fr; }
.greeting { align-items: flex-start; gap: 12px; flex-direction: column; }
.terminal { min-height: 430px; border-width: 11px; }
.modal-body label, .settings-form > label { grid-template-columns: 1fr; }
.form-actions { padding-left: 0; }
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:3000";
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
const baseUrl = new URL(`${protocol}://${host}`);
return {
metadataBase: baseUrl,
title: "智能回收运营平台",
description: "面向内网部署的称重、设备、人员、人脸与告警管理后台",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
openGraph: {
title: "智能回收运营平台",
description: "称重、设备、人员与告警的一体化内网管理平台",
type: "website",
images: [{ url: new URL("/og.png", baseUrl).toString(), width: 1672, height: 941 }],
},
twitter: {
card: "summary_large_image",
title: "智能回收运营平台",
description: "称重、设备、人员与告警的一体化内网管理平台",
images: [new URL("/og.png", baseUrl).toString()],
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AdminConsole } from "./AdminConsole";
export default function Home() {
return <AdminConsole />;
}
+13
View File
@@ -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 });
}
+127
View File
@@ -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(),
});
+184
View File
@@ -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(),
};
}
+14
View File
@@ -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:
+7
View File
@@ -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
}
]
}
+18
View File
@@ -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`),
});
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+11169
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+6
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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));
});
+34
View File
@@ -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"]
}
+59
View File
@@ -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,
}),
],
};
});
+47
View File
@@ -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;