Add Excel export feature for full production data
Add "导出全部" button that exports all contract production data to an .xlsx file via a new API endpoint using exceljs, with formatted headers, date columns, auto-width, and frozen header row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
954
package-lock.json
generated
954
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"mssql": "^12.5.5",
|
"mssql": "^12.5.5",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
139
src/app/api/export-excel/route.ts
Normal file
139
src/app/api/export-excel/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import sql from "mssql";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATE_COLUMNS = new Set([
|
||||||
|
"签订日期",
|
||||||
|
"交货日期",
|
||||||
|
"接单日期",
|
||||||
|
"执行卡下发日期",
|
||||||
|
"焊接领料日期",
|
||||||
|
"领料单签收日期",
|
||||||
|
"库房发出日期",
|
||||||
|
"焊接接收日期",
|
||||||
|
"日期",
|
||||||
|
"超压日期",
|
||||||
|
"退火日期",
|
||||||
|
"氦测日期",
|
||||||
|
"表壳焊接日期",
|
||||||
|
"隔膜接收",
|
||||||
|
"隔离膜片接收",
|
||||||
|
"车波纹日期",
|
||||||
|
"膜片焊",
|
||||||
|
"喷涂发出",
|
||||||
|
"喷涂回来",
|
||||||
|
"调试日期",
|
||||||
|
"检验日期",
|
||||||
|
"入库日期",
|
||||||
|
"烘洗",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
let pool: sql.ConnectionPool | undefined;
|
||||||
|
try {
|
||||||
|
pool = await sql.connect(dbConfig);
|
||||||
|
const result = await pool
|
||||||
|
.request()
|
||||||
|
.execute("[productionContractData].[sp_压力表合同生产数据_全部]");
|
||||||
|
|
||||||
|
const recordset = result.recordset;
|
||||||
|
if (!recordset || recordset.length === 0) {
|
||||||
|
return NextResponse.json({ error: "没有数据可导出" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(recordset[0]);
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet("压力表合同生产数据");
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
const headerRow = sheet.addRow(columns);
|
||||||
|
headerRow.eachCell((cell) => {
|
||||||
|
cell.font = { bold: true };
|
||||||
|
cell.fill = {
|
||||||
|
type: "pattern",
|
||||||
|
pattern: "solid",
|
||||||
|
fgColor: { argb: "FFE0EAF6" },
|
||||||
|
};
|
||||||
|
cell.alignment = { horizontal: "center" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
for (const row of recordset) {
|
||||||
|
const values = columns.map((col) => {
|
||||||
|
const val = row[col];
|
||||||
|
if (val instanceof Date) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
if (val === null || val === undefined) return "";
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
sheet.addRow(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date columns and auto-width
|
||||||
|
const dateColIndices: number[] = [];
|
||||||
|
const maxWidths: number[] = columns.map((col) => col.length);
|
||||||
|
|
||||||
|
columns.forEach((col, idx) => {
|
||||||
|
if (DATE_COLUMNS.has(col)) {
|
||||||
|
dateColIndices.push(idx + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sheet.eachRow((row, rowNumber) => {
|
||||||
|
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
||||||
|
if (rowNumber > 1 && dateColIndices.includes(colNumber)) {
|
||||||
|
if (cell.value instanceof Date) {
|
||||||
|
cell.numFmt = "yyyy/mm/dd";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Track max width
|
||||||
|
const text = cell.text || "";
|
||||||
|
const width = Math.max(maxWidths[colNumber - 1] || 0, text.length + 2);
|
||||||
|
maxWidths[colNumber - 1] = width;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply column widths (capped between 8 and 50)
|
||||||
|
columns.forEach((_, idx) => {
|
||||||
|
sheet.getColumn(idx + 1).width = Math.min(Math.max(maxWidths[idx], 8), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Freeze header row
|
||||||
|
sheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
|
|
||||||
|
// Stream to buffer
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type":
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"Content-Disposition":
|
||||||
|
"attachment; filename*=UTF-8''" + encodeURIComponent("压力表合同生产数据.xlsx"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
console.error("Excel export error:", message);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
if (pool) {
|
||||||
|
await pool.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { SearchOutlined, SettingOutlined, HolderOutlined, ClockCircleOutlined } from "@ant-design/icons";
|
import { SearchOutlined, SettingOutlined, HolderOutlined, ClockCircleOutlined, DownloadOutlined } from "@ant-design/icons";
|
||||||
import type { ColumnsType } from "antd/es/table";
|
import type { ColumnsType } from "antd/es/table";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -333,6 +333,8 @@ function SearchBar({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onClear,
|
onClear,
|
||||||
onOpenColumnSettings,
|
onOpenColumnSettings,
|
||||||
|
exporting,
|
||||||
|
onExport,
|
||||||
}: {
|
}: {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
@@ -340,6 +342,8 @@ function SearchBar({
|
|||||||
onSearch: (workshopNo: string) => Promise<void>;
|
onSearch: (workshopNo: string) => Promise<void>;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onOpenColumnSettings: () => void;
|
onOpenColumnSettings: () => void;
|
||||||
|
exporting: boolean;
|
||||||
|
onExport: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [history, setHistory] = useState<string[]>(() => loadHistory());
|
const [history, setHistory] = useState<string[]>(() => loadHistory());
|
||||||
@@ -418,6 +422,13 @@ function SearchBar({
|
|||||||
<Button icon={<SettingOutlined />} onClick={onOpenColumnSettings}>
|
<Button icon={<SettingOutlined />} onClick={onOpenColumnSettings}>
|
||||||
列设置
|
列设置
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={onExport}
|
||||||
|
loading={exporting}
|
||||||
|
>
|
||||||
|
导出全部
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
{searched && !loading && (
|
{searched && !loading && (
|
||||||
<Text type="secondary" style={{ marginLeft: 16 }}>
|
<Text type="secondary" style={{ marginLeft: 16 }}>
|
||||||
@@ -438,6 +449,7 @@ export default function Home() {
|
|||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadConfig);
|
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadConfig);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
// Persist config
|
// Persist config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -473,6 +485,31 @@ export default function Home() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/export-excel");
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
setError(json?.error || "导出失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "压力表合同生产数据.xlsx";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
setError("导出请求失败");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleApplyConfig = useCallback((newConfig: ColumnConfig[]) => {
|
const handleApplyConfig = useCallback((newConfig: ColumnConfig[]) => {
|
||||||
setColumnConfig(newConfig);
|
setColumnConfig(newConfig);
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
@@ -500,6 +537,8 @@ export default function Home() {
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
onOpenColumnSettings={() => setModalOpen(true)}
|
onOpenColumnSettings={() => setModalOpen(true)}
|
||||||
|
exporting={exporting}
|
||||||
|
onExport={handleExport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user