From d3abadf8eb071a2003eab9c0cc495d8327398e75 Mon Sep 17 00:00:00 2001 From: zty Date: Fri, 12 Jun 2026 16:09:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=90=E6=96=99=20Excel=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 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) --- .../materials/dto/bulk-create-material.dto.ts | 12 ++ .../api/src/materials/materials.controller.ts | 6 + apps/api/src/materials/materials.service.ts | 21 +++ apps/web/package.json | 3 +- apps/web/src/api/materials.ts | 4 + .../src/components/MaterialImportModal.vue | 173 ++++++++++++++++++ apps/web/src/pages/MaterialLibrary.vue | 14 +- pnpm-lock.yaml | 72 ++++++++ 8 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/materials/dto/bulk-create-material.dto.ts create mode 100644 apps/web/src/components/MaterialImportModal.vue diff --git a/apps/api/src/materials/dto/bulk-create-material.dto.ts b/apps/api/src/materials/dto/bulk-create-material.dto.ts new file mode 100644 index 0000000..061ad6e --- /dev/null +++ b/apps/api/src/materials/dto/bulk-create-material.dto.ts @@ -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[]; +} diff --git a/apps/api/src/materials/materials.controller.ts b/apps/api/src/materials/materials.controller.ts index 9df5a0a..f2cded6 100644 --- a/apps/api/src/materials/materials.controller.ts +++ b/apps/api/src/materials/materials.controller.ts @@ -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, diff --git a/apps/api/src/materials/materials.service.ts b/apps/api/src/materials/materials.service.ts index d171a02..d497091 100644 --- a/apps/api/src/materials/materials.service.ts +++ b/apps/api/src/materials/materials.service.ts @@ -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({ diff --git a/apps/web/package.json b/apps/web/package.json index 5644d65..74502a2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/api/materials.ts b/apps/web/src/api/materials.ts index 69b2499..cafe428 100644 --- a/apps/web/src/api/materials.ts +++ b/apps/web/src/api/materials.ts @@ -67,6 +67,10 @@ export function createMaterial(input: MaterialInput) { return http.post('/materials', input); } +export function bulkCreateMaterials(items: MaterialInput[]) { + return http.post('/materials/bulk', { items }); +} + export function updateMaterial(id: string, input: Partial) { return http.patch(`/materials/${id}`, input); } diff --git a/apps/web/src/components/MaterialImportModal.vue b/apps/web/src/components/MaterialImportModal.vue new file mode 100644 index 0000000..102b9aa --- /dev/null +++ b/apps/web/src/components/MaterialImportModal.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/apps/web/src/pages/MaterialLibrary.vue b/apps/web/src/pages/MaterialLibrary.vue index cc6cd77..0c83bc4 100644 --- a/apps/web/src/pages/MaterialLibrary.vue +++ b/apps/web/src/pages/MaterialLibrary.vue @@ -30,7 +30,10 @@ 查询 - + 新建材料 +
+ 📥 Excel 批量导入 + + 新建材料 +
+ @@ -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('已删除'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9da16e3..1a123c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}