feat: 材料 Excel 批量入库

- 后端 POST /materials/bulk(BulkCreateMaterialsDto,最多2000条)→createMany入自建库
- 前端 MaterialImportModal(自建库"Excel批量导入"):下载模板(xlsx,含表头+示例),
  拖拽上传→XLSX解析→按列映射5污染物×3参数→必填校验(标红错误行)→预览→批量导入
- 装 xlsx(SheetJS)前端解析,免后端文件处理
- 实测:3行(2有效1缺类别)→正确解析校验→导入2条到自建库

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zty 2026-06-12 16:09:32 +08:00
parent d4f8bb3826
commit d3abadf8eb
8 changed files with 303 additions and 2 deletions

View File

@ -0,0 +1,12 @@
import { Type } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { CreateMaterialDto } from './create-material.dto';
export class BulkCreateMaterialsDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(2000)
@ValidateNested({ each: true })
@Type(() => CreateMaterialDto)
items!: CreateMaterialDto[];
}

View File

@ -13,6 +13,7 @@ import { MaterialsService } from './materials.service';
import { QueryMaterialsDto } from './dto/query-materials.dto';
import { CreateMaterialDto } from './dto/create-material.dto';
import { UpdateMaterialDto } from './dto/update-material.dto';
import { BulkCreateMaterialsDto } from './dto/bulk-create-material.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentOrg, OrgPayload } from '../auth/current-org.decorator';
@ -36,6 +37,11 @@ export class MaterialsController {
return this.materials.create(org.id, dto);
}
@Post('bulk')
bulk(@CurrentOrg() org: OrgPayload, @Body() dto: BulkCreateMaterialsDto) {
return this.materials.createMany(org.id, dto.items);
}
@Patch(':id')
update(
@CurrentOrg() org: OrgPayload,

View File

@ -82,6 +82,27 @@ export class MaterialsService {
});
}
/** 批量入库(自建库)。返回成功条数。 */
async createMany(orgId: string, items: CreateMaterialDto[]) {
const data = items.map((dto) => ({
id: this.genId(),
name: dto.name,
category: dto.category,
brand: dto.brand,
manufacturer: dto.manufacturer,
spec: dto.spec,
envGrade: dto.envGrade,
healthGrade: dto.healthGrade,
usageUnit: dto.usageUnit ?? 'm²',
sortOrder: dto.sortOrder ?? 0,
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
isPublic: false,
ownerOrgId: orgId,
}));
const res = await this.prisma.material.createMany({ data });
return { created: res.count };
}
async update(orgId: string, id: string, dto: UpdateMaterialDto) {
await this.assertOwned(orgId, id);
return this.prisma.material.update({

View File

@ -16,7 +16,8 @@
"axios": "^1.7.7",
"pinia": "^2.2.4",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
"vue-router": "^4.4.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",

View File

@ -67,6 +67,10 @@ export function createMaterial(input: MaterialInput) {
return http.post<any, Material>('/materials', input);
}
export function bulkCreateMaterials(items: MaterialInput[]) {
return http.post<any, { created: number }>('/materials/bulk', { items });
}
export function updateMaterial(id: string, input: Partial<MaterialInput>) {
return http.patch<any, Material>(`/materials/${id}`, input);
}

View File

@ -0,0 +1,173 @@
<template>
<a-modal :open="open" title="Excel 批量入库材料" width="720px" :confirm-loading="saving" @cancel="emit('cancel')">
<template #footer>
<a-button @click="emit('cancel')">取消</a-button>
<a-button @click="downloadTemplate">下载模板</a-button>
<a-button type="primary" :disabled="!validRows.length || saving" :loading="saving" @click="submit">
导入 {{ validRows.length }}
</a-button>
</template>
<a-alert
type="info"
show-icon
style="margin-bottom: 14px"
message="先「下载模板」按列填好,再选文件导入。必填:材料名称、材料类别。15 个散发参数列(甲醛/TVOC/苯/甲苯/二甲苯 各 Y0/Yp/B),不释放填 0。导入的材料进自建库。"
/>
<a-upload-dragger
:before-upload="onFile"
:show-upload-list="false"
accept=".xlsx,.xls"
:disabled="saving"
>
<p class="ant-upload-drag-icon" style="margin-bottom: 6px"><inbox-outlined style="font-size: 32px; color: #b4232a" /></p>
<p>点击或拖拽 Excel 文件到此处</p>
<p style="color: #999; font-size: 12px">支持 .xlsx / .xls</p>
</a-upload-dragger>
<div v-if="fileName" class="parse-result">
<div class="pr-line">
已解析 <b>{{ fileName }}</b>: {{ rows.length }} ,
<span style="color: #2f8f5b">有效 {{ validRows.length }}</span>
<span v-if="errors.length" style="color: #b4232a">,错误 {{ errors.length }}</span>
</div>
<div v-if="errors.length" class="errs">
<div v-for="(e, i) in errors.slice(0, 8)" :key="i"> {{ e.row }} :{{ e.msg }}</div>
<div v-if="errors.length > 8"> 其余 {{ errors.length - 8 }} 条错误</div>
</div>
<a-table
v-if="validRows.length"
:columns="previewCols"
:data-source="validRows.slice(0, 6)"
size="small"
:pagination="false"
row-key="name"
style="margin-top: 10px"
/>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import * as XLSX from 'xlsx';
import { message } from 'ant-design-vue';
import { InboxOutlined } from '@ant-design/icons-vue';
import { POLLUTANTS, POLLUTANT_LABELS, type Pollutant } from '@airpredict/shared';
import { bulkCreateMaterials, type MaterialInput } from '../api/materials';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'ok', n: number): void; (e: 'cancel'): void }>();
const saving = ref(false);
const fileName = ref('');
const rows = ref<any[]>([]);
const validRows = ref<MaterialInput[]>([]);
const errors = ref<{ row: number; msg: string }[]>([]);
const previewCols = [
{ title: '材料名称', dataIndex: 'name' },
{ title: '类别', dataIndex: 'category' },
{ title: '品牌', dataIndex: 'brand' },
{ title: '甲醛Y0', customRender: ({ record }: any) => record.emissionParams.hcho.y0 },
{ title: 'TVOC Y0', customRender: ({ record }: any) => record.emissionParams.tvoc.y0 },
];
// + 5×3
const COLS = ['材料名称', '材料类别', '材料品牌', '材料厂家', '材料规格', '环保等级', '健康等级', '用量单位', '排序权重'];
const PARAM_COLS: { col: string; p: Pollutant; k: 'y0' | 'yp' | 'b' }[] = [];
for (const p of POLLUTANTS) {
for (const k of ['y0', 'yp', 'b'] as const) {
const suffix = k === 'y0' ? 'Y0' : k === 'yp' ? 'Yp' : 'B';
PARAM_COLS.push({ col: `${POLLUTANT_LABELS[p].zh}${suffix}`, p, k });
}
}
function num(v: any) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function onFile(file: File) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const wb = XLSX.read(e.target!.result, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const json = XLSX.utils.sheet_to_json<any>(ws, { defval: '' });
parse(file.name, json);
} catch (err: any) {
message.error('解析失败:' + err.message);
}
};
reader.readAsArrayBuffer(file);
return false; // antd
}
function parse(name: string, json: any[]) {
fileName.value = name;
rows.value = json;
const valid: MaterialInput[] = [];
const errs: { row: number; msg: string }[] = [];
json.forEach((r, i) => {
const rowNo = i + 2; //
const matName = String(r['材料名称'] ?? '').trim();
const category = String(r['材料类别'] ?? '').trim();
if (!matName) { errs.push({ row: rowNo, msg: '材料名称为空' }); return; }
if (!category) { errs.push({ row: rowNo, msg: '材料类别为空' }); return; }
const emissionParams: any = {};
for (const p of POLLUTANTS) emissionParams[p] = { y0: 0, yp: 0, b: 0 };
for (const pc of PARAM_COLS) emissionParams[pc.p][pc.k] = num(r[pc.col]);
valid.push({
name: matName,
category,
brand: String(r['材料品牌'] ?? '').trim() || undefined,
manufacturer: String(r['材料厂家'] ?? '').trim() || undefined,
spec: String(r['材料规格'] ?? '').trim() || undefined,
envGrade: String(r['环保等级'] ?? '').trim() || undefined,
healthGrade: String(r['健康等级'] ?? '').trim() || undefined,
usageUnit: String(r['用量单位'] ?? '').trim() || 'm²',
sortOrder: r['排序权重'] !== '' ? num(r['排序权重']) : 0,
emissionParams,
});
});
validRows.value = valid;
errors.value = errs;
if (!valid.length) message.warning('没有可导入的有效行');
}
function downloadTemplate() {
const headers = [...COLS, ...PARAM_COLS.map((c) => c.col)];
const example: any = {
材料名称: '示例·多层实木复合地板', 材料类别: '木地板/实木地板', 材料品牌: '某品牌',
材料厂家: '某厂家', 材料规格: '12mm', 环保等级: 'E1', 健康等级: 'B', 用量单位: 'm²', 排序权重: 100,
};
PARAM_COLS.forEach((c) => (example[c.col] = 0));
example['甲醛Y0'] = 0.09; example['甲醛Yp'] = 0.4; example['甲醛B'] = 0.47;
const ws = XLSX.utils.json_to_sheet([example], { header: headers });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '材料');
XLSX.writeFile(wb, '材料批量导入模板.xlsx');
}
async function submit() {
if (!validRows.value.length) return;
saving.value = true;
try {
const res = await bulkCreateMaterials(validRows.value);
message.success(`已导入 ${res.created} 条材料`);
emit('ok', res.created);
reset();
} finally {
saving.value = false;
}
}
function reset() { fileName.value = ''; rows.value = []; validRows.value = []; errors.value = []; }
</script>
<style scoped>
.parse-result { margin-top: 14px; }
.pr-line { font-size: 13px; }
.errs { margin-top: 8px; background: #fff7f5; border: 1px solid #f0d0c8; border-radius: 6px; padding: 8px 12px; font-size: 12px; color: #b4232a; max-height: 120px; overflow: auto; }
</style>

View File

@ -30,7 +30,10 @@
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
</a-form-item>
</a-form>
<a-button v-if="scope === 'self'" type="primary" @click="openCreate">+ 新建材料</a-button>
<div v-if="scope === 'self'" style="display: flex; gap: 8px">
<a-button @click="importOpen = true">📥 Excel 批量导入</a-button>
<a-button type="primary" @click="openCreate">+ 新建材料</a-button>
</div>
</div>
<a-table
@ -95,6 +98,7 @@
</a-modal>
<MaterialFormModal :open="formOpen" :material="editing" @ok="onFormOk" @cancel="formOpen = false" />
<MaterialImportModal :open="importOpen" @ok="onImported" @cancel="importOpen = false" />
</a-card>
</template>
@ -105,6 +109,7 @@ import { POLLUTANTS, POLLUTANT_LABELS } from '@airpredict/shared';
import { listMaterials, deleteMaterial, type Material, type Paged } from '../api/materials';
import { toggleFavorite } from '../api/favorites';
import MaterialFormModal from '../components/MaterialFormModal.vue';
import MaterialImportModal from '../components/MaterialImportModal.vue';
const pollutants = POLLUTANTS;
const labels = POLLUTANT_LABELS;
@ -190,6 +195,13 @@ function onFormOk() {
reload();
}
const importOpen = ref(false);
function onImported() {
importOpen.value = false;
scope.value = 'self';
reload();
}
async function onDelete(r: Material) {
await deleteMaterial(r.id);
message.success('已删除');

View File

@ -104,6 +104,9 @@ importers:
vue-router:
specifier: ^4.4.5
version: 4.6.4(vue@3.5.35(typescript@5.9.3))
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.1.4
@ -823,6 +826,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@ -994,6 +1001,10 @@ packages:
caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -1043,6 +1054,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1112,6 +1127,11 @@ packages:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@ -1357,6 +1377,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@ -2005,6 +2029,10 @@ packages:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -2338,6 +2366,14 @@ packages:
engines: {node: '>= 8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@ -2350,6 +2386,11 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@ -3059,6 +3100,8 @@ snapshots:
acorn@8.16.0: {}
adler-32@1.3.1: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@ -3268,6 +3311,11 @@ snapshots:
caniuse-lite@1.0.30001797: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -3317,6 +3365,8 @@ snapshots:
clone@1.0.4: {}
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -3380,6 +3430,8 @@ snapshots:
optionalDependencies:
typescript: 5.7.2
crc-32@1.2.2: {}
create-require@1.1.1: {}
cross-spawn@7.0.6:
@ -3648,6 +3700,8 @@ snapshots:
forwarded@0.2.0: {}
frac@1.1.2: {}
fresh@0.5.2: {}
fs-extra@10.1.0:
@ -4328,6 +4382,10 @@ snapshots:
source-map@0.7.4: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
statuses@2.0.2: {}
streamsearch@1.1.0: {}
@ -4602,6 +4660,10 @@ snapshots:
dependencies:
isexe: 2.0.0
wmf@1.0.2: {}
word@0.3.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@ -4620,6 +4682,16 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xtend@4.0.2: {}
yargs-parser@21.1.1: {}