feat: implement warehouse query dashboard with live SQL Server data

Convert the Demo UI component into a working Next.js TypeScript page
that fetches live data from the API routes with debounced search,
loading/error states, server-side filtering, sorting, and pagination.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-06-04 09:47:55 +08:00
parent 425fbbea28
commit 086ad62614
3 changed files with 439 additions and 97 deletions

View File

@@ -1,26 +1,5 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
margin: 0;
}

View File

@@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "成品仓库数据查询系统",
description: "Finished Goods Warehouse Query System — 成品库存实时查询",
};
export default function RootLayout({
@@ -23,10 +12,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<html lang="zh-CN" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>
</html>
);

View File

@@ -1,64 +1,441 @@
import Image from "next/image";
"use client";
export default function Home() {
import { useState, useEffect, useCallback, useRef } from "react";
import {
Search, ChevronLeft, ChevronRight, ChevronUp, ChevronDown, X,
CheckCircle2, Circle, ArrowUpDown, Boxes, SlidersHorizontal, Loader2,
} from "lucide-react";
import type { WarehouseRow, QueryResult, FilterOptions } from "@/types/warehouse";
/* ── 品牌色:沿用原 Excel 的深蓝表头 + 淡蓝隔行 ── */
const NAVY = "#1F497D";
const NAVY_DARK = "#163758";
const FONT =
'"PingFang SC","Microsoft YaHei","Noto Sans SC",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif';
/* ── 列定义(顺序与原 Excel 表头一致)── */
const COLUMNS = [
{ key: "paichan", label: "排产号", mono: true, align: "left" },
{ key: "zongpai", label: "总排号", mono: true, align: "left" },
{ key: "workshop", label: "车间", align: "left" },
{ key: "model", label: "产品型号", align: "left" },
{ key: "range", label: "量程", align: "left" },
{ key: "qty", label: "数量", num: true, align: "right" },
{ key: "boxNo", label: "箱号", mono: true, align: "left" },
{ key: "boxQty", label: "装箱数量", num: true, align: "right" },
{ key: "workOrder", label: "工令号", mono: true, align: "left" },
{ key: "shelf", label: "货架", mono: true, align: "left" },
{ key: "handler", label: "经办人", align: "left" },
{ key: "orderNo", label: "订单号", mono: true, align: "left" },
{ key: "inbound", label: "入库时间", mono: true, align: "left" },
];
/* ── Sub-components ── */
function SortIcon({ active, dir }: { active: boolean; dir: string }) {
if (!active) return <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />;
return dir === "asc" ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />;
}
function StatusBadge({ status }: { status: "IN" | "WAIT" }) {
if (status === "IN") {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
<Circle className="h-3.5 w-3.5" />
</span>
);
}
function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className={`flex flex-col gap-1 ${className}`}>
<label className="text-xs font-medium text-slate-500">{label}</label>
{children}
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
);
}
function SelectWrap({ value, onChange, children }: { value: string; onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; children: React.ReactNode }) {
return (
<div className="relative">
<select
value={value}
onChange={onChange}
className="w-full appearance-none rounded-lg border border-slate-200 bg-slate-50 py-2 pl-3 pr-8 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
</div>
);
}
function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-blue-200">
{children}
<button onClick={onRemove} className="grid h-4 w-4 place-items-center rounded-full hover:bg-blue-200/70">
<X className="h-3 w-3" />
</button>
</span>
);
}
/* ── Main Page Component ── */
export default function WarehouseDashboard() {
const [search, setSearch] = useState("");
const [workshop, setWorkshop] = useState("ALL");
const [handler, setHandler] = useState("ALL");
const [status, setStatus] = useState<"ALL" | "IN" | "WAIT">("ALL");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [sortKey, setSortKey] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<QueryResult | null>(null);
const [filterOptions, setFilterOptions] = useState<FilterOptions | null>(null);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
/* Fetch filter options on mount */
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/warehouse/filters");
if (!res.ok) throw new Error("Failed to load filter options");
const json: FilterOptions = await res.json();
setFilterOptions(json);
} catch {
// Filter options are not critical — silently degrade
}
})();
}, []);
/* Build fetch callback */
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (search) params.set("search", search);
if (workshop !== "ALL") params.set("workshop", workshop);
if (handler !== "ALL") params.set("handler", handler);
if (status !== "ALL") params.set("status", status);
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
if (sortKey) {
params.set("sortKey", sortKey);
params.set("sortDir", sortDir);
}
params.set("page", String(page));
params.set("pageSize", String(pageSize));
try {
const res = await fetch(`/api/warehouse?${params}`);
if (!res.ok) throw new Error("Query failed");
const json: QueryResult = await res.json();
setData(json);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, [search, workshop, handler, status, dateFrom, dateTo, sortKey, sortDir, page, pageSize]);
/* Debounced effect: fetch data when any dependency changes */
useEffect(() => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => {
fetchData();
}, search ? 300 : 0);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
};
}, [fetchData, search]);
/* Derived values */
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 1;
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const rows: WarehouseRow[] = data?.rows ?? [];
const filtersActive = search || workshop !== "ALL" || handler !== "ALL" || status !== "ALL" || dateFrom || dateTo;
const toggleSort = (key: string) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
setPage(1);
};
const resetFilters = () => {
setSearch(""); setWorkshop("ALL"); setHandler("ALL"); setStatus("ALL");
setDateFrom(""); setDateTo(""); setSortKey(""); setSortDir("asc");
setPage(1);
};
const STATUS_LABEL: Record<string, string> = { IN: "已入库", WAIT: "待入库" };
const dateInputCls =
"rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200";
return (
<div className="min-h-screen bg-slate-100 antialiased" style={{ fontFamily: FONT }}>
{/* ── 顶部应用栏 ── */}
<header className="text-white" style={{ background: `linear-gradient(135deg, ${NAVY} 0%, ${NAVY_DARK} 100%)` }}>
<div className="mx-auto flex max-w-screen-2xl flex-wrap items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white/10 ring-1 ring-white/20">
<Boxes className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-lg font-semibold leading-tight tracking-tight"></h1>
<p className="text-xs text-white/70">Finished Goods Warehouse · </p>
</div>
</div>
<div className="hidden items-center gap-2 rounded-lg bg-white/10 px-3 py-1.5 text-xs ring-1 ring-white/15 sm:flex">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
</span>
<span className="text-white/90"></span>
<span className="text-white/40">·</span>
<span className="font-mono text-white/80">CompanyDB · 192.168.110.114</span>
</div>
</div>
</header>
<main className="mx-auto max-w-screen-2xl space-y-5 px-6 py-6">
{/* ── 查询条件(核心)── */}
<section className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3">
<SlidersHorizontal className="h-4 w-4 text-slate-500" />
<h2 className="text-sm font-semibold text-slate-700"></h2>
<span className="ml-auto text-sm text-slate-500">
<span className="font-semibold tabular-nums text-slate-800">{total}</span>
</span>
</div>
<div className="space-y-4 p-5">
{/* 主搜索框 */}
<div className="relative">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="输入 总排号 / 工令号 / 订单号 / 产品型号 / 箱号 / 货架 进行查询…"
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-3 pl-11 pr-10 text-base text-slate-800 placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
{search && (
<button
onClick={() => { setSearch(""); setPage(1); }}
className="absolute right-3 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 筛选项 */}
<div className="flex flex-wrap gap-4">
<Field label="车间" className="w-44">
<SelectWrap value={workshop} onChange={(e) => { setWorkshop(e.target.value); setPage(1); }}>
<option value="ALL"></option>
{filterOptions?.workshops.map((w) => (
<option key={w.code} value={w.code}>{w.name}</option>
))}
</SelectWrap>
</Field>
<Field label="经办人" className="w-40">
<SelectWrap value={handler} onChange={(e) => { setHandler(e.target.value); setPage(1); }}>
<option value="ALL"></option>
{filterOptions?.handlers.map((h) => (
<option key={h} value={h}>{h}</option>
))}
</SelectWrap>
</Field>
<Field label="状态" className="w-36">
<SelectWrap value={status} onChange={(e) => { setStatus(e.target.value as "ALL" | "IN" | "WAIT"); setPage(1); }}>
<option value="ALL"></option>
<option value="IN"></option>
<option value="WAIT"></option>
</SelectWrap>
</Field>
<Field label="入库时间">
<div className="flex items-center gap-2">
<input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setPage(1); }} className={dateInputCls} />
<span className="text-sm text-slate-400"></span>
<input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setPage(1); }} className={dateInputCls} />
</div>
</Field>
</div>
{/* 已选条件标签 */}
{filtersActive && (
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3">
<span className="text-xs text-slate-400"></span>
{search && <Chip onRemove={() => { setSearch(""); setPage(1); }}>{search}</Chip>}
{workshop !== "ALL" && (
<Chip onRemove={() => { setWorkshop("ALL"); setPage(1); }}>
{filterOptions?.workshops.find(w => w.code === workshop)?.name ?? workshop}
</Chip>
)}
{handler !== "ALL" && <Chip onRemove={() => { setHandler("ALL"); setPage(1); }}>{handler}</Chip>}
{status !== "ALL" && <Chip onRemove={() => { setStatus("ALL"); setPage(1); }}>{STATUS_LABEL[status]}</Chip>}
{dateFrom && <Chip onRemove={() => { setDateFrom(""); setPage(1); }}>{dateFrom}</Chip>}
{dateTo && <Chip onRemove={() => { setDateTo(""); setPage(1); }}>{dateTo}</Chip>}
<button onClick={resetFilters} className="ml-1 text-xs text-slate-500 hover:text-slate-700 hover:underline">
</button>
</div>
)}
</div>
</section>
{/* ── 查询结果 ── */}
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-700"></h2>
<span className="text-xs text-slate-400"> + + + LEFT JOIN</span>
</div>
{/* Error banner */}
{error && (
<div className="border-b border-red-200 bg-red-50 px-5 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* Loading / Table area */}
<div className="relative">
{loading && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/70">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<span className="mt-2 text-sm text-slate-500">...</span>
</div>
)}
<div className="overflow-auto" style={{ maxHeight: "60vh" }}>
<table className="w-full border-collapse text-sm">
<thead>
<tr>
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className={`sticky top-0 z-10 cursor-pointer select-none whitespace-nowrap px-4 py-3 font-semibold text-white transition hover:brightness-110 ${col.align === "right" ? "text-right" : "text-left"}`}
style={{ background: NAVY }}
>
<span className={`inline-flex items-center gap-1 ${col.align === "right" ? "flex-row-reverse" : ""}`}>
{col.label}
<SortIcon active={sortKey === col.key} dir={sortDir} />
</span>
</th>
))}
<th className="sticky top-0 z-10 whitespace-nowrap px-4 py-3 text-left font-semibold text-white" style={{ background: NAVY }}>
</th>
</tr>
</thead>
<tbody>
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length + 1} className="px-4 py-16 text-center">
<div className="flex flex-col items-center gap-2 text-slate-400">
<Search className="h-8 w-8" />
<div className="text-sm"></div>
<button onClick={resetFilters} className="text-sm text-blue-600 hover:underline"></button>
</div>
</td>
</tr>
) : (
rows.map((r, i) => (
<tr
key={`${r.zongpai}-${r.boxNo}-${i}`}
className={`transition-colors ${i % 2 === 0 ? "bg-white hover:bg-slate-50" : "bg-blue-50 hover:bg-blue-100"}`}
>
{COLUMNS.map((col) => {
const raw = r[col.key as keyof WarehouseRow];
const empty = raw === "" || raw == null;
const align = col.align === "right" ? "text-right" : "text-left";
const font = col.mono ? "font-mono" : "";
const num = col.num ? "tabular-nums" : "";
const color = col.mono ? "text-slate-600" : "text-slate-700";
return (
<td key={col.key} className={`whitespace-nowrap border-b border-slate-100 px-4 py-2.5 ${align} ${font} ${num} ${color}`}>
{empty ? <span className="text-slate-300"></span> : String(raw)}
</td>
);
})}
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-2.5 text-left">
<StatusBadge status={r.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 分页 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-sm text-slate-500">
<span className="font-medium tabular-nums text-slate-700">{total === 0 ? 0 : start + 1}</span>
<span className="font-medium tabular-nums text-slate-700">{Math.min(start + pageSize, total)}</span>
<span className="font-medium tabular-nums text-slate-700">{total}</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-slate-500">
<div className="relative">
<select
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="appearance-none rounded-lg border border-slate-200 bg-white py-1.5 pl-3 pr-7 text-sm text-slate-700 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="px-2 text-sm tabular-nums text-slate-600">{safePage} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</section>
</main>
</div>
);