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,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: ["192.168.110.146"],
};
export default nextConfig;

1858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"antd": "^6.4.3",
"mssql": "^12.5.5",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/mssql": "^12.3.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import sql from "mssql";
const dbConfig = {
server: process.env.DB_SERVER!,
port: 1433,
database: process.env.DB_DATABASE!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
options: {
trustServerCertificate: process.env.DB_TRUST_CERT === "true",
encrypt: false,
},
};
export async function GET(request: NextRequest) {
const workshopNo = request.nextUrl.searchParams.get("workshopNo");
if (!workshopNo) {
return NextResponse.json(
{ error: "请提供车间号参数" },
{ status: 400 }
);
}
let pool: sql.ConnectionPool | undefined;
try {
pool = await sql.connect(dbConfig);
const result = await pool
.request()
.input("车间号", sql.NVarChar(50), workshopNo)
.execute(
"[productionContractData].[sp_压力表合同生产数据_按车间号]"
);
const recordset = result.recordset;
if (!recordset || recordset.length === 0) {
return NextResponse.json({ columns: [], data: [], total: 0 });
}
const columns = Object.keys(recordset[0]);
const data = recordset.map((row: Record<string, unknown>) => {
const serialized: Record<string, unknown> = {};
for (const key of columns) {
const val = row[key];
if (val instanceof Date) {
serialized[key] = val.toISOString().split("T")[0];
} else {
serialized[key] = val;
}
}
return serialized;
});
return NextResponse.json({ columns, data, total: data.length });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("DB query error:", message);
return NextResponse.json({ error: message }, { status: 500 });
} finally {
if (pool) {
await pool.close();
}
}
}

View File

@@ -1,26 +1,8 @@
@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;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}

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: "Web Table - 压力表合同生产数据",
description: "压力表合同生产数据查询系统",
};
export default function RootLayout({
@@ -23,11 +12,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}

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>
);
}