Build smart recycling management platform
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.vinext/
|
||||
/out/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
next-env.d.ts
|
||||
/dist/
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a68c340b3808191b4014d448ed2bcb3",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# vinext-starter
|
||||
|
||||
A clean full-stack starter running on
|
||||
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
|
||||
Drizzle support.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js `>=22.13.0`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
This starter does not use `wrangler.jsonc`.
|
||||
|
||||
## Included Shape
|
||||
|
||||
- edit site code under `app/`
|
||||
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
|
||||
- `vite.config.ts` simulates declared bindings for local development
|
||||
- `db/schema.ts` starts intentionally empty
|
||||
- `examples/d1/` contains an optional D1 example surface
|
||||
- `drizzle.config.ts` supports local migration generation when needed
|
||||
|
||||
## Workspace Auth Headers
|
||||
|
||||
OpenAI workspace sites can read the current user's email from
|
||||
`oai-authenticated-user-email`.
|
||||
|
||||
SIWC-authenticated workspace sites may also receive
|
||||
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
|
||||
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
|
||||
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
|
||||
|
||||
Treat the full name as optional and fall back to email when it is absent:
|
||||
|
||||
```tsx
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function Home() {
|
||||
const requestHeaders = await headers();
|
||||
const email = requestHeaders.get("oai-authenticated-user-email");
|
||||
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
|
||||
"percent-encoded-utf-8"
|
||||
? decodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
const displayName = fullName ?? email;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Optional Dispatch-Owned ChatGPT Sign-In
|
||||
|
||||
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
|
||||
optional or required ChatGPT sign-in:
|
||||
|
||||
- Use `getChatGPTUser()` for optional signed-in UI.
|
||||
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
|
||||
anonymous visitors through Sign in with ChatGPT.
|
||||
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
|
||||
browser links or actions.
|
||||
- Pass a same-origin relative `returnTo` path for the destination after sign-in
|
||||
or sign-out. The helper validates and safely encodes it.
|
||||
- Mark protected pages with `export const dynamic = "force-dynamic"` because
|
||||
they depend on per-request identity headers.
|
||||
|
||||
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
|
||||
OAuth cookies, and identity header injection. Do not implement app routes for
|
||||
those reserved paths. Routes that do not import and call the helper remain
|
||||
anonymous-compatible.
|
||||
|
||||
SIWC establishes identity only; it does not prove workspace membership. Use the
|
||||
Sites hosting platform's access policy controls for workspace-wide restrictions,
|
||||
or enforce explicit server-side membership or allowlist checks.
|
||||
|
||||
Use SIWC for account pages, user-specific dashboards, saved records, and write
|
||||
actions tied to the current ChatGPT user. Leave public content anonymous.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
- `npm run dev`: start local development
|
||||
- `npm run build`: verify the vinext build output
|
||||
- `npm test`: build the starter and verify its rendered loading skeleton
|
||||
- `npm run db:generate`: generate Drizzle migrations after schema changes
|
||||
|
||||
## Learn More
|
||||
|
||||
- [vinext Documentation](https://github.com/cloudflare/vinext)
|
||||
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+2000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
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 base = new URL(`${protocol}://${host}`);
|
||||
const title = "沃尔核材智能回收管理平台";
|
||||
const description = "统一接入人脸识别回收箱、电子台秤、地磅与 RFID 系统的生产回收管理平台。";
|
||||
|
||||
return {
|
||||
metadataBase: base,
|
||||
title,
|
||||
description,
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: "website",
|
||||
images: [{ url: new URL("/og.png", base).toString(), width: 1731, height: 909, alt: title }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: [new URL("/og.png", base).toString()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+805
@@ -0,0 +1,805 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type PageKey =
|
||||
| "dashboard"
|
||||
| "waste"
|
||||
| "events"
|
||||
| "floor"
|
||||
| "trace"
|
||||
| "assets"
|
||||
| "alerts"
|
||||
| "settings"
|
||||
| "terminal";
|
||||
|
||||
type RoleKey = "admin" | "warehouse" | "operator";
|
||||
|
||||
const pageMeta: Record<PageKey, { title: string; sub: string }> = {
|
||||
dashboard: { title: "运营驾驶舱", sub: "全厂设备与称重业务总览" },
|
||||
waste: { title: "废料称重", sub: "电子台秤与人脸回收箱原始记录" },
|
||||
events: { title: "回收事件", sub: "人员进出、投放与视频事件归档" },
|
||||
floor: { title: "地磅核对", sub: "台秤汇总与地磅增量自动比对" },
|
||||
trace: { title: "RFID 追溯", sub: "导体称重、批次、标签与 MES 推送" },
|
||||
assets: { title: "人员与设备", sub: "人员、产线、回收设备统一管理" },
|
||||
alerts: { title: "告警中心", sub: "偏差、满溢、离线与接口异常" },
|
||||
settings: { title: "系统设置", sub: "设备接入、权限、字典、MES 与审计" },
|
||||
terminal: { title: "终端模拟", sub: "回收箱与导体称重操作流程" },
|
||||
};
|
||||
|
||||
const navGroups: Array<{
|
||||
label: string;
|
||||
items: Array<{ key: PageKey; label: string; icon: string; roles: RoleKey[] }>;
|
||||
}> = [
|
||||
{
|
||||
label: "总览",
|
||||
items: [
|
||||
{ key: "dashboard", label: "运营驾驶舱", icon: "⌂", roles: ["admin", "warehouse", "operator"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "废料回收",
|
||||
items: [
|
||||
{ key: "waste", label: "废料称重", icon: "◫", roles: ["admin", "warehouse"] },
|
||||
{ key: "events", label: "回收事件", icon: "◎", roles: ["admin", "warehouse"] },
|
||||
{ key: "floor", label: "地磅核对", icon: "⚖", roles: ["admin", "warehouse"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "导体称重",
|
||||
items: [{ key: "trace", label: "RFID 追溯", icon: "◇", roles: ["admin", "operator"] }],
|
||||
},
|
||||
{
|
||||
label: "运营管理",
|
||||
items: [
|
||||
{ key: "assets", label: "人员与设备", icon: "◉", roles: ["admin", "warehouse", "operator"] },
|
||||
{ key: "alerts", label: "告警中心", icon: "!", roles: ["admin"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "系统",
|
||||
items: [
|
||||
{ key: "settings", label: "系统设置", icon: "≡", roles: ["admin"] },
|
||||
{ key: "terminal", label: "终端模拟", icon: "▣", roles: ["admin", "warehouse", "operator"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const wasteRows = [
|
||||
{ time: "13:33:05", name: "李四", machine: "MC-108", category: "废锡", device: "智能回收箱 B", weight: 19.5, event: "EVT-0722-006", status: "已确认" },
|
||||
{ time: "13:31:12", name: "李四", machine: "MC-105", category: "废铜", device: "电子台秤 CU-01", weight: 9.9, event: "EVT-0722-006", status: "已确认" },
|
||||
{ time: "11:08:59", name: "赵六", machine: "MC-112", category: "废电线", device: "电子台秤 WR-01", weight: 14.5, event: "EVT-0722-005", status: "已确认" },
|
||||
{ time: "11:07:44", name: "赵六", machine: "MC-101", category: "废铝", device: "电子台秤 AL-01", weight: 13.3, event: "EVT-0722-005", status: "已确认" },
|
||||
{ time: "10:25:07", name: "张三", machine: "MC-105", category: "废电线", device: "电子台秤 WR-01", weight: 11.5, event: "EVT-0722-004", status: "偏差预警" },
|
||||
{ time: "09:46:20", name: "王五", machine: "MC-112", category: "废铜", device: "电子台秤 CU-01", weight: 21.2, event: "EVT-0722-003", status: "偏差预警" },
|
||||
{ time: "09:44:03", name: "王五", machine: "MC-112", category: "废铜", device: "电子台秤 CU-01", weight: 11.4, event: "EVT-0722-003", status: "偏差预警" },
|
||||
{ time: "08:16:35", name: "张三", machine: "MC-103", category: "废锡", device: "人脸回收箱 A", weight: 8.7, event: "EVT-0722-001", status: "已确认" },
|
||||
];
|
||||
|
||||
const eventRows = [
|
||||
{ id: "EVT-20260722-006", name: "李四", span: "13:30:26 → 13:34:02", duration: "3分36秒", count: 2, total: 29.4, result: "正常" },
|
||||
{ id: "EVT-20260722-005", name: "赵六", span: "11:05:12 → 11:09:48", duration: "4分36秒", count: 3, total: 44.6, result: "正常" },
|
||||
{ id: "EVT-20260722-004", name: "张三", span: "10:22:45 → 10:26:10", duration: "3分25秒", count: 2, total: 17.9, result: "超标" },
|
||||
{ id: "EVT-20260722-003", name: "王五", span: "09:41:07 → 09:47:33", duration: "6分26秒", count: 3, total: 51.2, result: "警告" },
|
||||
{ id: "EVT-20260722-002", name: "李四", span: "09:02:18 → 09:05:52", duration: "3分34秒", count: 2, total: 22.8, result: "正常" },
|
||||
];
|
||||
|
||||
const floorRows = [
|
||||
{ event: "EVT-0722-001", category: "废铜", bench: 19.8, floor: 20.0, diff: 0.2, rate: 1.0, result: "正常", stored: "自动入库" },
|
||||
{ event: "EVT-0722-002", category: "废锡", bench: 8.3, floor: 8.4, diff: 0.1, rate: 1.2, result: "正常", stored: "自动入库" },
|
||||
{ event: "EVT-0722-003", category: "废铜", bench: 51.2, floor: 53.6, diff: 2.4, rate: 4.7, result: "警告", stored: "已入库·预警" },
|
||||
{ event: "EVT-0722-004", category: "废电线", bench: 17.9, floor: 19.35, diff: 1.45, rate: 8.1, result: "超标", stored: "已入库·异常" },
|
||||
{ event: "EVT-0722-005", category: "废铝", bench: 13.3, floor: 13.5, diff: 0.2, rate: 1.5, result: "正常", stored: "自动入库" },
|
||||
{ event: "EVT-0722-006", category: "废锡", bench: 19.5, floor: 19.75, diff: 0.25, rate: 1.3, result: "正常", stored: "自动入库" },
|
||||
];
|
||||
|
||||
const traceRows = [
|
||||
{ time: "13:32:08", workshop: "六楼车间", task: "WT-0722-07", rfid: "E280-6102-0351", tare: 149.8, gross: 270.5, machine: "MC-205", batch: "B2607-020", name: "赵六", push: "已推送" },
|
||||
{ time: "11:07:52", workshop: "六楼车间", task: "WT-0722-06", rfid: "E280-6102-0318", tare: 143.6, gross: 232.2, machine: "MC-205", batch: "B2607-020", name: "赵六", push: "重试中" },
|
||||
{ time: "10:20:36", workshop: "一楼新能源车间", task: "WT-0722-05", rfid: "E280-6102-0345", tare: 158.4, gross: 289.9, machine: "MC-201", batch: "B2607-018", name: "李四", push: "已推送" },
|
||||
{ time: "09:48:15", workshop: "一楼新能源车间", task: "WT-0722-04", rfid: "E280-6102-0340", tare: 151.9, gross: 247.6, machine: "MC-203", batch: "B2607-019", name: "王五", push: "待推送" },
|
||||
{ time: "09:04:41", workshop: "一楼新能源车间", task: "WT-0722-03", rfid: "E280-6102-0335", tare: 165.2, gross: 301.8, machine: "MC-203", batch: "B2607-019", name: "王五", push: "已推送" },
|
||||
];
|
||||
|
||||
const tagSeed = [
|
||||
{ id: "E280-6102-0331", name: "1# 导体轴", state: "在用", last: "今天 08:15" },
|
||||
{ id: "E280-6102-0335", name: "3# 导体轴", state: "在用", last: "今天 09:04" },
|
||||
{ id: "E280-6102-0345", name: "5# 导体轴", state: "在用", last: "今天 10:20" },
|
||||
{ id: "E280-6102-0360", name: "备用标签 A", state: "未用", last: "—" },
|
||||
{ id: "E280-6102-0318", name: "7# 导体轴", state: "损坏", last: "昨天 15:03" },
|
||||
];
|
||||
|
||||
const people = [
|
||||
{ name: "张三", id: "MES-0117", workshop: "一楼新能源车间", role: "仓管员", face: "已录入", sync: "已同步", last: "今日 10:26" },
|
||||
{ name: "李四", id: "MES-0132", workshop: "一楼新能源车间", role: "产线工人", face: "需更新", sync: "已同步", last: "今日 13:34" },
|
||||
{ name: "王五", id: "MES-0098", workshop: "一楼新能源车间", role: "产线工人", face: "未录入", sync: "已同步", last: "今日 09:47" },
|
||||
{ name: "赵六", id: "MES-0201", workshop: "六楼车间", role: "产线工人", face: "已录入", sync: "已同步", last: "今日 11:09" },
|
||||
{ name: "王工", id: "MES-0007", workshop: "一楼新能源车间", role: "管理员", face: "已录入", sync: "已同步", last: "今日 13:40" },
|
||||
];
|
||||
|
||||
const machines = [
|
||||
{ id: "MC-101", name: "拉丝机 1#", type: "拉丝机", workshop: "一楼新能源车间", state: "运行中", mes: "正常" },
|
||||
{ id: "MC-105", name: "绞线机 1#", type: "绞线机", workshop: "一楼新能源车间", state: "运行中", mes: "正常" },
|
||||
{ id: "MC-112", name: "挤塑机 1#", type: "挤塑机", workshop: "六楼车间", state: "维护中", mes: "正常" },
|
||||
{ id: "MC-201", name: "导体加工线 1#", type: "导体加工线", workshop: "一楼新能源车间", state: "运行中", mes: "正常" },
|
||||
{ id: "MC-205", name: "导体加工线 3#", type: "导体加工线", workshop: "六楼车间", state: "停机", mes: "断连" },
|
||||
];
|
||||
|
||||
const devices = [
|
||||
{ id: "BIN-FACE-01", name: "人脸识别回收箱 A", type: "智能回收箱", place: "一楼废料库", protocol: "HTTPS / WebSocket", state: "在线", heartbeat: "刚刚", value: "62%" },
|
||||
{ id: "BIN-FACE-02", name: "人脸识别回收箱 B", type: "智能回收箱", place: "六楼车间", protocol: "HTTPS / WebSocket", state: "在线", heartbeat: "2 秒前", value: "86%" },
|
||||
{ id: "SCALE-B-01", name: "废铜电子台秤", type: "电子台秤", place: "废铜区", protocol: "RS232 → 边缘网关", state: "在线", heartbeat: "1 秒前", value: "12.61 kg" },
|
||||
{ id: "SCALE-F-01", name: "3 吨地磅", type: "地磅系统", place: "一楼废料库", protocol: "Modbus TCP", state: "在线", heartbeat: "刚刚", value: "1843.2 kg" },
|
||||
{ id: "RFID-UHF-01", name: "UHF RFID 读写器", type: "RFID 系统", place: "导体称重线", protocol: "TCP / LLRP", state: "在线", heartbeat: "3 秒前", value: "99.1%" },
|
||||
{ id: "EDGE-GW-01", name: "车间边缘网关", type: "边缘网关", place: "工控机房", protocol: "MQTT over TLS", state: "在线", heartbeat: "刚刚", value: "18 ms" },
|
||||
];
|
||||
|
||||
const alerts = [
|
||||
{ level: "紧急", time: "10:26", type: "双秤偏差", source: "地磅核对", ref: "EVT-20260722-004 · 废电线", detail: "偏差率 8.1%,超过红色阈值 5%", state: "待处理" },
|
||||
{ level: "警告", time: "09:47", type: "双秤偏差", source: "地磅核对", ref: "EVT-20260722-003 · 废铜", detail: "偏差率 4.7%,建议人工复核", state: "待处理" },
|
||||
{ level: "警告", time: "09:12", type: "满溢预警", source: "人脸回收箱 B", ref: "BIN-FACE-02", detail: "满溢率 86%,已通知清运", state: "处理中" },
|
||||
{ level: "提示", time: "07:55", type: "设备离线", source: "电子台秤", ref: "SCALE-B-01", detail: "心跳中断 42 秒后自动恢复", state: "已关闭" },
|
||||
];
|
||||
|
||||
const logs = [
|
||||
["13:40:12", "王工", "修改告警阈值", "双秤偏差警告阈值 3% → 2%", "敏感"],
|
||||
["11:02:33", "王工", "同步 MES 人员", "增量更新 3 条人员数据", "正常"],
|
||||
["10:28:01", "王工", "审核告警", "EVT-20260722-003 转人工复核", "正常"],
|
||||
["09:15:47", "王工", "导出报表", "废料称重记录 · 今日全部", "正常"],
|
||||
["08:50:22", "王工", "更新人脸", "李四 MES-0132 · 旧特征已归档", "敏感"],
|
||||
];
|
||||
|
||||
const integrationCards = [
|
||||
{ icon: "▦", title: "人脸识别回收箱", protocol: "HTTPS / WebSocket", detail: "人脸身份、门磁、满溢、投放重量、抓拍事件", device: "2 台", status: "待现场联调" },
|
||||
{ icon: "⚖", title: "电子台秤", protocol: "RS232 / Modbus-RTU", detail: "稳定值、毛重/皮重、精度、去皮、设备心跳", device: "4 台", status: "待协议参数" },
|
||||
{ icon: "▰", title: "地磅系统", protocol: "RS485 / Modbus TCP", detail: "进场重量、离场重量、净重、车辆/事件关联", device: "1 套", status: "待协议参数" },
|
||||
{ icon: "◇", title: "RFID 系统", protocol: "TCP / LLRP / 串口", detail: "EPC、读写、标签状态、称重任务和批次绑定", device: "1 套", status: "待协议参数" },
|
||||
];
|
||||
|
||||
function Badge({ value }: { value: string }) {
|
||||
const kind =
|
||||
/正常|在线|已确认|已同步|已录入|已推送|自动入库|运行中|在用/.test(value)
|
||||
? "ok"
|
||||
: /超标|紧急|断连|损坏|未录入/.test(value)
|
||||
? "danger"
|
||||
: /警告|预警|重试|待|维护|需更新|处理中/.test(value)
|
||||
? "warn"
|
||||
: "muted";
|
||||
return <span className={`badge badge-${kind}`}>{value}</span>;
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return <div className="empty">{children}</div>;
|
||||
}
|
||||
|
||||
function PageHeaderActions({
|
||||
onExport,
|
||||
onRefresh,
|
||||
}: {
|
||||
onExport?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-actions">
|
||||
{onRefresh && (
|
||||
<button className="button secondary" onClick={onRefresh}>
|
||||
↻ 刷新
|
||||
</button>
|
||||
)}
|
||||
{onExport && (
|
||||
<button className="button primary" onClick={onExport}>
|
||||
↓ 导出报表
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({
|
||||
liveWeight,
|
||||
notify,
|
||||
}: {
|
||||
liveWeight: number;
|
||||
notify: (text: string) => void;
|
||||
}) {
|
||||
const metric = [
|
||||
["今日回收总量", "386.4", "kg", "+12.6% 较昨日", "mint"],
|
||||
["今日回收事件", "42", "次", "6 个完整进出事件", "blue"],
|
||||
["在线设备", "10 / 10", "台", "边缘网关链路正常", "violet"],
|
||||
["待处理告警", "2", "条", "含 1 条红色告警", "orange"],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<section className="hero-panel">
|
||||
<div>
|
||||
<div className="eyebrow">SMART RECYCLING CONTROL CENTER</div>
|
||||
<h2>今天的设备与业务都在正常运转</h2>
|
||||
<p>所有称重记录、人员身份、RFID 标签和 MES 推送在同一条数据链路上完成追溯。</p>
|
||||
</div>
|
||||
<div className="hero-status">
|
||||
<span className="pulse-dot" />
|
||||
<div>
|
||||
<b>边缘网关在线</b>
|
||||
<small>最近心跳:刚刚 · 延迟 18ms</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metric-grid">
|
||||
{metric.map(([label, value, unit, hint, tone]) => (
|
||||
<article className={`metric tone-${tone}`} key={label}>
|
||||
<span>{label}</span>
|
||||
<div>
|
||||
<strong>{value}</strong>
|
||||
<em>{unit}</em>
|
||||
</div>
|
||||
<small>{hint}</small>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-grid">
|
||||
<article className="card live-card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<span className="section-kicker">LIVE SIGNAL</span>
|
||||
<h3>实时称重</h3>
|
||||
</div>
|
||||
<Badge value="电子台秤在线" />
|
||||
</div>
|
||||
<div className="live-readout">
|
||||
<span>{liveWeight.toFixed(2)}</span>
|
||||
<small>kg</small>
|
||||
</div>
|
||||
<div className="stability">
|
||||
<span className="stability-bar" />
|
||||
稳定采样中 · 精度 ±10g
|
||||
</div>
|
||||
<div className="live-meta">
|
||||
<div><small>当前操作人</small><b>王工</b></div>
|
||||
<div><small>来源机台</small><b>MC-101</b></div>
|
||||
<div><small>废料类别</small><b>废铜</b></div>
|
||||
</div>
|
||||
<button className="button primary block" onClick={() => notify("已模拟生成一条稳定称重记录")}>
|
||||
生成稳定值记录
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article className="card trend-card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<span className="section-kicker">7 DAY TREND</span>
|
||||
<h3>近 7 日回收趋势</h3>
|
||||
</div>
|
||||
<span className="quiet">单位:kg</span>
|
||||
</div>
|
||||
<div className="bars">
|
||||
{[68, 45, 82, 56, 92, 74, 88].map((height, index) => (
|
||||
<div className="bar-wrap" key={height + index}>
|
||||
<div className="bar-value">{[301, 246, 362, 278, 418, 344, 386][index]}</div>
|
||||
<div className="bar" style={{ height: `${height}%` }} />
|
||||
<small>{["一", "二", "三", "四", "五", "六", "日"][index]}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="trend-legend">
|
||||
<span><i className="legend-dot copper" />废铜 42%</span>
|
||||
<span><i className="legend-dot wire" />废电线 28%</span>
|
||||
<span><i className="legend-dot aluminum" />废铝 19%</span>
|
||||
<span><i className="legend-dot tin" />废锡 11%</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card device-health">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<span className="section-kicker">DEVICE HEALTH</span>
|
||||
<h3>设备健康</h3>
|
||||
</div>
|
||||
<Badge value="全部在线" />
|
||||
</div>
|
||||
{[
|
||||
["人脸回收箱", "2 / 2", "98%"],
|
||||
["电子台秤", "4 / 4", "99%"],
|
||||
["地磅系统", "1 / 1", "97%"],
|
||||
["RFID 读写器", "1 / 1", "99%"],
|
||||
["边缘网关", "1 / 1", "100%"],
|
||||
].map(([name, count, health]) => (
|
||||
<div className="health-row" key={name}>
|
||||
<span className="online-dot" />
|
||||
<div><b>{name}</b><small>{count} 在线</small></div>
|
||||
<strong>{health}</strong>
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="split-grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<span className="section-kicker">RECENT RECORDS</span>
|
||||
<h3>最近称重流水</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="compact-list">
|
||||
{wasteRows.slice(0, 5).map((row) => (
|
||||
<div className="compact-row" key={row.time + row.machine}>
|
||||
<span className="category-mark" data-category={row.category} />
|
||||
<div><b>{row.name} · {row.machine}</b><small>{row.category} · {row.device}</small></div>
|
||||
<strong>{row.weight.toFixed(2)} kg</strong>
|
||||
<span>{row.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<span className="section-kicker">INTEGRATION FLOW</span>
|
||||
<h3>统一设备链路</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flow">
|
||||
<div><span>01</span><b>现场设备</b><small>人脸箱 · 台秤 · 地磅 · RFID</small></div>
|
||||
<i>→</i>
|
||||
<div><span>02</span><b>边缘网关</b><small>协议适配 · 断网缓存</small></div>
|
||||
<i>→</i>
|
||||
<div><span>03</span><b>管理平台</b><small>业务规则 · 告警 · 追溯</small></div>
|
||||
<i>→</i>
|
||||
<div><span>04</span><b>MES</b><small>人员 · 机台 · 批次 · 上报</small></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WastePage({ notify }: { notify: (text: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("全部");
|
||||
const [view, setView] = useState<"list" | "stats">("list");
|
||||
const filtered = wasteRows.filter(
|
||||
(row) =>
|
||||
(category === "全部" || row.category === category) &&
|
||||
[row.name, row.machine, row.event, row.device].join(" ").toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<div className="segmented">
|
||||
{["全部", "废铜", "废电线", "废铝", "废锡"].map((item) => (
|
||||
<button className={category === item ? "active" : ""} onClick={() => setCategory(item)} key={item}>{item}</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="search-input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索操作人、机台、事件 ID…" />
|
||||
<PageHeaderActions onRefresh={() => notify("称重记录已刷新")} onExport={() => exportCsv("废料称重记录", filtered)} />
|
||||
</div>
|
||||
<div className="tabs">
|
||||
<button className={view === "list" ? "active" : ""} onClick={() => setView("list")}>记录列表</button>
|
||||
<button className={view === "stats" ? "active" : ""} onClick={() => setView("stats")}>统计视图</button>
|
||||
</div>
|
||||
{view === "list" ? (
|
||||
filtered.length ? (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>称重时间</th><th>操作人</th><th>关联机台</th><th>废料类别</th><th>来源设备</th><th className="number">精确重量</th><th>事件 ID</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{filtered.map((row) => (
|
||||
<tr key={row.time + row.machine}><td>{row.time}</td><td>{row.name}</td><td className="mono">{row.machine}</td><td><span className="category-cell"><i className="category-mark" data-category={row.category} />{row.category}</span></td><td>{row.device}</td><td className="number strong">{row.weight.toFixed(2)} kg</td><td className="mono">{row.event}</td><td><Badge value={row.status} /></td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Empty>没有符合筛选条件的称重记录</Empty>
|
||||
) : (
|
||||
<div className="stats-view">
|
||||
<div className="stats-numbers">
|
||||
<article><span>今日总量</span><b>386.4 kg</b><small>较昨日 +12.6%</small></article>
|
||||
<article><span>称重次数</span><b>42 次</b><small>原子记录 42 条</small></article>
|
||||
<article><span>本月累计</span><b>8.52 t</b><small>自动入库率 100%</small></article>
|
||||
<article><span>日均投放</span><b>392 kg</b><small>近 30 日均值</small></article>
|
||||
</div>
|
||||
<div className="rank-list">
|
||||
{[["MC-112", 89], ["MC-101", 78], ["MC-108", 63], ["MC-105", 51]].map(([name, width], index) => (
|
||||
<div key={name}><span>{index + 1}</span><b>{name}</b><i><em style={{ width: `${width}%` }} /></i><strong>{width} kg</strong></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsPage({ notify }: { notify: (text: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [result, setResult] = useState("全部");
|
||||
const filtered = eventRows.filter((row) => (result === "全部" || row.result === result) && `${row.id}${row.name}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<div className="segmented">
|
||||
{["全部", "正常", "警告", "超标"].map((item) => <button key={item} className={result === item ? "active" : ""} onClick={() => setResult(item)}>{item}</button>)}
|
||||
</div>
|
||||
<input className="search-input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索事件 ID 或操作人…" />
|
||||
<PageHeaderActions onRefresh={() => notify("事件列表已刷新")} onExport={() => exportCsv("回收事件", filtered)} />
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>事件 ID</th><th>操作人</th><th>进入 → 离开</th><th>历时</th><th>原子笔数</th><th className="number">台秤总重</th><th>判定</th><th>视频</th></tr></thead>
|
||||
<tbody>
|
||||
{filtered.map((row) => <tr key={row.id}><td className="mono">{row.id}</td><td>{row.name}</td><td className="mono">{row.span}</td><td>{row.duration}</td><td>{row.count} 条</td><td className="number strong">{row.total.toFixed(2)} kg</td><td><Badge value={row.result} /></td><td><button className="text-button" onClick={() => notify(`${row.id} 的监控录像已定位`)}>查看录像</button></td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorPage({ notify }: { notify: (text: string) => void }) {
|
||||
const [result, setResult] = useState("全部");
|
||||
const filtered = floorRows.filter((row) => result === "全部" || row.result === result);
|
||||
return (
|
||||
<>
|
||||
<section className="rule-strip">
|
||||
<div><span>自动比对规则</span><b>正常 ≤ 2%</b><b className="warn-text">警告 2%—5%</b><b className="danger-text">超标 > 5%</b></div>
|
||||
<button className="button secondary" onClick={() => notify("阈值设置已打开,正式版将写入审计日志")}>调整阈值</button>
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="toolbar">
|
||||
<div className="segmented">
|
||||
{["全部", "正常", "警告", "超标"].map((item) => <button key={item} className={result === item ? "active" : ""} onClick={() => setResult(item)}>{item}</button>)}
|
||||
</div>
|
||||
<PageHeaderActions onExport={() => exportCsv("地磅核对", filtered)} />
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>事件 ID</th><th>废料类别</th><th className="number">台秤汇总</th><th className="number">地磅增量</th><th className="number">偏差值</th><th className="number">偏差率</th><th>判定</th><th>入库状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{filtered.map((row) => <tr key={row.event + row.category}><td className="mono">{row.event}</td><td>{row.category}</td><td className="number">{row.bench.toFixed(2)} kg</td><td className="number">{row.floor.toFixed(2)} kg</td><td className="number strong">{row.diff.toFixed(2)} kg</td><td className={`number strong ${row.result === "超标" ? "danger-text" : row.result === "警告" ? "warn-text" : ""}`}>{row.rate.toFixed(1)}%</td><td><Badge value={row.result} /></td><td><Badge value={row.stored} /></td><td><button className="text-button" onClick={() => notify(`${row.event} 已进入人工复核流程`)}>人工复核</button></td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TracePage({ notify }: { notify: (text: string) => void }) {
|
||||
const [tab, setTab] = useState<"trace" | "tags">("trace");
|
||||
const [query, setQuery] = useState("");
|
||||
const [tags, setTags] = useState(tagSeed);
|
||||
const rows = traceRows.filter((row) => Object.values(row).join(" ").toLowerCase().includes(query.toLowerCase()));
|
||||
const addTag = () => {
|
||||
const next = { id: `E280-6102-${String(370 + tags.length)}`, name: `备用标签 ${String.fromCharCode(65 + tags.length)}`, state: "未用", last: "—" };
|
||||
setTags((current) => [...current, next]);
|
||||
notify(`RFID 标签 ${next.id} 已添加`);
|
||||
};
|
||||
return (
|
||||
<section className="card">
|
||||
<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" ? (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<input className="search-input wide" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务号、RFID、批次号、机台…" />
|
||||
<PageHeaderActions onRefresh={() => notify("RFID 数据已刷新")} onExport={() => exportCsv("RFID称重追溯", rows)} />
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>所属车间</th><th>任务号</th><th>RFID 标签</th><th className="number">皮重</th><th className="number">毛重</th><th className="number">净重</th><th>机台</th><th>批次</th><th>操作人</th><th>MES</th></tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.task}><td>{row.time}</td><td>{row.workshop}</td><td className="mono">{row.task}</td><td className="mono">{row.rfid}</td><td className="number">{row.tare.toFixed(2)}</td><td className="number">{row.gross.toFixed(2)}</td><td className="number strong">{(row.gross - row.tare).toFixed(2)} kg</td><td className="mono">{row.machine}</td><td className="mono">{row.batch}</td><td>{row.name}</td><td><Badge value={row.push} /></td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<span className="quiet">标签写入、检测、报废与称重任务绑定</span>
|
||||
<button className="button primary push-right" onClick={addTag}>+ 添加标签</button>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>标签 EPC</th><th>名称</th><th>状态</th><th>最后检测</th><th>操作</th></tr></thead>
|
||||
<tbody>{tags.map((tag) => <tr key={tag.id}><td className="mono">{tag.id}</td><td>{tag.name}</td><td><Badge value={tag.state} /></td><td>{tag.last}</td><td><button className="text-button" onClick={() => notify(`${tag.id} 检测完成:信号正常`)}>检测</button><button className="text-button danger-text" onClick={() => setTags((current) => current.map((item) => item.id === tag.id ? { ...item, state: "损坏" } : item))}>标记损坏</button></td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetsPage({ notify }: { notify: (text: string) => void }) {
|
||||
const [tab, setTab] = useState<"people" | "machines" | "devices">("people");
|
||||
return (
|
||||
<section className="card">
|
||||
<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" && (
|
||||
<>
|
||||
<div className="toolbar"><span className="quiet">人员主数据以 MES 为准,本系统维护人脸与使用权限</span><button className="button primary push-right" onClick={() => notify("MES 人员增量同步完成:更新 3 条")}>↻ 同步 MES</button></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>姓名</th><th>MES 员工 ID</th><th>所属车间</th><th>角色</th><th>人脸状态</th><th>同步状态</th><th>最近操作</th><th>操作</th></tr></thead><tbody>{people.map((person) => <tr key={person.id}><td className="strong">{person.name}</td><td className="mono">{person.id}</td><td>{person.workshop}</td><td>{person.role}</td><td><Badge value={person.face} /></td><td><Badge value={person.sync} /></td><td>{person.last}</td><td><button className="text-button" onClick={() => notify(`${person.name} 的人脸特征管理已打开`)}>人脸管理</button><button className="text-button">编辑</button></td></tr>)}</tbody></table></div>
|
||||
</>
|
||||
)}
|
||||
{tab === "machines" && (
|
||||
<div className="table-scroll"><table><thead><tr><th>机台编号</th><th>机台名称</th><th>类型</th><th>所属车间</th><th>运行状态</th><th>MES 链路</th></tr></thead><tbody>{machines.map((machine) => <tr key={machine.id}><td className="mono">{machine.id}</td><td className="strong">{machine.name}</td><td>{machine.type}</td><td>{machine.workshop}</td><td><Badge value={machine.state} /></td><td><Badge value={machine.mes} /></td></tr>)}</tbody></table></div>
|
||||
)}
|
||||
{tab === "devices" && (
|
||||
<div className="device-grid">
|
||||
{devices.map((device) => <article className="device-card" key={device.id}><div className="device-card-top"><span className="online-dot" /><Badge value={device.state} /></div><h3>{device.name}</h3><p>{device.id} · {device.place}</p><div className="device-value">{device.value}</div><dl><div><dt>设备类型</dt><dd>{device.type}</dd></div><div><dt>通信方式</dt><dd>{device.protocol}</dd></div><div><dt>最后心跳</dt><dd>{device.heartbeat}</dd></div></dl><button className="button secondary block" onClick={() => notify(`${device.name} 诊断完成:通信正常`)}>运行诊断</button></article>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage({ notify }: { notify: (text: string) => void }) {
|
||||
const [items, setItems] = useState(alerts);
|
||||
return (
|
||||
<>
|
||||
<section className="alert-summary">
|
||||
<div><span>紧急</span><b>1</b></div><div><span>警告</span><b>2</b></div><div><span>处理中</span><b>1</b></div><div><span>今日已关闭</span><b>8</b></div>
|
||||
<button className="button secondary push-right" onClick={() => notify("告警阈值设置已打开")}>阈值设置</button>
|
||||
</section>
|
||||
<section className="alert-list">
|
||||
{items.map((item) => (
|
||||
<article className={`alert-card alert-${item.level}`} key={item.time + item.ref}>
|
||||
<div className="alert-level"><span>{item.level}</span><b>{item.time}</b></div>
|
||||
<div className="alert-main"><div><h3>{item.type}</h3><Badge value={item.state} /></div><p>{item.ref}</p><small>{item.detail}</small></div>
|
||||
<div className="alert-actions"><button className="button secondary" onClick={() => notify(`${item.ref} 的详情已定位`)}>查看详情</button>{item.state !== "已关闭" && <button className="button primary" onClick={() => setItems((current) => current.map((alert) => alert.ref === item.ref ? { ...alert, state: "已关闭" } : alert))}>关闭告警</button>}</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage({ notify }: { notify: (text: string) => void }) {
|
||||
const [tab, setTab] = useState<"integration" | "accounts" | "workshops" | "mes" | "logs">("integration");
|
||||
const [testing, setTesting] = useState("");
|
||||
const test = (title: string) => {
|
||||
setTesting(title);
|
||||
window.setTimeout(() => {
|
||||
setTesting("");
|
||||
notify(`${title} 接入测试完成:等待现场网络和协议参数`);
|
||||
}, 700);
|
||||
};
|
||||
return (
|
||||
<section className="card settings-card">
|
||||
<div className="tabs scroll-tabs">
|
||||
<button className={tab === "integration" ? "active" : ""} onClick={() => setTab("integration")}>设备接入中心</button>
|
||||
<button className={tab === "accounts" ? "active" : ""} onClick={() => setTab("accounts")}>账号与权限</button>
|
||||
<button className={tab === "workshops" ? "active" : ""} onClick={() => setTab("workshops")}>车间字典</button>
|
||||
<button className={tab === "mes" ? "active" : ""} onClick={() => setTab("mes")}>MES 接口</button>
|
||||
<button className={tab === "logs" ? "active" : ""} onClick={() => setTab("logs")}>操作日志</button>
|
||||
</div>
|
||||
{tab === "integration" && (
|
||||
<div className="integration-page">
|
||||
<div className="integration-intro">
|
||||
<div><span className="section-kicker">UNIFIED DEVICE ACCESS</span><h3>四类硬件统一接入</h3><p>现场串口、RS485 和局域网设备先连接边缘网关,再通过加密 MQTT/HTTPS 与管理平台通信。断网时本地缓存,恢复后自动补传。</p></div>
|
||||
<div className="gateway-chip"><span className="pulse-dot" /><b>EDGE-GW-01</b><small>网关服务在线 · 演示配置</small></div>
|
||||
</div>
|
||||
<div className="integration-grid">
|
||||
{integrationCards.map((item) => (
|
||||
<article className="integration-card" key={item.title}>
|
||||
<div className="integration-icon">{item.icon}</div>
|
||||
<div className="integration-title"><div><h3>{item.title}</h3><span>{item.device}</span></div><Badge value={item.status} /></div>
|
||||
<p>{item.detail}</p>
|
||||
<dl><div><dt>推荐协议</dt><dd>{item.protocol}</dd></div><div><dt>接入方式</dt><dd>车间边缘网关</dd></div><div><dt>数据保障</dt><dd>签名校验 · 离线补传 · 去重</dd></div></dl>
|
||||
<button className="button secondary block" disabled={testing === item.title} onClick={() => test(item.title)}>{testing === item.title ? "正在检测…" : "测试接入"}</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="protocol-form">
|
||||
<div className="card-head"><div><span className="section-kicker">GATEWAY PROFILE</span><h3>边缘网关配置</h3></div><Badge value="配置未下发" /></div>
|
||||
<div className="form-grid">
|
||||
<label><span>网关编号</span><input defaultValue="EDGE-GW-01" /></label>
|
||||
<label><span>车间位置</span><select defaultValue="一楼废料库"><option>一楼废料库</option><option>六楼车间</option></select></label>
|
||||
<label><span>上报地址</span><input defaultValue="mqtts://gateway.example.com:8883" /></label>
|
||||
<label><span>心跳频率</span><select defaultValue="15 秒"><option>5 秒</option><option>15 秒</option><option>30 秒</option></select></label>
|
||||
</div>
|
||||
<div className="form-actions"><span>正式接入前需提供每台设备的品牌型号、通信协议、寄存器/数据字典和现场网络条件。</span><button className="button primary" onClick={() => notify("网关配置已保存为草稿")}>保存配置草稿</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "accounts" && (
|
||||
<div className="table-scroll"><table><thead><tr><th>姓名</th><th>工号</th><th>部门</th><th>系统角色</th><th>权限范围</th><th>状态</th></tr></thead><tbody>{people.map((person) => <tr key={person.id}><td>{person.name}</td><td className="mono">{person.id}</td><td>{person.workshop}</td><td>{person.role}</td><td>{person.role === "管理员" ? "全部模块与系统配置" : person.role === "仓管员" ? "废料回收与设备查看" : "导体称重与 RFID 追溯"}</td><td><Badge value="已启用" /></td></tr>)}</tbody></table></div>
|
||||
)}
|
||||
{tab === "workshops" && (
|
||||
<div className="dictionary"><div className="toolbar"><span className="quiet">人员和机台统一引用车间主数据</span><button className="button primary push-right" onClick={() => notify("新增车间表单已打开")}>+ 新增车间</button></div>{[["WS-01", "一楼新能源车间", "1F · 东侧", "5 人", "5 台"], ["WS-02", "六楼车间", "6F · 西侧", "2 人", "3 台"]].map((row) => <article key={row[0]}><span className="mono">{row[0]}</span><div><b>{row[1]}</b><small>{row[2]}</small></div><div><small>关联人员</small><b>{row[3]}</b></div><div><small>关联机台</small><b>{row[4]}</b></div><button className="text-button">编辑</button></article>)}</div>
|
||||
)}
|
||||
{tab === "mes" && (
|
||||
<div className="mes-layout">
|
||||
<div className="protocol-form">
|
||||
<div className="card-head"><div><span className="section-kicker">MES CONNECTOR</span><h3>接口配置</h3></div><Badge value="链路正常" /></div>
|
||||
<div className="form-grid one">
|
||||
<label><span>接口地址</span><input defaultValue="https://mes.example.local/api/v2" /></label>
|
||||
<label><span>认证方式</span><select><option>OAuth 2.0 Client Credentials</option><option>签名 Token</option></select></label>
|
||||
<label><span>失败重试</span><select><option>自动重试 3 次 · 间隔 5 秒</option><option>进入人工队列</option></select></label>
|
||||
<label><span>离线队列</span><select><option>本地排队 · 恢复后按序补推</option><option>仅保留失败记录</option></select></label>
|
||||
</div>
|
||||
<div className="form-actions"><span>人员、机台、批次向下同步;称重结果向上推送。</span><button className="button primary" onClick={() => notify("MES 连接测试成功,响应耗时 186ms")}>测试连接</button></div>
|
||||
</div>
|
||||
<div className="push-log"><h3>最近推送</h3>{traceRows.slice(0, 4).map((row) => <div key={row.task}><span>{row.time}</span><b>{row.task}</b><Badge value={row.push} /></div>)}</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "logs" && (
|
||||
<><div className="toolbar"><span className="quiet">日志不可修改,建议至少保留 180 天</span><PageHeaderActions onExport={() => exportCsv("系统操作日志", logs)} /></div><div className="table-scroll"><table><thead><tr><th>时间</th><th>操作人</th><th>动作</th><th>详情</th><th>级别</th></tr></thead><tbody>{logs.map((row) => <tr key={row.join("-")}><td className="mono">{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td><td>{row[3]}</td><td><Badge value={row[4]} /></td></tr>)}</tbody></table></div></>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalPage({ notify }: { notify: (text: string) => void }) {
|
||||
const [wasteStep, setWasteStep] = useState(0);
|
||||
const [rfidStep, setRfidStep] = useState(0);
|
||||
const wasteFlow = [
|
||||
{ no: "W1", title: "身份验证", big: "请面向摄像头", detail: "支持人脸、工号、IC 卡三种身份方式", action: "模拟识别:张三" },
|
||||
{ no: "W2", title: "门锁已开启", big: "04:58", detail: "请进入投放,门磁与地磅正在记录", action: "模拟投放完成" },
|
||||
{ no: "W3", title: "投放确认", big: "38.50 kg", detail: "台秤汇总 38.50kg · 地磅增量 39.00kg · 偏差 1.3%", action: "确认并上报 MES" },
|
||||
{ no: "W4", title: "处理完成", big: "数据已归档", detail: "事件 EVT-20260722-001 已生成", action: "重新开始" },
|
||||
];
|
||||
const rfidFlow = [
|
||||
{ no: "C0", title: "导体轴称重", big: "请选择称重类型", detail: "全程追溯并自动上报 MES", action: "一次称重 · 皮重" },
|
||||
{ no: "C1", title: "RFID 读取成功", big: "152.30 kg", detail: "E280-6102-0345 · 历史皮重偏差 < 0.5kg", action: "模拟二次称重" },
|
||||
{ no: "C2", title: "净重计算完成", big: "115.80 kg", detail: "毛重 268.10kg − 皮重 152.30kg", action: "上报 MES 并打印" },
|
||||
{ no: "C3", title: "任务完成", big: "MES I-04 成功", detail: "标签、批次、机台与净重已绑定", action: "重新开始" },
|
||||
];
|
||||
const advance = (kind: "waste" | "rfid") => {
|
||||
if (kind === "waste") {
|
||||
const next = (wasteStep + 1) % wasteFlow.length;
|
||||
setWasteStep(next);
|
||||
if (next === 0) notify("废料回收终端流程已重置");
|
||||
} else {
|
||||
const next = (rfidStep + 1) % rfidFlow.length;
|
||||
setRfidStep(next);
|
||||
if (next === 0) notify("RFID 称重终端流程已重置");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="terminal-grid">
|
||||
{[
|
||||
{ label: "人脸识别回收箱终端", color: "green", current: wasteFlow[wasteStep], step: wasteStep, click: () => advance("waste") },
|
||||
{ label: "RFID 导体称重终端", color: "blue", current: rfidFlow[rfidStep], step: rfidStep, click: () => advance("rfid") },
|
||||
].map((terminal) => (
|
||||
<article className="terminal-shell" key={terminal.label}>
|
||||
<div className="terminal-top"><span className={`terminal-light ${terminal.color}`} />{terminal.label}<small>ANDROID INDUSTRIAL HMI</small></div>
|
||||
<div className={`terminal-screen terminal-${terminal.color}`}>
|
||||
<div className="terminal-step">{terminal.current.no}</div>
|
||||
<span>{terminal.current.title}</span>
|
||||
<h3>{terminal.current.big}</h3>
|
||||
<p>{terminal.current.detail}</p>
|
||||
<button onClick={terminal.click}>{terminal.current.action} →</button>
|
||||
</div>
|
||||
<div className="step-dots">{[0, 1, 2, 3].map((dot) => <i className={dot === terminal.step ? "active" : ""} key={dot} />)}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function exportCsv(name: string, data: unknown[]) {
|
||||
const rows = data.map((item) => (Array.isArray(item) ? item : Object.values(item as Record<string, unknown>)));
|
||||
const headers = data.length && !Array.isArray(data[0]) ? Object.keys(data[0] as Record<string, unknown>) : [];
|
||||
const csv = [headers, ...rows].map((row) => row.map((cell) => `"${String(cell ?? "").replaceAll('"', '""')}"`).join(",")).join("\n");
|
||||
const blob = new Blob([`\ufeff${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `${name}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [page, setPage] = useState<PageKey>("dashboard");
|
||||
const [role, setRole] = useState<RoleKey>("admin");
|
||||
const [clock, setClock] = useState("00:00:00");
|
||||
const [date, setDate] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
const [liveWeight, setLiveWeight] = useState(12.61);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
setClock(now.toLocaleTimeString("zh-CN", { hour12: false }));
|
||||
setDate(now.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", weekday: "short" }));
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setLiveWeight((value) => Math.max(0, value + (Math.random() - 0.5) * 0.03)), 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const allowed = navGroups.flatMap((group) => group.items).find((item) => item.key === page)?.roles.includes(role);
|
||||
if (!allowed) setPage("dashboard");
|
||||
}, [page, role]);
|
||||
|
||||
const notify = (text: string) => {
|
||||
setToast(text);
|
||||
window.setTimeout(() => setToast(""), 2600);
|
||||
};
|
||||
|
||||
const current = pageMeta[page];
|
||||
const roleLabel = { admin: "管理员", warehouse: "仓管员", operator: "产线工人" }[role];
|
||||
const pageContent = useMemo(() => {
|
||||
if (page === "dashboard") return <Dashboard liveWeight={liveWeight} notify={notify} />;
|
||||
if (page === "waste") return <WastePage notify={notify} />;
|
||||
if (page === "events") return <EventsPage notify={notify} />;
|
||||
if (page === "floor") return <FloorPage notify={notify} />;
|
||||
if (page === "trace") return <TracePage notify={notify} />;
|
||||
if (page === "assets") return <AssetsPage notify={notify} />;
|
||||
if (page === "alerts") return <AlertsPage notify={notify} />;
|
||||
if (page === "settings") return <SettingsPage notify={notify} />;
|
||||
return <TerminalPage notify={notify} />;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, liveWeight]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className={`sidebar ${sidebarOpen ? "open" : ""}`}>
|
||||
<div className="brand">
|
||||
<div className="brand-symbol"><span>W</span></div>
|
||||
<div><b>沃尔核材</b><small>智能回收管理平台</small></div>
|
||||
<em>V1.0</em>
|
||||
</div>
|
||||
<nav>
|
||||
{navGroups.map((group) => {
|
||||
const visible = group.items.filter((item) => item.roles.includes(role));
|
||||
if (!visible.length) return null;
|
||||
return (
|
||||
<div className="nav-group" key={group.label}>
|
||||
<span>{group.label}</span>
|
||||
{visible.map((item) => (
|
||||
<button className={page === item.key ? "active" : ""} key={item.key} onClick={() => { setPage(item.key); setSidebarOpen(false); }}>
|
||||
<i>{item.icon}</i>{item.label}
|
||||
{item.key === "alerts" && <em>3</em>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<div><span className="pulse-dot" /><b>10 / 10 设备在线</b></div>
|
||||
<small>网关 EDGE-GW-01 · 18ms</small>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="main-shell">
|
||||
<header className="topbar">
|
||||
<button className="mobile-menu" onClick={() => setSidebarOpen((open) => !open)} aria-label="打开菜单">☰</button>
|
||||
<div className="page-title"><h1>{current.title}</h1><p>{current.sub}</p></div>
|
||||
<div className="topbar-right">
|
||||
<div className="mes-state"><span className="online-dot" />MES 已连接</div>
|
||||
<div className="clock"><b>{clock}</b><small>{date}</small></div>
|
||||
<button className="alert-bell" onClick={() => setPage("alerts")} aria-label="告警中心">!<em>3</em></button>
|
||||
<select value={role} onChange={(event) => setRole(event.target.value as RoleKey)} aria-label="切换角色">
|
||||
<option value="admin">管理员</option>
|
||||
<option value="warehouse">仓管员</option>
|
||||
<option value="operator">产线工人</option>
|
||||
</select>
|
||||
<div className="avatar">王</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content-shell">
|
||||
<div className="mobile-role">当前视角:{roleLabel}</div>
|
||||
{pageContent}
|
||||
</main>
|
||||
</div>
|
||||
{sidebarOpen && <button className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} aria-label="关闭菜单" />}
|
||||
{toast && <div className="toast"><span>✓</span>{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { access, cp, mkdir, rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Packages Sites metadata and migrations after Vite finishes compiling.
|
||||
export function sites(): Plugin {
|
||||
let root = process.cwd();
|
||||
|
||||
return {
|
||||
name: "sites",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
root = config.root;
|
||||
},
|
||||
async closeBundle() {
|
||||
const outputDirectory = resolve(root, "dist", ".openai");
|
||||
const hostingConfig = resolve(root, ".openai", "hosting.json");
|
||||
const drizzleSource = resolve(root, "drizzle");
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
if (await exists(hostingConfig)) {
|
||||
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
|
||||
}
|
||||
if (await exists(drizzleSource)) {
|
||||
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export function getDb() {
|
||||
if (!env.DB) {
|
||||
throw new Error(
|
||||
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
|
||||
);
|
||||
}
|
||||
|
||||
return drizzle(env.DB, { schema });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Intentionally empty by default.
|
||||
// Add Drizzle tables here when the site actually needs a database.
|
||||
// See examples/d1/db/schema.ts for an opt-in example.
|
||||
export {};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import { getDb } from "../../../../../db";
|
||||
import { notes } from "../../../db/schema";
|
||||
|
||||
function toRouteErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
const detail =
|
||||
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
|
||||
const combined = `${message}\n${detail}`;
|
||||
|
||||
if (combined.includes("no such table") || combined.includes('from "notes"')) {
|
||||
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(desc(notes.createdAt), desc(notes.id))
|
||||
.limit(20);
|
||||
|
||||
return Response.json({ notes: rows });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = (await request.json()) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
const title = payload.title?.trim() ?? "";
|
||||
const content = payload.content?.trim() ?? "";
|
||||
|
||||
if (!title) {
|
||||
return Response.json({ error: "title is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const [note] = await db.insert(notes).values({ title, content }).returning();
|
||||
return Response.json({ note }, { status: 201 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: toRouteErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const notes = sqliteTable("notes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content").notNull().default(""),
|
||||
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+11292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "site-creator-vinext-starter",
|
||||
"version": "0.1.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 vinext start",
|
||||
"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"
|
||||
}
|
||||
Generated
+6827
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
workerd: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
- workerd
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const developmentPreviewMeta =
|
||||
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
|
||||
const templateRoot = new URL("../", import.meta.url);
|
||||
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
|
||||
|
||||
async function render() {
|
||||
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||
const { default: worker } = await import(workerUrl.href);
|
||||
|
||||
return worker.fetch(
|
||||
new Request("http://localhost/", {
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
{
|
||||
ASSETS: {
|
||||
fetch: async () => new Response("Not found", { status: 404 }),
|
||||
},
|
||||
},
|
||||
{
|
||||
waitUntil() {},
|
||||
passThroughOnException() {},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("server-renders the starter loading skeleton", async () => {
|
||||
const response = await render();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
|
||||
const html = await response.text();
|
||||
assert.match(html, developmentPreviewMeta);
|
||||
assert.match(html, /<title>Your site is taking shape<\/title>/i);
|
||||
assert.match(html, /Building your site/);
|
||||
assert.match(html, /Your site is taking shape/);
|
||||
assert.match(
|
||||
html,
|
||||
/Your first version will appear here automatically when it’s ready\./,
|
||||
);
|
||||
assert.doesNotMatch(html, /Codex/);
|
||||
assert.match(html, /react-loading-skeleton/);
|
||||
assert.match(html, /role="status"/);
|
||||
});
|
||||
|
||||
test("keeps the loading skeleton scoped and disposable", async () => {
|
||||
const [preview, css, page, layout, packageJson, files] = await Promise.all([
|
||||
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
|
||||
readFile(new URL("preview.css", previewRoot), "utf8"),
|
||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readdir(previewRoot),
|
||||
]);
|
||||
|
||||
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
|
||||
assert.match(preview, /from "react-loading-skeleton"/);
|
||||
assert.match(preview, /baseColor="#eceae7"/);
|
||||
assert.match(preview, /highlightColor="#f9f8f6"/);
|
||||
assert.match(preview, /duration=\{2\.8\}/);
|
||||
assert.match(preview, /sites-skeleton-search-placeholder/);
|
||||
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
|
||||
|
||||
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
|
||||
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
|
||||
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
|
||||
assert.match(css, /position:\s*fixed/);
|
||||
assert.match(css, /inset:\s*0/);
|
||||
assert.match(css, /opacity:\s*0\.52/);
|
||||
assert.match(css, /prefers-reduced-motion:\s*reduce/);
|
||||
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
|
||||
assert.doesNotMatch(
|
||||
preview,
|
||||
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
|
||||
);
|
||||
|
||||
assert.match(page, /export const metadata:\s*Metadata/);
|
||||
assert.match(page, /"codex-preview": "development"/);
|
||||
assert.match(page, /<SkeletonPreview \/>/);
|
||||
assert.match(layout, /title:\s*"Starter Project"/);
|
||||
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
|
||||
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
|
||||
|
||||
await assert.rejects(
|
||||
access(new URL("public/_sites-preview", templateRoot)),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import vinext from "vinext";
|
||||
import { defineConfig } from "vite";
|
||||
import hostingConfig from "./.openai/hosting.json";
|
||||
import { sites } from "./build/sites-vite-plugin";
|
||||
|
||||
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
||||
"00000000-0000-4000-8000-000000000000";
|
||||
|
||||
const { d1, r2 } = hostingConfig;
|
||||
|
||||
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||
|
||||
const localBindingConfig = {
|
||||
main: "./worker/index.ts",
|
||||
compatibility_flags: ["nodejs_compat"],
|
||||
d1_databases: d1
|
||||
? [
|
||||
{
|
||||
binding: d1,
|
||||
database_name: "site-creator-d1",
|
||||
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
r2_buckets: r2
|
||||
? [
|
||||
{
|
||||
binding: r2,
|
||||
bucket_name: "site-creator-r2",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
export default defineConfig(async () => {
|
||||
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
|
||||
// settings; application environment belongs in ignored `.env*` files.
|
||||
process.env.WRANGLER_WRITE_LOGS ??= "false";
|
||||
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
|
||||
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
|
||||
|
||||
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||
|
||||
return {
|
||||
server: isCodexSeatbeltSandbox
|
||||
? { watch: { useFsEvents: false, usePolling: true } }
|
||||
: undefined,
|
||||
plugins: [
|
||||
vinext(),
|
||||
sites(),
|
||||
cloudflare({
|
||||
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|
||||
config: localBindingConfig,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Cloudflare Worker entry point for the vinext-starter template. */
|
||||
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||
import handler from "vinext/server/app-router-entry";
|
||||
|
||||
interface Env {
|
||||
ASSETS: Fetcher;
|
||||
DB: D1Database;
|
||||
IMAGES: {
|
||||
input(stream: ReadableStream): {
|
||||
transform(options: Record<string, unknown>): {
|
||||
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
// Image security config. SVG sources with .svg extension auto-skip the
|
||||
// optimization endpoint on the client side (served directly, no proxy).
|
||||
// To route SVGs through the optimizer (with security headers), set
|
||||
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
|
||||
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
|
||||
|
||||
const worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/_vinext/image") {
|
||||
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||
return handleImageOptimization(request, {
|
||||
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||
transformImage: async (body, { width, format, quality }) => {
|
||||
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||
return result.response();
|
||||
},
|
||||
}, allowedWidths);
|
||||
}
|
||||
|
||||
return handler.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export default worker;
|
||||
Reference in New Issue
Block a user