Build smart scale application
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_6a682803f444819188189f76b08c7a19",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# ChatGPT project context
|
||||
|
||||
This directory is a local mirror of the ChatGPT project “沃尔核材电子台秤”.
|
||||
|
||||
- Treat every file under `sources/` as read-only reference material.
|
||||
- Do not edit, rename, move, or delete synced project files.
|
||||
- These files may be replaced the next time a task is created from this ChatGPT project.
|
||||
|
||||
## Project instructions
|
||||
|
||||
This project has no custom instructions.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--ink: #17211d;
|
||||
--muted: #66736c;
|
||||
--line: #dfe6e1;
|
||||
--green: #007f5f;
|
||||
--green-dark: #005f48;
|
||||
--mint: #dff5ed;
|
||||
--paper: #f8faf8;
|
||||
--cream: #f2f4ee;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: #dfe8e2; }
|
||||
body { margin: 0; color: var(--ink); font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 75% 15%, rgba(234, 184, 90, .18), transparent 25%),
|
||||
linear-gradient(145deg, #e9efeb, #cad9d0);
|
||||
}
|
||||
.ambient { position: absolute; border-radius: 50%; filter: blur(2px); opacity: .65; }
|
||||
.ambient-one { width: 460px; height: 460px; background: rgba(0, 127, 95, .08); left: -180px; bottom: -180px; }
|
||||
.ambient-two { width: 260px; height: 260px; border: 50px solid rgba(255,255,255,.3); right: -70px; top: 40px; }
|
||||
.device-frame {
|
||||
width: min(1080px, 100%);
|
||||
min-height: min(760px, calc(100vh - 56px));
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(249, 251, 248, .96);
|
||||
border: 1px solid rgba(255,255,255,.85);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 35px 90px rgba(28, 54, 42, .21), inset 0 1px #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-header { height: 74px; display: flex; align-items: center; justify-content: space-between; padding: 0 38px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.72); }
|
||||
.brand { display: flex; align-items: center; gap: 12px; border: 0; background: none; color: var(--ink); padding: 0; text-align: left; }
|
||||
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; background: var(--green); color: white; border-radius: 11px; font-size: 24px; font-weight: 900; transform: skew(-5deg); }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 16px; letter-spacing: .04em; }
|
||||
.brand small { color: #88938d; font-size: 9px; margin-top: 3px; letter-spacing: .12em; }
|
||||
.status-pill { display: flex; gap: 8px; align-items: center; padding: 8px 13px; border-radius: 99px; background: #edf7f2; color: #236451; font-size: 12px; font-weight: 700; }
|
||||
.status-dot { width: 7px; height: 7px; background: #22a06b; border-radius: 50%; box-shadow: 0 0 0 4px rgba(34,160,107,.12); }
|
||||
.progress-strip { display: flex; gap: 36px; justify-content: center; padding: 17px 28px; border-bottom: 1px solid var(--line); color: #9aa39e; font-size: 12px; font-weight: 700; }
|
||||
.progress-item { display: flex; align-items: center; gap: 8px; }
|
||||
.progress-item span { width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid #ccd4cf; border-radius: 50%; font-size: 10px; }
|
||||
.progress-item.active { color: var(--green); }
|
||||
.progress-item.active span { color: white; background: var(--green); border-color: var(--green); }
|
||||
.content { flex: 1; display: grid; }
|
||||
footer { min-height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 38px; border-top: 1px solid var(--line); color: #94a098; font-size: 10px; letter-spacing: .04em; }
|
||||
|
||||
.home-view { padding: 64px clamp(44px, 9vw, 110px) 44px; position: relative; background-image: linear-gradient(90deg, transparent 0 63%, rgba(0,127,95,.035) 63%); }
|
||||
.home-view::after { content: ""; position: absolute; right: 9%; top: 17%; width: 250px; height: 250px; border: 1px solid rgba(0,127,95,.13); border-radius: 50%; box-shadow: 0 0 0 35px rgba(0,127,95,.025), 0 0 0 72px rgba(0,127,95,.018); }
|
||||
.home-view::before { content: "kg"; position: absolute; right: calc(9% + 80px); top: calc(17% + 76px); z-index: 1; color: rgba(0,127,95,.7); font-size: 70px; font-weight: 200; letter-spacing: -.08em; }
|
||||
.eyebrow, .step-kicker { color: var(--green); font-size: 11px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
|
||||
.eyebrow { display: flex; align-items: center; gap: 8px; }
|
||||
.eyebrow span { width: 20px; height: 2px; background: #e5a63c; }
|
||||
h1 { font-size: clamp(48px, 6vw, 70px); line-height: 1.03; letter-spacing: -.06em; margin: 19px 0 24px; max-width: 590px; }
|
||||
h1 em { font-style: normal; color: var(--green); }
|
||||
.home-view > p { color: var(--muted); line-height: 1.85; font-size: 14px; }
|
||||
.primary-cta { margin-top: 30px; min-width: 360px; display: flex; align-items: center; gap: 15px; border: 0; border-radius: 14px; padding: 14px 18px; background: var(--green); color: white; box-shadow: 0 15px 32px rgba(0,127,95,.24); text-align: left; transition: .2s ease; }
|
||||
.primary-cta:hover { transform: translateY(-2px); background: var(--green-dark); }
|
||||
.primary-cta .cta-icon { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 10px; background: rgba(255,255,255,.13); font-size: 21px; }
|
||||
.primary-cta strong, .primary-cta small { display: block; }
|
||||
.primary-cta strong { font-size: 16px; margin-bottom: 4px; }
|
||||
.primary-cta small { font-size: 10px; opacity: .68; }
|
||||
.primary-cta b { margin-left: auto; font-size: 21px; }
|
||||
.admin-link { margin-top: 15px; margin-left: 8px; border: 0; background: none; color: #68746e; font-size: 12px; }
|
||||
.admin-link:hover { color: var(--green); }
|
||||
.home-stats { margin-top: 46px; display: flex; gap: 42px; color: #849088; font-size: 10px; }
|
||||
.home-stats span { padding-left: 12px; border-left: 2px solid #e0e6e1; }
|
||||
.home-stats b { display: block; color: var(--ink); font-size: 13px; margin-bottom: 4px; }
|
||||
|
||||
.panel-view { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 35px 0; }
|
||||
.section-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 24px; }
|
||||
.section-heading h2, .result-view h2 { margin: 7px 0 7px; font-size: 29px; letter-spacing: -.035em; }
|
||||
.section-heading p, .result-view > p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.refresh-button { border: 1px solid var(--line); border-radius: 10px; background: white; color: #53615a; font-size: 11px; padding: 10px 14px; }
|
||||
.identity-card { display: flex; align-items: center; gap: 16px; background: #fff; border: 1px solid var(--line); border-radius: 15px; padding: 16px; box-shadow: 0 8px 24px rgba(35,58,46,.05); }
|
||||
.avatar { width: 54px; height: 54px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 14px; background: var(--green); color: white; font-size: 21px; font-weight: 800; }
|
||||
.identity-card small { color: #8c9791; font-size: 10px; }
|
||||
.identity-card h3 { margin: 4px 0; font-size: 19px; }
|
||||
.identity-card p { margin: 0; color: #66736c; font-size: 11px; }
|
||||
.verified { margin-left: auto; background: var(--mint); color: var(--green); padding: 8px 11px; border-radius: 99px; font-size: 10px; font-weight: 700; }
|
||||
.verified.missing { color: #9b6b14; background: #fbf0d7; }
|
||||
.field-label { display: block; margin: 22px 0 8px; font-size: 11px; font-weight: 800; }
|
||||
.machine-picker { height: 52px; display: flex; align-items: center; gap: 10px; border: 1px solid #cfd8d2; border-radius: 12px; background: white; padding: 0 15px; }
|
||||
.machine-picker:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.08); }
|
||||
.machine-picker input { flex: 1; border: 0; outline: 0; background: transparent; font-size: 13px; }
|
||||
.machine-picker b { color: var(--green); }
|
||||
.suggestions { margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 5px; box-shadow: 0 15px 40px rgba(32,55,44,.1); }
|
||||
.suggestions button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 8px; background: transparent; padding: 10px; text-align: left; font-size: 12px; }
|
||||
.suggestions button:hover { background: #eef7f2; color: var(--green); }
|
||||
.suggestions button small { margin-left: auto; color: #95a099; }
|
||||
.suggestions p { text-align: center; color: #7f8b84; font-size: 11px; }
|
||||
.full-primary { width: 100%; margin-top: 22px; border: 0; border-radius: 12px; background: var(--green); color: white; padding: 15px 18px; font-size: 13px; font-weight: 800; box-shadow: 0 10px 25px rgba(0,127,95,.18); }
|
||||
.full-primary span { float: right; }
|
||||
.notice { margin-top: 12px; padding: 10px 12px; border-radius: 8px; background: #f7edd8; color: #8b671e; font-size: 10px; }
|
||||
.notice.compact { margin: 10px 0 -10px; }
|
||||
|
||||
.weigh-view { padding: 22px 32px 28px; }
|
||||
.weigh-meta { display: flex; gap: 0; border: 1px solid var(--line); background: white; border-radius: 11px; padding: 10px 14px; margin-bottom: 14px; }
|
||||
.weigh-meta > span { min-width: 210px; padding-right: 20px; margin-right: 20px; border-right: 1px solid var(--line); }
|
||||
.weigh-meta small, .weigh-meta b { display: block; }
|
||||
.weigh-meta small { color: #8a958f; font-size: 9px; margin-bottom: 4px; }
|
||||
.weigh-meta b { font-size: 11px; }
|
||||
.weigh-meta button { margin-left: auto; border: 0; background: none; color: var(--green); font-size: 10px; font-weight: 800; }
|
||||
.scale-display { border-radius: 18px; background: #112b23; color: white; padding: 17px 24px 15px; box-shadow: inset 0 1px rgba(255,255,255,.08), 0 16px 30px rgba(16,42,33,.17); }
|
||||
.display-top { display: flex; justify-content: space-between; align-items: center; }
|
||||
.stable-badge { color: #e2c680; font-size: 10px; font-weight: 700; }
|
||||
.stable-badge i { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #e4b64e; box-shadow: 0 0 0 4px rgba(228,182,78,.13); }
|
||||
.stable-badge.ready { color: #83dfb8; }
|
||||
.stable-badge.ready i { background: #48c78e; box-shadow: 0 0 0 4px rgba(72,199,142,.13); }
|
||||
.unit-switch { display: flex; background: rgba(255,255,255,.07); border-radius: 8px; padding: 3px; }
|
||||
.unit-switch button { border: 0; border-radius: 6px; background: transparent; color: #849b91; font-size: 9px; padding: 5px 9px; }
|
||||
.unit-switch button.active { background: #e8f0ea; color: #15352a; font-weight: 800; }
|
||||
.weight-number { display: flex; justify-content: center; align-items: baseline; gap: 13px; margin: 2px 0; }
|
||||
.weight-number span { font-family: "Courier New", monospace; font-size: clamp(58px, 8vw, 82px); letter-spacing: -.08em; font-weight: 700; }
|
||||
.weight-number b { color: #8da298; font-size: 14px; }
|
||||
.stability-track { height: 3px; background: rgba(255,255,255,.08); border-radius: 4px; overflow: hidden; }
|
||||
.stability-track span { display: block; height: 100%; background: #4bd19a; transition: width .25s; }
|
||||
.display-details { display: flex; justify-content: center; gap: 42px; margin-top: 12px; color: #789187; font-size: 9px; }
|
||||
.display-details b { color: #dbe8e1; margin-left: 5px; }
|
||||
.scale-tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 11px 0 16px; }
|
||||
.scale-tools button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 10px 13px; color: var(--green); text-align: left; font-size: 18px; }
|
||||
.scale-tools button:hover { border-color: #a8cabb; }
|
||||
.scale-tools span, .scale-tools b, .scale-tools small { display: block; }
|
||||
.scale-tools b { color: var(--ink); font-size: 11px; }
|
||||
.scale-tools small { color: #909a94; font-size: 8px; margin-top: 3px; }
|
||||
.material-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
|
||||
.material-heading span, .material-heading b, .material-heading small { display: block; }
|
||||
.material-heading b { font-size: 12px; }
|
||||
.material-heading small { color: #8b9690; font-size: 9px; margin-top: 3px; }
|
||||
.material-heading em { color: #a16e15; background: #f8eed7; font-style: normal; font-size: 8px; padding: 4px 7px; border-radius: 99px; }
|
||||
.material-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; }
|
||||
.material-card { min-width: 0; display: flex; align-items: center; gap: 10px; position: relative; border: 1px solid var(--line); border-radius: 11px; background: white; padding: 12px 10px; text-align: left; }
|
||||
.material-card:hover { transform: translateY(-1px); }
|
||||
.material-card.selected { border: 2px solid var(--green); background: #f3faf7; padding: 11px 9px; }
|
||||
.material-icon { width: 33px; height: 33px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; font-size: 12px; font-weight: 900; background: #edf1ed; color: #536259; }
|
||||
.material-card.copper .material-icon { background: #f3e2d6; color: #a65e2e; }
|
||||
.material-card.wire .material-icon { background: #f3ead2; color: #b27714; }
|
||||
.material-card.aluminum .material-icon { background: #e5ecee; color: #60747c; }
|
||||
.material-card.tin .material-icon { background: #e5e6ed; color: #62687c; }
|
||||
.material-card b, .material-card small { display: block; white-space: nowrap; }
|
||||
.material-card b { font-size: 11px; }
|
||||
.material-card small { margin-top: 4px; color: #9aa39e; font-size: 6px; }
|
||||
.material-card i { margin-left: auto; font-size: 10px; color: var(--green); font-style: normal; }
|
||||
.full-primary.upload { margin-top: 18px; }
|
||||
|
||||
.result-view { width: min(520px, calc(100% - 48px)); margin: auto; padding: 28px 0; text-align: center; }
|
||||
.success-ring { width: 66px; height: 66px; display: grid; place-items: center; margin: 0 auto 13px; border-radius: 50%; background: var(--mint); box-shadow: 0 0 0 8px rgba(223,245,237,.55); }
|
||||
.success-ring span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: var(--green); color: white; font-size: 19px; }
|
||||
.receipt { margin-top: 20px; border: 1px solid var(--line); border-radius: 15px; background: white; box-shadow: 0 15px 40px rgba(26,50,38,.08); overflow: hidden; text-align: left; }
|
||||
.receipt-head { display: flex; justify-content: space-between; padding: 12px 18px; border-bottom: 1px dashed #d9e0db; font-size: 10px; font-weight: 800; }
|
||||
.receipt-head small { color: #8c9691; font-weight: 400; }
|
||||
.receipt-weight { display: flex; align-items: center; justify-content: space-between; padding: 15px 20px; background: #f2f8f5; }
|
||||
.receipt-weight small, .receipt-weight b { display: block; }
|
||||
.receipt-weight small { color: var(--green); font-size: 10px; font-weight: 800; }
|
||||
.receipt-weight b { font-family: "Courier New", monospace; font-size: 28px; margin-top: 4px; }
|
||||
.receipt-weight em { font-family: Arial, sans-serif; font-size: 10px; font-style: normal; color: #7d8a83; }
|
||||
.receipt-weight > i { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 11px; background: white; color: var(--green); font-style: normal; font-weight: 900; box-shadow: 0 4px 15px rgba(0,0,0,.05); }
|
||||
.receipt dl { padding: 5px 20px 10px; margin: 0; }
|
||||
.receipt dl div { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #edf0ee; font-size: 10px; }
|
||||
.receipt dl div:last-child { border: 0; }
|
||||
.receipt dt { color: #859189; }
|
||||
.receipt dd { margin: 0; font-weight: 800; }
|
||||
.upload-ok { color: var(--green); }
|
||||
.result-view .full-primary { margin-top: 15px; }
|
||||
.countdown { position: relative; margin-top: 10px; color: #8b9690; font-size: 9px; overflow: hidden; }
|
||||
.countdown span { position: absolute; left: 0; bottom: -4px; height: 2px; background: var(--green); transition: width 1s linear; }
|
||||
|
||||
.settings-card { background: white; border: 1px solid var(--line); border-radius: 15px; padding: 8px 18px; }
|
||||
.settings-card label { display: grid; grid-template-columns: 160px 1fr; align-items: center; gap: 18px; padding: 13px 0; border-bottom: 1px solid var(--line); }
|
||||
.settings-card label:last-child { border: 0; }
|
||||
.settings-card label > span { font-size: 11px; font-weight: 800; }
|
||||
.settings-card label small { display: block; color: #ac8154; font-size: 8px; margin-top: 4px; font-weight: 400; }
|
||||
.settings-card input { width: 100%; border: 1px solid #d8dfda; border-radius: 8px; background: #fafbf9; padding: 10px 12px; outline: 0; font-size: 10px; }
|
||||
.settings-card input:focus { border-color: var(--green); }
|
||||
.setting-hint { margin-top: 12px; background: #edf5f1; border-radius: 9px; padding: 10px 12px; color: #5f7369; font-size: 9px; }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 10; display: grid; place-items: center; background: rgba(13,31,24,.58); backdrop-filter: blur(5px); padding: 20px; }
|
||||
.modal { width: min(390px, 100%); position: relative; background: #fff; border-radius: 20px; padding: 30px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.28); }
|
||||
.modal-close { position: absolute; right: 14px; top: 11px; border: 0; background: none; color: #89938e; font-size: 24px; }
|
||||
.modal-icon { width: 48px; height: 48px; display: grid; place-items: center; margin: 0 auto 12px; border-radius: 13px; background: var(--mint); color: var(--green); font-size: 22px; }
|
||||
.modal h2 { margin: 0 0 6px; font-size: 23px; }
|
||||
.modal p { color: #87928c; font-size: 11px; }
|
||||
.modal > input { width: 100%; margin-top: 12px; border: 1px solid #cfd8d2; border-radius: 11px; padding: 13px; text-align: center; font-size: 22px; letter-spacing: .45em; outline: 0; }
|
||||
.modal > input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(0,127,95,.08); }
|
||||
.password-error { display: block; color: #b44b3e; margin-top: 8px; font-size: 9px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell { padding: 0; display: block; }
|
||||
.device-frame { min-height: 100vh; border-radius: 0; }
|
||||
.app-header { padding: 0 18px; }
|
||||
.status-pill { font-size: 0; padding: 9px; }
|
||||
.progress-strip { gap: 12px; padding-inline: 12px; }
|
||||
.progress-item { font-size: 9px; gap: 4px; }
|
||||
.home-view { padding: 52px 24px 34px; background-image: none; }
|
||||
.home-view::after, .home-view::before { display: none; }
|
||||
h1 { font-size: 48px; }
|
||||
.primary-cta { min-width: 0; width: 100%; }
|
||||
.home-stats { gap: 15px; justify-content: space-between; }
|
||||
.panel-view { width: calc(100% - 32px); padding: 27px 0; }
|
||||
.section-heading { display: block; }
|
||||
.refresh-button { margin-top: 15px; }
|
||||
.weigh-view { padding: 16px; }
|
||||
.weigh-meta > span { min-width: 0; flex: 1; }
|
||||
.weight-number span { font-size: 54px; }
|
||||
.display-details { gap: 15px; }
|
||||
.scale-tools button { justify-content: center; padding: 10px 5px; }
|
||||
.scale-tools button > span { display: none; }
|
||||
.material-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.settings-card label { grid-template-columns: 1fr; gap: 7px; }
|
||||
footer { padding: 0 16px; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headerStore = await headers();
|
||||
const host = headerStore.get("x-forwarded-host") ?? headerStore.get("host") ?? "localhost:3000";
|
||||
const protocol = headerStore.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const origin = `${protocol}://${host}`;
|
||||
|
||||
return {
|
||||
title: "智能称重终端|沃尔核材",
|
||||
description: "面向生产现场的废料智能称重、稳定采样与数据追溯应用。",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title: "智能称重终端",
|
||||
description: "稳定采样 · 精准记录 · 数据可追溯",
|
||||
images: [{ url: `${origin}/og.png`, width: 1536, height: 864, alt: "智能称重终端" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "智能称重终端",
|
||||
description: "稳定采样 · 精准记录 · 数据可追溯",
|
||||
images: [`${origin}/og.png`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Step = "home" | "identity" | "weigh" | "result" | "settings";
|
||||
type Unit = "kg" | "g" | "lb";
|
||||
type Material = {
|
||||
id: string;
|
||||
name: string;
|
||||
tone: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
const materials: Material[] = [
|
||||
{ id: "SCRAP_CU_01", name: "废铜", tone: "copper", icon: "Cu" },
|
||||
{ id: "SCRAP_WIRE_02", name: "废电线", tone: "wire", icon: "⚡" },
|
||||
{ id: "SCRAP_AL_03", name: "废铝", tone: "aluminum", icon: "Al" },
|
||||
{ id: "SCRAP_SN_04", name: "废锡", tone: "tin", icon: "Sn" },
|
||||
];
|
||||
|
||||
const machines = ["A01 绕线机", "A02 押出机", "B03 裁线机", "B06 编织机", "C12 包装机"];
|
||||
|
||||
const unitFactors: Record<Unit, number> = { kg: 1, g: 1000, lb: 2.20462262 };
|
||||
|
||||
function formatWeight(valueKg: number, unit: Unit) {
|
||||
const value = Math.max(0, valueKg) * unitFactors[unit];
|
||||
const digits = unit === "g" ? 0 : 3;
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [step, setStep] = useState<Step>("home");
|
||||
const [employee, setEmployee] = useState({ name: "", id: "" });
|
||||
const [machineQuery, setMachineQuery] = useState("");
|
||||
const [machine, setMachine] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [unit, setUnit] = useState<Unit>("kg");
|
||||
const [rawWeight, setRawWeight] = useState(0);
|
||||
const [zeroOffset, setZeroOffset] = useState(0);
|
||||
const [tare, setTare] = useState(0);
|
||||
const [totalKg, setTotalKg] = useState(0);
|
||||
const [material, setMaterial] = useState<Material>(materials[0]);
|
||||
const [stable, setStable] = useState(false);
|
||||
const [stableProgress, setStableProgress] = useState(0);
|
||||
const [countdown, setCountdown] = useState(10);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [apiSettings, setApiSettings] = useState({
|
||||
employee: "",
|
||||
machines: "",
|
||||
weight: "",
|
||||
upload: "",
|
||||
});
|
||||
const stableSamples = useRef<number[]>([]);
|
||||
|
||||
const netWeight = Math.max(0, rawWeight - zeroOffset - tare);
|
||||
const displayedWeight = totalKg + netWeight;
|
||||
|
||||
const filteredMachines = useMemo(
|
||||
() =>
|
||||
machines.filter((item) =>
|
||||
item.toLowerCase().includes(machineQuery.trim().toLowerCase()),
|
||||
),
|
||||
[machineQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== "weigh") return;
|
||||
const start = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = (Date.now() - start) / 1000;
|
||||
const target = elapsed < 1.4 ? 0 : 2.486;
|
||||
const movement = elapsed < 3 ? Math.sin(elapsed * 9) * 0.085 : Math.sin(elapsed * 3) * 0.0012;
|
||||
setRawWeight(Math.max(0, target + movement));
|
||||
}, 180);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== "weigh") return;
|
||||
stableSamples.current = [...stableSamples.current.slice(-7), netWeight];
|
||||
const samples = stableSamples.current;
|
||||
if (samples.length < 6 || netWeight < 0.01) {
|
||||
setStable(false);
|
||||
setStableProgress(Math.min(85, samples.length * 14));
|
||||
return;
|
||||
}
|
||||
const spread = Math.max(...samples) - Math.min(...samples);
|
||||
const isStable = spread < 0.008;
|
||||
setStable(isStable);
|
||||
setStableProgress(isStable ? 100 : Math.max(25, 90 - spread * 500));
|
||||
}, [netWeight, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== "result") return;
|
||||
setCountdown(10);
|
||||
const timer = window.setInterval(() => {
|
||||
setCountdown((value) => {
|
||||
if (value <= 1) {
|
||||
window.clearInterval(timer);
|
||||
resetToHome();
|
||||
return 0;
|
||||
}
|
||||
return value - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [step]);
|
||||
|
||||
function resetToHome() {
|
||||
setStep("home");
|
||||
setMachine("");
|
||||
setMachineQuery("");
|
||||
setTotalKg(0);
|
||||
setRawWeight(0);
|
||||
setZeroOffset(0);
|
||||
setTare(0);
|
||||
setStable(false);
|
||||
stableSamples.current = [];
|
||||
}
|
||||
|
||||
async function fetchIdentity() {
|
||||
setLoading(true);
|
||||
setNotice("");
|
||||
try {
|
||||
if (apiSettings.employee) {
|
||||
const response = await fetch(apiSettings.employee);
|
||||
if (!response.ok) throw new Error("接口不可用");
|
||||
const data = await response.json();
|
||||
setEmployee({ name: data.name, id: data.employeeId });
|
||||
} else {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 650));
|
||||
setEmployee({ name: "张伟", id: "E-0826" });
|
||||
setNotice("当前使用模拟数据,接口接入后将自动切换");
|
||||
}
|
||||
setStep("identity");
|
||||
} catch {
|
||||
setNotice("暂时无法连接本地服务器,请点击“重新获取”");
|
||||
setStep("identity");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function enterAdmin() {
|
||||
if (password === "888888") {
|
||||
setPasswordOpen(false);
|
||||
setPassword("");
|
||||
setPasswordError("");
|
||||
setStep("settings");
|
||||
} else {
|
||||
setPasswordError("密码不正确,演示密码为 888888");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWeight() {
|
||||
if (!stable) {
|
||||
setNotice("请等待重量稳定后再保存");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setNotice("");
|
||||
const payload = {
|
||||
employeeName: employee.name,
|
||||
employeeId: employee.id,
|
||||
machineId: machine,
|
||||
materialType: material.name,
|
||||
materialTypeId: material.id,
|
||||
weightKg: Number(displayedWeight.toFixed(3)),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
if (apiSettings.upload) {
|
||||
const response = await fetch(apiSettings.upload, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error("上传失败");
|
||||
} else {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
||||
}
|
||||
setStep("result");
|
||||
} catch {
|
||||
setNotice("数据上传失败,请检查服务器连接后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addToTotal() {
|
||||
if (!stable || netWeight <= 0) {
|
||||
setNotice("重量稳定后才能累计");
|
||||
return;
|
||||
}
|
||||
setTotalKg((value) => value + netWeight);
|
||||
setZeroOffset(rawWeight);
|
||||
setTare(0);
|
||||
stableSamples.current = [];
|
||||
setNotice(`已累计 ${formatWeight(netWeight, unit)} ${unit},可继续放置下一批`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="ambient ambient-one" />
|
||||
<div className="ambient ambient-two" />
|
||||
|
||||
<section className="device-frame">
|
||||
<header className="app-header">
|
||||
<button className="brand" onClick={resetToHome} aria-label="返回首页">
|
||||
<span className="brand-mark">W</span>
|
||||
<span>
|
||||
<strong>智能称重终端</strong>
|
||||
<small>WOTE · SMART SCALE</small>
|
||||
</span>
|
||||
</button>
|
||||
<div className="status-pill">
|
||||
<span className="status-dot" />
|
||||
设备在线
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{step !== "home" && step !== "settings" && (
|
||||
<div className="progress-strip" aria-label="流程进度">
|
||||
{["人员确认", "智能称重", "结果上传"].map((label, index) => {
|
||||
const activeIndex = step === "identity" ? 0 : step === "weigh" ? 1 : 2;
|
||||
return (
|
||||
<div className={index <= activeIndex ? "progress-item active" : "progress-item"} key={label}>
|
||||
<span>{index < activeIndex ? "✓" : index + 1}</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="content">
|
||||
{step === "home" && (
|
||||
<div className="home-view">
|
||||
<div className="eyebrow"><span /> 安全 · 精准 · 可追溯</div>
|
||||
<h1>废料称重,<br /><em>简单一点。</em></h1>
|
||||
<p>自动识别员工信息,稳定采样并上传记录。<br />请确认秤台处于空载状态后开始。</p>
|
||||
<button className="primary-cta" onClick={fetchIdentity} disabled={loading}>
|
||||
<span className="cta-icon">↗</span>
|
||||
<span><strong>{loading ? "正在连接…" : "开始称重"}</strong><small>读取门禁人员信息</small></span>
|
||||
<b>→</b>
|
||||
</button>
|
||||
<button className="admin-link" onClick={() => setPasswordOpen(true)}>⚙ 管理员登录</button>
|
||||
<div className="home-stats">
|
||||
<span><b>0.001 kg</b>显示精度</span>
|
||||
<span><b>连续采样</b>稳定判断</span>
|
||||
<span><b>4 种</b>废料分类</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "identity" && (
|
||||
<div className="panel-view identity-view">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="step-kicker">01 · 人员与机台</span>
|
||||
<h2>确认本次称重信息</h2>
|
||||
<p>人员信息由门禁机自动获取,请选择对应生产机台。</p>
|
||||
</div>
|
||||
<button className="refresh-button" onClick={fetchIdentity} disabled={loading}>↻ 重新获取</button>
|
||||
</div>
|
||||
<div className="identity-card">
|
||||
<div className="avatar">{employee.name ? employee.name.slice(-1) : "?"}</div>
|
||||
<div><small>当前操作员工</small><h3>{employee.name || "未获取"}</h3><p>工号 · {employee.id || "—"}</p></div>
|
||||
<span className={employee.name ? "verified" : "verified missing"}>{employee.name ? "✓ 已验证" : "! 待获取"}</span>
|
||||
</div>
|
||||
<label className="field-label" htmlFor="machine">生产机台号</label>
|
||||
<div className="machine-picker">
|
||||
<span>⌕</span>
|
||||
<input
|
||||
id="machine"
|
||||
value={machineQuery}
|
||||
onChange={(event) => {
|
||||
setMachineQuery(event.target.value);
|
||||
setMachine("");
|
||||
}}
|
||||
placeholder="输入机台号或名称,如 A01"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{machine && <b>✓</b>}
|
||||
</div>
|
||||
{machineQuery && !machine && (
|
||||
<div className="suggestions">
|
||||
{filteredMachines.length ? filteredMachines.map((item) => (
|
||||
<button key={item} onClick={() => { setMachine(item); setMachineQuery(item); }}>
|
||||
<span>▣</span>{item}<small>选择</small>
|
||||
</button>
|
||||
)) : <p>没有匹配机台,可点击“重新获取”同步最新数据</p>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="full-primary"
|
||||
disabled={!employee.name || !machine}
|
||||
onClick={() => { setNotice(""); setStep("weigh"); }}
|
||||
>
|
||||
确认并进入称重 <span>→</span>
|
||||
</button>
|
||||
{notice && <div className="notice">ⓘ {notice}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "weigh" && (
|
||||
<div className="weigh-view">
|
||||
<div className="weigh-meta">
|
||||
<span><small>员工</small><b>{employee.name} · {employee.id}</b></span>
|
||||
<span><small>机台</small><b>{machine}</b></span>
|
||||
<button onClick={() => setStep("identity")}>修改</button>
|
||||
</div>
|
||||
|
||||
<div className="scale-display">
|
||||
<div className="display-top">
|
||||
<span className={stable ? "stable-badge ready" : "stable-badge"}>
|
||||
<i /> {stable ? "重量已稳定" : "正在稳定采样"}
|
||||
</span>
|
||||
<div className="unit-switch">
|
||||
{(["kg", "g", "lb"] as Unit[]).map((item) => (
|
||||
<button className={unit === item ? "active" : ""} onClick={() => setUnit(item)} key={item}>{item}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="weight-number">
|
||||
<span>{formatWeight(displayedWeight, unit)}</span><b>{unit}</b>
|
||||
</div>
|
||||
<div className="stability-track"><span style={{ width: `${stableProgress}%` }} /></div>
|
||||
<div className="display-details">
|
||||
<span>净重 <b>{formatWeight(netWeight, unit)} {unit}</b></span>
|
||||
<span>累计 <b>{formatWeight(totalKg, unit)} {unit}</b></span>
|
||||
<span>皮重 <b>{formatWeight(tare, unit)} {unit}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scale-tools">
|
||||
<button onClick={() => { setZeroOffset(rawWeight); setTare(0); setTotalKg(0); setNotice("秤台已清零"); }}>⟲<span><b>清零</b><small>消除空秤误差</small></span></button>
|
||||
<button onClick={() => { setTare(Math.max(0, rawWeight - zeroOffset)); setNotice("已去皮,仅显示净重"); }}>▱<span><b>去皮</b><small>扣除容器重量</small></span></button>
|
||||
<button onClick={addToTotal}>+<span><b>累计</b><small>加入当前重量</small></span></button>
|
||||
</div>
|
||||
|
||||
<div className="material-heading"><span><b>选择废料类型</b><small>点击分类后保存称重记录</small></span><em>必选</em></div>
|
||||
<div className="material-grid">
|
||||
{materials.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`material-card ${item.tone} ${material.id === item.id ? "selected" : ""}`}
|
||||
onClick={() => setMaterial(item)}
|
||||
>
|
||||
<span className="material-icon">{item.icon}</span>
|
||||
<span><b>{item.name}</b><small>TYPE ID · {item.id}</small></span>
|
||||
<i>{material.id === item.id ? "✓" : "→"}</i>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notice && <div className="notice compact">ⓘ {notice}</div>}
|
||||
<button className="full-primary upload" disabled={!stable || loading} onClick={submitWeight}>
|
||||
{loading ? "正在上传…" : stable ? `保存 ${material.name} 称重数据` : "请等待重量稳定"} <span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<div className="result-view">
|
||||
<div className="success-ring"><span>✓</span></div>
|
||||
<span className="step-kicker">称重完成</span>
|
||||
<h2>数据已保存</h2>
|
||||
<p>本次称重记录已进入上传队列</p>
|
||||
<div className="receipt">
|
||||
<div className="receipt-head"><span>称重凭证</span><small>{new Date().toLocaleString("zh-CN", { hour12: false })}</small></div>
|
||||
<div className="receipt-weight">
|
||||
<span><small>{material.name}</small><b>{formatWeight(displayedWeight, unit)} <em>{unit}</em></b></span>
|
||||
<i>{material.icon}</i>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>员工姓名</dt><dd>{employee.name}</dd></div>
|
||||
<div><dt>员工工号</dt><dd>{employee.id}</dd></div>
|
||||
<div><dt>生产机台</dt><dd>{machine}</dd></div>
|
||||
<div><dt>废料类型 ID</dt><dd>{material.id}</dd></div>
|
||||
<div><dt>上传状态</dt><dd className="upload-ok">● 已保存</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<button className="full-primary" onClick={resetToHome}>返回首页 <span>→</span></button>
|
||||
<div className="countdown"><span style={{ width: `${countdown * 10}%` }} />{countdown} 秒后自动返回首页</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "settings" && (
|
||||
<div className="panel-view settings-view">
|
||||
<div className="section-heading">
|
||||
<div><span className="step-kicker">管理员中心</span><h2>接口与设备设置</h2><p>接口开发完成后,在此填入本地服务器地址。</p></div>
|
||||
<button className="refresh-button" onClick={resetToHome}>退出管理</button>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{[
|
||||
["employee", "人员信息接口", "例如 http://192.168.1.20/api/employee"],
|
||||
["machines", "机台列表接口", "例如 http://192.168.1.20/api/machines"],
|
||||
["weight", "电子秤数据接口", "例如 ws://192.168.1.21/weight"],
|
||||
["upload", "称重上传接口", "例如 http://192.168.1.20/api/records"],
|
||||
].map(([key, label, placeholder]) => (
|
||||
<label key={key}><span>{label}<small>暂未配置</small></span><input value={apiSettings[key as keyof typeof apiSettings]} onChange={(event) => setApiSettings({ ...apiSettings, [key]: event.target.value })} placeholder={placeholder} /></label>
|
||||
))}
|
||||
</div>
|
||||
<div className="setting-hint">当前为空时,应用会使用内置模拟数据,便于先行演示与验收交互流程。</div>
|
||||
<button className="full-primary" onClick={() => { setNotice("设置已保存在本次会话中"); resetToHome(); }}>保存设置 <span>✓</span></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer><span>沃尔核材 · 数字化生产</span><span>设备编号 SCALE-01 · v1.0</span></footer>
|
||||
</section>
|
||||
|
||||
{passwordOpen && (
|
||||
<div className="modal-backdrop" onMouseDown={() => setPasswordOpen(false)}>
|
||||
<div className="modal" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setPasswordOpen(false)}>×</button>
|
||||
<span className="modal-icon">⌁</span>
|
||||
<h2>管理员登录</h2>
|
||||
<p>请输入六位管理密码进入设置</p>
|
||||
<input type="password" inputMode="numeric" autoFocus maxLength={6} value={password} onChange={(event) => setPassword(event.target.value.replace(/\D/g, ""))} onKeyDown={(event) => event.key === "Enter" && enterAdmin()} placeholder="••••••" aria-label="管理员密码" />
|
||||
{passwordError && <small className="password-error">{passwordError}</small>}
|
||||
<button className="full-primary" onClick={enterAdmin}>确认登录 <span>→</span></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="15" fill="#007f5f"/>
|
||||
<path d="M13 18h9l5 25 5-17h8l4 17 5-25h8L49 49H39l-4-15-5 15H20z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 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.7 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