From 086ad626141f1f4bd836a101c94ec9141b59df46 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 4 Jun 2026 09:47:55 +0800 Subject: [PATCH] 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 --- app/globals.css | 23 +-- app/layout.tsx | 20 +- app/page.tsx | 493 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 439 insertions(+), 97 deletions(-) diff --git a/app/globals.css b/app/globals.css index a2dc41e..03d224a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..af5a661 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - + {children} ); diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..7cb9def 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ; + return dir === "asc" ? : ; +} + +function StatusBadge({ status }: { status: "IN" | "WAIT" }) { + if (status === "IN") { + return ( + + 已入库 + + ); + } return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - + + 待入库 + + ); +} + +function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) { + return ( +
+ + {children} +
+ ); +} + +function SelectWrap({ value, onChange, children }: { value: string; onChange: (e: React.ChangeEvent) => void; children: React.ReactNode }) { + return ( +
+ + +
+ ); +} + +function Chip({ children, onRemove }: { children: React.ReactNode; onRemove: () => void }) { + return ( + + {children} + + + ); +} + +/* ── 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(null); + const [data, setData] = useState(null); + const [filterOptions, setFilterOptions] = useState(null); + + const searchTimeout = useRef | 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 = { 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 ( +
+ {/* ── 顶部应用栏 ── */} +
+
+
+
+ +
+
+

成品仓库数据查询系统

+

Finished Goods Warehouse · 成品库存实时查询

+
+
+ +
+ + + + + 实时数据 + · + CompanyDB · 192.168.110.114 +
+
+ +
+ {/* ── 查询条件(核心)── */} +
+
+ +

查询条件

+ + 匹配 {total} 条结果 + +
+ +
+ {/* 主搜索框 */} +
+ + { 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 && ( + + )} +
+ + {/* 筛选项 */} +
+ + { setWorkshop(e.target.value); setPage(1); }}> + + {filterOptions?.workshops.map((w) => ( + + ))} + + + + + { setHandler(e.target.value); setPage(1); }}> + + {filterOptions?.handlers.map((h) => ( + + ))} + + + + + { setStatus(e.target.value as "ALL" | "IN" | "WAIT"); setPage(1); }}> + + + + + + + +
+ { setDateFrom(e.target.value); setPage(1); }} className={dateInputCls} /> + + { setDateTo(e.target.value); setPage(1); }} className={dateInputCls} /> +
+
+
+ + {/* 已选条件标签 */} + {filtersActive && ( +
+ 已选条件 + {search && { setSearch(""); setPage(1); }}>关键词:{search}} + {workshop !== "ALL" && ( + { setWorkshop("ALL"); setPage(1); }}> + 车间:{filterOptions?.workshops.find(w => w.code === workshop)?.name ?? workshop} + + )} + {handler !== "ALL" && { setHandler("ALL"); setPage(1); }}>经办人:{handler}} + {status !== "ALL" && { setStatus("ALL"); setPage(1); }}>状态:{STATUS_LABEL[status]}} + {dateFrom && { setDateFrom(""); setPage(1); }}>入库起:{dateFrom}} + {dateTo && { setDateTo(""); setPage(1); }}>入库止:{dateTo}} + +
+ )} +
+
+ + {/* ── 查询结果 ── */} +
+
+

查询结果

+ 数据来源:生产合同 + 装箱明细 + 库位 + 入库记录(LEFT JOIN) +
+ + {/* Error banner */} + {error && ( +
+ 查询出错:{error} +
+ )} + + {/* Loading / Table area */} +
+ {loading && ( +
+ + 加载中... +
+ )} + +
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + + {!loading && rows.length === 0 ? ( + + + + ) : ( + rows.map((r, i) => ( + + {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 ( + + ); + })} + + + )) + )} + +
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 }} + > + + {col.label} + + + + 状态 +
+
+ +
未找到匹配的记录
+ +
+
+ {empty ? : String(raw)} + + +
+
+
+ + {/* 分页 */} +
+
+ 显示第 {total === 0 ? 0 : start + 1} + –{Math.min(start + pageSize, total)} 条, + 共 {total} 条 +
+
+
+ 每页 +
+ + +
+ 条 +
+
+ + {safePage} / {totalPages} + +
+
+
+
);