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:
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user