Add production data table with Ant Design and SQL Server integration

- Add API route to execute stored procedure and query production data
- Build search bar with isolated component to prevent input lag on large datasets
- Configure Ant Design Table with sticky header and horizontal scroll for 56 columns
- Add antd, @ant-design/icons, mssql dependencies
- Configure allowedDevOrigins for LAN access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-06-10 10:05:32 +08:00
parent 38adf674e8
commit 93564c6f4e
7 changed files with 2176 additions and 132 deletions

View File

@@ -1,65 +1,278 @@
import Image from "next/image";
"use client";
import { useCallback, useMemo, useState } from "react";
import {
Button,
Input,
Table,
Space,
Spin,
Alert,
Typography,
Empty,
} from "antd";
import { SearchOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
const { Title, Text } = Typography;
interface DataRow {
[key: string]: string | number | null;
}
const COLUMN_DEFS: { key: string; width: number }[] = [
{ key: "ID", width: 70 },
{ key: "总排号", width: 100 },
{ key: "序号", width: 60 },
{ key: "生产订单号", width: 150 },
{ key: "车间号", width: 100 },
{ key: "订单号", width: 120 },
{ key: "经办人", width: 80 },
{ key: "签订日期", width: 110 },
{ key: "交货日期", width: 110 },
{ key: "客户名称", width: 180 },
{ key: "产品型号", width: 200 },
{ key: "量程", width: 120 },
{ key: "数量", width: 60 },
{ key: "技术参数", width: 300 },
{ key: "车间", width: 80 },
{ key: "工令号", width: 100 },
{ key: "备注", width: 150 },
{ key: "隔膜类型", width: 100 },
{ key: "标准", width: 80 },
{ key: "隔膜大小", width: 100 },
{ key: "隔膜材质", width: 100 },
{ key: "膜片尺寸", width: 100 },
{ key: "膜片材质", width: 100 },
{ key: "新参数", width: 300 },
{ key: "盘号", width: 80 },
{ key: "特殊要求", width: 200 },
{ key: "位号", width: 80 },
{ key: "CRM订单明细ID", width: 160 },
{ key: "成品物料码", width: 120 },
{ key: "接单日期", width: 110 },
{ key: "执行卡下发日期", width: 130 },
{ key: "缺件明细", width: 250 },
{ key: "物料类别", width: 100 },
{ key: "焊接领料日期", width: 130 },
{ key: "领料单签收日期", width: 140 },
{ key: "库房发出日期", width: 130 },
{ key: "焊接接收日期", width: 130 },
{ key: "操作者", width: 80 },
{ key: "日期", width: 110 },
{ key: "超压日期", width: 110 },
{ key: "退火日期", width: 110 },
{ key: "氦测日期", width: 110 },
{ key: "壳焊接员", width: 80 },
{ key: "表壳焊接日期", width: 130 },
{ key: "隔膜接收", width: 110 },
{ key: "隔离膜片接收", width: 140 },
{ key: "车波纹日期", width: 110 },
{ key: "膜片焊", width: 110 },
{ key: "喷涂发出", width: 110 },
{ key: "喷涂回来", width: 110 },
{ key: "调校人", width: 80 },
{ key: "调试日期", width: 110 },
{ key: "检验员", width: 80 },
{ key: "检验日期", width: 110 },
{ key: "入库日期", width: 110 },
{ key: "烘洗", width: 110 },
];
function buildColumns(keys: string[]): ColumnsType<DataRow> {
return keys.map((key) => {
const def = COLUMN_DEFS.find((d) => d.key === key);
return {
title: key,
dataIndex: key,
key,
width: def?.width ?? 120,
ellipsis: true,
render: (val: string | number | null) => {
if (val === null || val === undefined) return "";
return String(val);
},
};
});
}
// Search bar: isolated component with its own input state
// Typing only re-renders THIS component, not the parent with the heavy Table
function SearchBar({
loading,
resultCount,
searched,
onSearch,
onClear,
}: {
loading: boolean;
resultCount: number;
searched: boolean;
onSearch: (workshopNo: string) => void;
onClear: () => void;
}) {
const [inputValue, setInputValue] = useState("R05697");
const handleSearch = useCallback(() => {
if (inputValue.trim()) {
onSearch(inputValue.trim());
}
}, [inputValue, onSearch]);
export default function Home() {
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
style={{
flexShrink: 0,
padding: "16px 24px",
borderBottom: "1px solid #f0f0f0",
background: "#fff",
}}
>
<Title level={4} style={{ margin: 0, marginBottom: 12 }}>
</Title>
<Space.Compact>
<Input
prefix={<SearchOutlined />}
placeholder="请输入车间号,如 R05697"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleSearch}
allowClear
style={{ width: 320 }}
/>
<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>
</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"
>
<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>
</div>
</main>
<Button type="primary" onClick={handleSearch} loading={loading}>
</Button>
<Button onClick={onClear}></Button>
</Space.Compact>
{searched && !loading && (
<Text type="secondary" style={{ marginLeft: 16 }}>
{resultCount}
</Text>
)}
</div>
);
}
export default function Home() {
const [data, setData] = useState<DataRow[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searched, setSearched] = useState(false);
const handleSearch = useCallback(async (workshopNo: string) => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/production-data?workshopNo=${encodeURIComponent(workshopNo)}`
);
const json = await res.json();
if (!res.ok) {
setError(json.error || "查询失败");
return;
}
setColumns(json.columns);
setData(json.data);
setSearched(true);
} catch {
setError("网络请求失败");
} finally {
setLoading(false);
}
}, []);
const handleClear = useCallback(() => {
setData([]);
setColumns([]);
setSearched(false);
setError(null);
}, []);
const tableColumns = useMemo(() => buildColumns(columns), [columns]);
const totalWidth = useMemo(
() => tableColumns.reduce((sum, col) => sum + (col.width as number), 0),
[tableColumns]
);
const scrollConfig = useMemo(
() => ({ x: totalWidth, y: "calc(100vh - 140px)" }),
[totalWidth]
);
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<SearchBar
loading={loading}
resultCount={data.length}
searched={searched}
onSearch={handleSearch}
onClear={handleClear}
/>
<div style={{ flex: 1, minHeight: 0 }}>
{error && (
<Alert
message={error}
type="error"
showIcon
style={{ margin: "16px 24px" }}
/>
)}
{loading && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
>
<Spin size="large" tip="查询中..." />
</div>
)}
{!loading && !error && !searched && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
>
<Empty description="请输入车间号并点击查询" />
</div>
)}
{!loading && !error && searched && data.length > 0 && (
<Table<DataRow>
columns={tableColumns}
dataSource={data}
rowKey="ID"
bordered
size="small"
pagination={false}
scroll={scrollConfig}
sticky
/>
)}
{!loading && !error && searched && data.length === 0 && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
>
<Empty description="未找到匹配的数据" />
</div>
)}
</div>
</div>
);
}