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:
@@ -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
1858
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
65
src/app/api/production-data/route.ts
Normal file
65
src/app/api/production-data/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
331
src/app/page.tsx
331
src/app/page.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user