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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
489
app/page.tsx
489
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 <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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user