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"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ allowedDevOrigins: ["192.168.110.146"],
}; };
export default nextConfig; 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" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.5",
"antd": "^6.4.3",
"mssql": "^12.5.5",
"next": "16.2.9", "next": "16.2.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/mssql": "^12.3.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^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"; @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 { body {
background: var(--background); margin: 0;
color: var(--foreground); padding: 0;
font-family: Arial, Helvetica, sans-serif; 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 type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; 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 = { export const metadata: Metadata = {
title: "Create Next App", title: "Web Table - 压力表合同生产数据",
description: "Generated by create next app", description: "压力表合同生产数据查询系统",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,11 +12,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="zh-CN">
lang="en" <body>{children}</body>
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html> </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 ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div
<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"> style={{
<Image flexShrink: 0,
className="dark:invert" padding: "16px 24px",
src="/next.svg" borderBottom: "1px solid #f0f0f0",
alt="Next.js logo" background: "#fff",
width={100} }}
height={20} >
priority <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"> <Button type="primary" onClick={handleSearch} loading={loading}>
<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. </Button>
</h1> <Button onClick={onClear}></Button>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> </Space.Compact>
Looking for a starting point or more instructions? Head over to{" "} {searched && !loading && (
<a <Text type="secondary" style={{ marginLeft: 16 }}>
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" {resultCount}
className="font-medium text-zinc-950 dark:text-zinc-50" </Text>
> )}
Templates </div>
</a>{" "} );
or the{" "} }
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" export default function Home() {
className="font-medium text-zinc-950 dark:text-zinc-50" const [data, setData] = useState<DataRow[]>([]);
> const [columns, setColumns] = useState<string[]>([]);
Learning const [loading, setLoading] = useState(false);
</a>{" "} const [error, setError] = useState<string | null>(null);
center. const [searched, setSearched] = useState(false);
</p>
</div> const handleSearch = useCallback(async (workshopNo: string) => {
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> setLoading(true);
<a setError(null);
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]" try {
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" const res = await fetch(
target="_blank" `/api/production-data?workshopNo=${encodeURIComponent(workshopNo)}`
rel="noopener noreferrer" );
> const json = await res.json();
<Image if (!res.ok) {
className="dark:invert" setError(json.error || "查询失败");
src="/vercel.svg" return;
alt="Vercel logomark" }
width={16} setColumns(json.columns);
height={16} setData(json.data);
/> setSearched(true);
Deploy Now } catch {
</a> setError("网络请求失败");
<a } finally {
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]" setLoading(false);
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"
> const handleClear = useCallback(() => {
Documentation setData([]);
</a> setColumns([]);
</div> setSearched(false);
</main> 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> </div>
); );
} }