From 8ee0784f20eeebbfd5923d007038f214e1eeff7c Mon Sep 17 00:00:00 2001 From: zty Date: Tue, 28 Jul 2026 21:16:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(ms/web):=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E6=9D=90=E6=96=99=E5=BA=93=E6=89=A9=E5=B1=95=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=20+=20=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20+=20=E9=A5=BC=E5=9B=BE=E6=B5=85=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理端: 去掉环保等级, 材料表单/列表新增16个字段(型号/参考价格/供应商/联系人/生产日期/样品来源/检测信息等) - 管理端: 新建材料必填项加*号(名称/类别/品牌/污染物参数/样品来源) - 管理端: 材料导入Excel模板更新为39列新格式, 解析逻辑同步 - 管理端: 材料表单增加样品图片/应用效果图上传 - 后端: PublicMaterial/SelfMaterial schema 新增16个字段 - 用户端: 饼图颜色改为浅色系 Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01T71oHHhfhLAWAL9EuoFuYh --- .../服务端/iapip-svr/package-lock.json | 125 ++ .../源码/服务端/iapip-svr/package.json | 2 + .../服务端/iapip-svr/prisma/schema.prisma | 42 + .../iapip-svr/src/controllers/material.ts | 116 +- .../源码/用户端/iapip-web/.umirc.ts | 5 - .../src/components/material-card/index.tsx | 179 ++- .../src/components/space-modal-form/index.tsx | 1095 +++++++++++++++++ .../iapip-web/src/pages/article/index.tsx | 111 +- .../iapip-web/src/pages/articles/index.tsx | 17 +- .../iapip-web/src/pages/landing/index.tsx | 447 ++++++- .../iapip-web/src/pages/landing/landing.css | 25 +- .../iapip-web/src/pages/source/index.tsx | 39 +- .../iapip-web/src/pages/source/source.css | 11 - .../iapip-web/src/services/api/material.ts | 22 + .../管理端/iapip-ms/public/fts/imft.xlsx | Bin 0 -> 12640 bytes .../iapip-ms/src/common/excel-validator.ts | 75 ++ .../管理端/iapip-ms/src/common/types.ts | 16 + .../import-file-modal/materials.tsx | 316 +++++ .../src/components/material-card/index.tsx | 121 +- .../components/material-modal-form/index.tsx | 617 ++++++++++ .../iapip-ms/src/services/api/config.ts | 236 ++++ 21 files changed, 3437 insertions(+), 180 deletions(-) create mode 100644 空气质量预测/源码/用户端/iapip-web/src/components/space-modal-form/index.tsx create mode 100644 空气质量预测/源码/管理端/iapip-ms/public/fts/imft.xlsx create mode 100644 空气质量预测/源码/管理端/iapip-ms/src/common/excel-validator.ts create mode 100644 空气质量预测/源码/管理端/iapip-ms/src/components/import-file-modal/materials.tsx create mode 100644 空气质量预测/源码/管理端/iapip-ms/src/components/material-modal-form/index.tsx create mode 100644 空气质量预测/源码/管理端/iapip-ms/src/services/api/config.ts diff --git a/空气质量预测/源码/服务端/iapip-svr/package-lock.json b/空气质量预测/源码/服务端/iapip-svr/package-lock.json index 1028aa1..3e0e103 100644 --- a/空气质量预测/源码/服务端/iapip-svr/package-lock.json +++ b/空气质量预测/源码/服务端/iapip-svr/package-lock.json @@ -22,8 +22,10 @@ "koa-body": "^6.0.1", "koa-router": "^12.0.1", "log4js": "^6.9.1", + "mammoth": "^1.12.0", "moment": "^2.29.4", "node-schedule": "^2.1.1", + "pdf-parse": "^1.1.1", "reflect-metadata": "^0.1.13", "source-map-support": "^0.5.21" }, @@ -1751,6 +1753,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -2556,6 +2567,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2622,6 +2639,15 @@ "node": ">=12" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz", @@ -4157,6 +4183,17 @@ "resolved": "https://registry.npmmirror.com/long-timeout/-/long-timeout-0.1.1.tgz", "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz", @@ -4191,6 +4228,39 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", @@ -4368,6 +4438,12 @@ "node": ">= 0.6" } }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, "node_modules/node-schedule": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz", @@ -4466,6 +4542,12 @@ "resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz", "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", @@ -4595,6 +4677,28 @@ "node": "*" } }, + "node_modules/pdf-parse": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/pdf-parse/-/pdf-parse-1.1.1.tgz", + "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + } + }, + "node_modules/pdf-parse/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", @@ -5159,6 +5263,12 @@ "source-map": "^0.6.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", @@ -5510,6 +5620,12 @@ "dev": true, "license": "MIT" }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", @@ -5814,6 +5930,15 @@ "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/空气质量预测/源码/服务端/iapip-svr/package.json b/空气质量预测/源码/服务端/iapip-svr/package.json index a11a83a..12789be 100644 --- a/空气质量预测/源码/服务端/iapip-svr/package.json +++ b/空气质量预测/源码/服务端/iapip-svr/package.json @@ -37,8 +37,10 @@ "koa-body": "^6.0.1", "koa-router": "^12.0.1", "log4js": "^6.9.1", + "mammoth": "^1.12.0", "moment": "^2.29.4", "node-schedule": "^2.1.1", + "pdf-parse": "^1.1.1", "reflect-metadata": "^0.1.13", "source-map-support": "^0.5.21" }, diff --git a/空气质量预测/源码/服务端/iapip-svr/prisma/schema.prisma b/空气质量预测/源码/服务端/iapip-svr/prisma/schema.prisma index 8e6f443..bf05e16 100644 --- a/空气质量预测/源码/服务端/iapip-svr/prisma/schema.prisma +++ b/空气质量预测/源码/服务端/iapip-svr/prisma/schema.prisma @@ -439,6 +439,22 @@ model PublicMaterial { spec String? eco_level String? health_level String? + model_name String? + sample_image String? + reference_price String? + application_case String? + effect_image String? + supplier String? + contact_person String? + contact_phone String? + production_date String? + batch_no String? + sample_source String? + client_unit String? + test_date String? + test_standard String? + report_no String? + test_org String? methanal Float? methanal_min_be Float? methanal_be_area Float? @@ -486,6 +502,22 @@ model SelfMaterial { spec String? eco_level String? health_level String? + model_name String? + sample_image String? + reference_price String? + application_case String? + effect_image String? + supplier String? + contact_person String? + contact_phone String? + production_date String? + batch_no String? + sample_source String? + client_unit String? + test_date String? + test_standard String? + report_no String? + test_org String? methanal Float? methanal_min_be Float? methanal_be_area Float? @@ -634,6 +666,16 @@ model ApcLimitStandard { // ------ 论坛 ------ +model SiteConfig { + key String @id + value String @db.Text + updated_at DateTime @updatedAt + + @@map("site_config") +} + +// ------ 论坛 ------ + model ForumThread { id Int @id @default(autoincrement()) board String // 板块(取值属于 FORUM_BOARDS) diff --git a/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts b/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts index e61a0d0..2b40b11 100644 --- a/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts +++ b/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts @@ -1,3 +1,5 @@ +import fs from 'fs' +import path from 'path' import Router from 'koa-router' import authn from '../middlewares/authn' import { ADMIN_TYPE, AcctCert, AdminCert, AnyObj, CERT_TYPE, UserCert } from '../common/types' @@ -52,30 +54,104 @@ function rankAndPaginate(materials: AnyObj[], query: AnyObj): AnyObj[] { return list } -// 环保建材展示(公开,免认证):E0 优先,不足补 E1,最多 8 条 +const ECO_PICKS_FILE = path.join(__dirname, '../../uploads/eco-picks.json') +const readEcoPicks = (): string[] => { + try { return JSON.parse(fs.readFileSync(ECO_PICKS_FILE, 'utf8')) } catch { return [] } +} +const ECO_IMAGES_FILE = path.join(__dirname, '../../uploads/eco-images.json') +const readEcoImages = (): Record => { + try { return JSON.parse(fs.readFileSync(ECO_IMAGES_FILE, 'utf8')) } catch { return {} } +} +const selectFields = { + material_id: true, name: true, category: true, + brand: true, factory: true, eco_level: true, methanal: true +} + materialRouter.get('/eco', async ctx => { - const take = 8 - const selectFields = { - material_id: true, name: true, category: true, - brand: true, factory: true, eco_level: true, methanal: true - } - const e0 = await db.publicMaterial.findMany({ - where: { display: 1, eco_level: 'E0' }, - orderBy: { material_id: 'desc' }, - take, - select: selectFields - }) - let list = e0 - if (list.length < take) { - const e1 = await db.publicMaterial.findMany({ - where: { display: 1, eco_level: 'E1' }, - orderBy: { material_id: 'desc' }, - take: take - list.length, + const picks = readEcoPicks() + const images = readEcoImages() + let list: any[] + if (picks.length > 0) { + const rows = await db.publicMaterial.findMany({ + where: { material_id: { in: picks }, display: 1 }, select: selectFields }) - list = list.concat(e1) + rows.sort((a, b) => picks.indexOf(a.material_id) - picks.indexOf(b.material_id)) + list = rows + } else { + const take = 8 + const e0 = await db.publicMaterial.findMany({ + where: { display: 1, eco_level: 'E0' }, + orderBy: { material_id: 'desc' }, + take, + select: selectFields + }) + list = e0 + if (list.length < take) { + const e1 = await db.publicMaterial.findMany({ + where: { display: 1, eco_level: 'E1' }, + orderBy: { material_id: 'desc' }, + take: take - list.length, + select: selectFields + }) + list = list.concat(e1) + } } - ctx.body = list + ctx.body = list.map(m => ({ ...m, image: images[m.material_id] || null })) +}) + +materialRouter.get('/eco/picks', authn(CERT_TYPE.ADMIN), async ctx => { + ctx.body = readEcoPicks() +}) + +materialRouter.put( + '/eco/picks', + authn(CERT_TYPE.ADMIN), + authz(cert => cert.type == ADMIN_TYPE.SUPER_ADMIN || (cert.type == ADMIN_TYPE.ADMIN && cert.authz_sys_cfg == 1)), + async ctx => { + const { ids } = ctx.request.body as { ids: string[] } + if (!Array.isArray(ids)) { ctx.throw(422, fail(ERR_CODE.INVALID_REQ_DATA, '需要 ids 数组')); return } + const dir = path.dirname(ECO_PICKS_FILE) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(ECO_PICKS_FILE, JSON.stringify(ids), 'utf8') + ctx.body = ids + } +) + +materialRouter.put( + '/eco/image/:material_id', + authn(CERT_TYPE.ADMIN), + authz(cert => cert.type == ADMIN_TYPE.SUPER_ADMIN || (cert.type == ADMIN_TYPE.ADMIN && cert.authz_sys_cfg == 1)), + async ctx => { + const mid = ctx.params.material_id + const { url } = ctx.request.body as { url: string } + if (!url) { ctx.throw(422, fail(ERR_CODE.INVALID_REQ_DATA, '缺少 url')); return } + const dir = path.dirname(ECO_IMAGES_FILE) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + const data = readEcoImages() + data[mid] = url + fs.writeFileSync(ECO_IMAGES_FILE, JSON.stringify(data), 'utf8') + ctx.body = data + } +) + +materialRouter.get('/eco/candidates', authn(CERT_TYPE.ADMIN), async ctx => { + const keyword = (ctx.query.keyword as string || '').trim() + const where: any = { display: 1 } + if (keyword) { + where.OR = [ + { name: { contains: keyword } }, + { brand: { contains: keyword } }, + { category: { contains: keyword } }, + { factory: { contains: keyword } } + ] + } + ctx.body = await db.publicMaterial.findMany({ + where, + select: selectFields, + orderBy: { material_id: 'desc' }, + take: 50 + }) }) // 查询材料 diff --git a/空气质量预测/源码/用户端/iapip-web/.umirc.ts b/空气质量预测/源码/用户端/iapip-web/.umirc.ts index 7ce46e1..e3de07a 100644 --- a/空气质量预测/源码/用户端/iapip-web/.umirc.ts +++ b/空气质量预测/源码/用户端/iapip-web/.umirc.ts @@ -78,11 +78,6 @@ export default defineConfig({ path: '/home', component: './home' }, - { - name: '污染源识别', - path: '/source', - component: './source' - }, { name: '模板库', path: '/template', diff --git a/空气质量预测/源码/用户端/iapip-web/src/components/material-card/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/components/material-card/index.tsx index a221225..50955dd 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/components/material-card/index.tsx +++ b/空气质量预测/源码/用户端/iapip-web/src/components/material-card/index.tsx @@ -4,6 +4,7 @@ import { searchParams } from '@/common/utils' import MaterialModalForm from '@/components/material-modal-form' import PageCard from '@/components/page-card' import api from '@/services/api' +import * as ExcelJS from 'exceljs' import { ActionType, LightFilter, @@ -14,7 +15,10 @@ import { QueryFilter } from '@ant-design/pro-components' import { Button, Form, InputNumber, Modal, Popconfirm, Select, Switch, Tag, message } from 'antd' -import { useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' + +const CAT_TAG_COLORS = ['blue', 'green', 'orange', 'purple', 'cyan', 'magenta', 'gold', 'volcano', 'geekblue', 'lime'] as const +const CAT_HEX: Record = { blue: '#1677ff', green: '#52c41a', orange: '#fa8c16', purple: '#722ed1', cyan: '#13c2c2', magenta: '#eb2f96', gold: '#faad14', volcano: '#fa541c', geekblue: '#2f54eb', lime: '#a0d911' } import { useModel } from '@umijs/max' import ImportMaterialsModal from '../import-file-modal/materials' @@ -46,6 +50,15 @@ const MaterialCard: React.FC<{ (cert?.type === ADMIN_TYPE.SUPER_ADMIN || (cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1)) const pageDataRef = useRef([]) + const [catSelections, setCatSelections] = useState([]) + const [dataFirstCats, setDataFirstCats] = useState([]) + const catColorMap = useMemo(() => { + const fromFilter = [...new Set(catSelections.map((path) => path[0]))] + const firsts = fromFilter.length > 0 ? fromFilter : dataFirstCats + const map: Record = {} + firsts.forEach((f, i) => { map[f] = CAT_TAG_COLORS[i % CAT_TAG_COLORS.length] }) + return map + }, [catSelections, dataFirstCats]) // 仅在「手动排序」视图下启用排序控件(避免在按其他字段排序时破坏 sort_order 语义)。 // 顶部「排序依据」Select 才是实际决定请求排序字段的来源(见下方 request), // 因此这里额外要求 sortKey === 'sort_order' 且未开启分组,避免在 Ypt/污染物排序或分组视图下 @@ -95,6 +108,51 @@ const MaterialCard: React.FC<{ } // 取材料在全部数据中的全局序号(0-based),用于排序输入框显示 const globalIndexOf = (material_id: string) => (pageDataRef.current ?? []).findIndex((m) => m.material_id === material_id) + const exportExcel = async () => { + const data = pageDataRef.current ?? [] + if (!data.length) { + message.warning('当前无数据可导出') + return + } + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('材料库') + ws.columns = [ + { header: '材料ID', key: 'material_id', width: 18 }, + { header: '材料名称', key: 'name', width: 22 }, + { header: '材料类别', key: 'category', width: 22 }, + { header: '材料品牌', key: 'brand', width: 18 }, + { header: '材料厂家', key: 'factory', width: 18 }, + { header: '材料规格', key: 'spec', width: 18 }, + { header: '健康等级', key: 'health_level', width: 10 }, + { header: '甲醛 min_be', key: 'methanal_min_be', width: 14 }, + { header: '甲醛 be_area', key: 'methanal_be_area', width: 14 }, + { header: '甲醛 be_roc', key: 'methanal_be_roc', width: 14 }, + { header: 'TVOC min_be', key: 'tvoc_min_be', width: 14 }, + { header: 'TVOC be_area', key: 'tvoc_be_area', width: 14 }, + { header: 'TVOC be_roc', key: 'tvoc_be_roc', width: 14 }, + { header: '苯 min_be', key: 'benzene_min_be', width: 14 }, + { header: '苯 be_area', key: 'benzene_be_area', width: 14 }, + { header: '苯 be_roc', key: 'benzene_be_roc', width: 14 }, + { header: '甲苯 min_be', key: 'toluene_min_be', width: 14 }, + { header: '甲苯 be_area', key: 'toluene_be_area', width: 14 }, + { header: '甲苯 be_roc', key: 'toluene_be_roc', width: 14 }, + { header: '二甲苯 min_be', key: 'p_xylene_min_be', width: 14 }, + { header: '二甲苯 be_area', key: 'p_xylene_be_area', width: 14 }, + { header: '二甲苯 be_roc', key: 'p_xylene_be_roc', width: 14 }, + ] + const headerRow = ws.getRow(1) + headerRow.font = { bold: true } + headerRow.alignment = { horizontal: 'center' } + data.forEach((m) => ws.addRow(m)) + const buf = await wb.xlsx.writeBuffer() + const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `材料库导出_${new Date().toISOString().slice(0, 10)}.xlsx` + a.click() + URL.revokeObjectURL(url) + } const content = ( setTab(key as DATA_TYPE)} extra={ - !modal && tab === DATA_TYPE.PRIVATE + !modal ? [ - 导入材料} - onSuccess={async () => { - await tableRef?.current?.reload?.() - }} - />, - 新建材料} - onFinish={async (data) => { - try { - await api.material.createSelf(data) - message.success('新建成功') - await tableRef?.current?.reload?.() - return true - } catch (err) { - return false - } - }} - /> + ...(isAdmin ? [] : []), + ...(tab === DATA_TYPE.PRIVATE + ? [ + 导入材料} + onSuccess={async () => { + await tableRef?.current?.reload?.() + }} + />, + 新建材料} + onFinish={async (data) => { + try { + await api.material.createSelf(data) + message.success('新建成功') + await tableRef?.current?.reload?.() + return true + } catch (err) { + return false + } + }} + /> + ] + : []) ] : [] } @@ -160,9 +223,20 @@ const MaterialCard: React.FC<{ fieldProps={{ placeholder: '请选择大类/子类(可多选跨类)', multiple: true, - showSearch: true, changeOnSelect: true, - maxTagCount: 'responsive' + maxTagCount: 'responsive', + onChange: (val: any) => setCatSelections((val ?? []) as string[][]), + tagRender: (props: any) => { + const parts = Array.isArray(props.label) ? props.label : [String(props.label ?? '')] + const labelText = parts.join(' / ') + const first = String(parts[0]) + const color = catColorMap[first] ?? 'default' + return ( + + {labelText} + + ) + } }} request={async () => { const cats = await api.config.getMtrlCatList() @@ -170,22 +244,20 @@ const MaterialCard: React.FC<{ value: item.first_category, label: item.first_category, children: item.second_categories.map((c) => ({ - value: c.second_category, + value: `${item.first_category}/${c.second_category}`, label: c.second_category })) })) }} - transform={(val) => ({ category: (val as string[][])?.map((v: string[]) => v.join('/')) ?? undefined })} + transform={(val) => ({ + category: (val as string[][])?.map((v: string[]) => + v.length === 2 ? v[1] : v[0] + ) ?? undefined + })} /> - { setFilterConditions(undefined) filterForm.resetFields() + setCatSelections([]) }} > 重置 @@ -383,7 +456,17 @@ const MaterialCard: React.FC<{ { title: '材料类别', dataIndex: 'category', - align: 'center' + align: 'center', + render: (_, m) => { + const first = (m.category ?? '').split('/')[0] + const color = catColorMap[first] + const hex = color ? (CAT_HEX[color] ?? undefined) : undefined + return ( + + {m.category} + + ) + } }, { title: '材料品牌', @@ -400,13 +483,6 @@ const MaterialCard: React.FC<{ dataIndex: 'spec', align: 'center' }, - { - title: '环保等级', - dataIndex: 'eco_level', - align: 'center', - width: 90, - render: (_, { eco_level }) => (eco_level ? {eco_level} : '-') - }, { title: '健康等级', dataIndex: 'health_level', @@ -573,11 +649,12 @@ const MaterialCard: React.FC<{ ) ) pageDataRef.current = data + const uniqueFirstCats = [...new Set(data.map((m) => (m.category ?? '').split('/')[0]).filter(Boolean))] + setDataFirstCats(uniqueFirstCats) if (sortByCategory) { data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? '')) } if (gbtParam) { - // 分组开时后端已按健康等级排序;为每档第一条打标,供「材料名称」列渲染分区小标题 let last: string | null | undefined = undefined data.forEach((m) => { if (m.health_level !== last) { @@ -586,6 +663,17 @@ const MaterialCard: React.FC<{ } }) } + const catFilter = (otherConditions as AnyObj)?.category as string[] | undefined + if (catFilter && catFilter.length > 1) { + data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? '')) + let lastCat: string | undefined = undefined + data.forEach((m) => { + if (m.category !== lastCat) { + ;(m as Material & { __catHead?: string }).__catHead = m.category ?? '未分类' + lastCat = m.category + } + }) + } return { success: true, data @@ -690,6 +778,7 @@ const MaterialCard: React.FC<{ setTab(DATA_TYPE.PUBLIC) setFilterConditions(undefined) filterForm.resetFields() + setCatSelections([]) setSelected(undefined) }} onCancel={modalProps?.onCancel} diff --git a/空气质量预测/源码/用户端/iapip-web/src/components/space-modal-form/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/components/space-modal-form/index.tsx new file mode 100644 index 0000000..c3eebf0 --- /dev/null +++ b/空气质量预测/源码/用户端/iapip-web/src/components/space-modal-form/index.tsx @@ -0,0 +1,1095 @@ +import { AnyObj, ApcLimitStandard, MTRL_UNIT, Material, MaterialCategory, Space } from '@/common/types' +import { isNone, round } from '@/common/utils' +import api from '@/services/api' +import { PlusOutlined, SelectOutlined } from '@ant-design/icons' +import { + ActionType, + EditableProTable, + ModalForm, + ProFormDigit, + ProFormInstance, + ProFormRadio, + ProFormSelect, + ProFormText, + ProTable +} from '@ant-design/pro-components' +import { Button, Divider, Tag, message, theme } from 'antd' +import React, { useEffect, useRef, useState } from 'react' +import IconFont from '../icon-font' +import MaterialCard from '../material-card' +import MaterialModalForm from '../material-modal-form' + +const POLLS = [ + { key: 'methanal', label: '甲醛', lmtKey: 'methanal_c_lmt' }, + { key: 'tvoc', label: 'TVOC', lmtKey: 'tvoc_c_lmt' }, + { key: 'benzene', label: '苯', lmtKey: 'benzene_c_lmt' }, + { key: 'toluene', label: '甲苯', lmtKey: 'toluene_c_lmt' }, + { key: 'p_xylene', label: '二甲苯', lmtKey: 'p_xylene_c_lmt' }, +] +const PIE_COLORS = ['#70C9B0', '#7EB8E2', '#F2C96D', '#E8A08E', '#B3A4E0', '#6DC8C0', '#EAB85E', '#8DB3E5', '#E090A8', '#A8C8A0'] + +const PieChart: React.FC<{ data: { name: string; value: number; isSource?: boolean }[]; size?: number }> = ({ data, size = 90 }) => { + const r = size / 2 + const ir = r * 0.55 + let cum = 0 + const slices = data.map((d, i) => { + const start = cum + cum += d.value + const end = cum + const sa = start * 2 * Math.PI - Math.PI / 2 + const ea = end * 2 * Math.PI - Math.PI / 2 + const large = end - start > 0.5 ? 1 : 0 + const x1 = r + r * Math.cos(sa), y1 = r + r * Math.sin(sa) + const x2 = r + r * Math.cos(ea), y2 = r + r * Math.sin(ea) + const ix1 = r + ir * Math.cos(sa), iy1 = r + ir * Math.sin(sa) + const ix2 = r + ir * Math.cos(ea), iy2 = r + ir * Math.sin(ea) + const path = d.value >= 0.9999 + ? `M ${r} ${r - r} A ${r} ${r} 0 1 1 ${r - 0.01} ${r - r} Z M ${r} ${r - ir} A ${ir} ${ir} 0 1 0 ${r - 0.01} ${r - ir} Z` + : `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${ir} ${ir} 0 ${large} 0 ${ix1} ${iy1} Z` + return + }) + return ( + + {slices} + + ) +} + +const SpaceModalForm: React.FC<{ + title?: string + readonly?: boolean + initialValues?: Space + projectCategory?: string + submitText?: string + resetText?: string + open?: boolean + trigger?: JSX.Element + onCancel?: () => void + onFinish?: (data: Space) => Promise +}> = ({ + title, + readonly, + initialValues, + projectCategory, + submitText, + resetText, + open, + trigger, + onCancel, + onFinish +}) => { + const { token } = theme.useToken() + const tableRef = useRef() + const formRef = useRef() + const tableFormRef = useRef() + const [layout, setLayout] = useState() + const [selectedApcLmtStd, setSelectedApcLmtStd] = useState() + const [spacePredictedResult, setSpacePredictedResult] = useState() + const [openSelectMaterialModal, setOpenSelectMaterialModal] = useState(false) + const [materials, setMaterials] = useState() + const [materialDetails, setMaterialDetails] = useState() + const [materialCategories, setMaterialCategories] = useState() + const [predicting, setPredicting] = useState(false) + const predict = async function (data: AnyObj) { + const result = await api.predict.space(data) + data.methanal_c = result.methanal_c + data.methanal_c_exceed = result.methanal_c_exceed + data.tvoc_c = result.tvoc_c + data.tvoc_c_exceed = result.tvoc_c_exceed + data.benzene_c = result.benzene_c + data.benzene_c_exceed = result.benzene_c_exceed + data.toluene_c = result.toluene_c + data.toluene_c_exceed = result.toluene_c_exceed + data.p_xylene_c = result.p_xylene_c + data.p_xylene_c_exceed = result.p_xylene_c_exceed + setSpacePredictedResult({ + methanal_c: result.methanal_c, + methanal_c_exceed: result.methanal_c_exceed, + tvoc_c: result.tvoc_c, + tvoc_c_exceed: result.tvoc_c_exceed, + benzene_c: result.benzene_c, + benzene_c_exceed: result.benzene_c_exceed, + toluene_c: result.toluene_c, + toluene_c_exceed: result.toluene_c_exceed, + p_xylene_c: result.p_xylene_c, + p_xylene_exceed: result.p_xylene_c_exceed + }) + setMaterials( + (pre) => + pre?.map((m1) => { + const mtrl = result.materials?.find((m2) => m1.material_id === m2.material_id) + m1.methanal_cr = mtrl?.methanal_cr + m1.methanal_cr_exceed = mtrl?.methanal_cr_exceed + m1.tvoc_cr = mtrl?.tvoc_cr + m1.tvoc_cr_exceed = mtrl?.tvoc_cr_exceed + m1.benzene_cr = mtrl?.benzene_cr + m1.benzene_cr_exceed = mtrl?.benzene_cr_exceed + m1.toluene_cr = mtrl?.toluene_cr + m1.toluene_cr_exceed = mtrl?.toluene_cr_exceed + m1.p_xylene_cr = mtrl?.p_xylene_cr + m1.p_xylene_cr_exceed = mtrl?.p_xylene_cr_exceed + return m1 + }) + ) + } + useEffect(() => { + api.config.getMtrlCatList().then((res) => setMaterialCategories(res)) + }, []) + return ( + + title={title} + open={open} + width="calc(100% - 100px)" + layout="horizontal" + modalProps={{ + destroyOnClose: true, + maskClosable: false, + style: { + top: 65 + }, + styles: { + content: { + paddingBottom: 12 + } + }, + onCancel: onCancel + }} + formRef={formRef} + trigger={trigger} + initialValues={initialValues} + onOpenChange={(open) => { + if (open) { + setLayout(initialValues?.layout) + setSelectedApcLmtStd(initialValues?.apc_lmt_std) + setSpacePredictedResult({ + methanal_c: initialValues?.methanal_c, + methanal_c_exceed: initialValues?.methanal_c_exceed, + tvoc_c: initialValues?.tvoc_c, + tvoc_c_exceed: initialValues?.tvoc_c_exceed, + benzene_c: initialValues?.benzene_c, + benzene_c_exceed: initialValues?.benzene_c_exceed, + toluene_c: initialValues?.toluene_c, + toluene_c_exceed: initialValues?.toluene_c_exceed, + p_xylene_c: initialValues?.p_xylene_c, + p_xylene_c_exceed: initialValues?.p_xylene_c_exceed + }) + setMaterials(initialValues?.materials) + } + }} + submitter={ + !readonly + ? { + searchConfig: { submitText, resetText }, + render: (_, dom) => { + const cancelBtn = React.cloneElement(dom[0], { key: 'cancel-btn', style: { width: 120 } }) + const predictBtn = ( + + ) + const saveBtn = React.cloneElement(dom[1], { key: 'save-btn', style: { width: 120 } }) + return [cancelBtn, predictBtn, saveBtn] + } + } + : false + } + onFinish={ + onFinish + ? async (data) => { + let tableValues = tableFormRef.current?.getFieldsValue() + tableValues = materials?.map((mtrl) => { + const item = tableValues[mtrl.material_id] + return { + ...mtrl, + unit: item.unit, + qty: item.qty + } + }) + if (tableValues?.find((mtrl: Material) => !mtrl.unit || isNone(mtrl.qty))) { + message.error('请选择材料用量单位并填写用量') + } + if (await tableFormRef.current?.validateFields()) { + data.materials = tableValues?.map( + (m: Material) => + ({ id: m.id, material_id: m.material_id, unit: m.unit, qty: m.qty }) as Material + ) + return await onFinish(data) + } + return false + } + : undefined + } + > + +
+
+
+ + 基本信息 +
+
+
+
+
+ + { + const cats = await api.config.getProjSpaceCatList({ + project_category: projectCategory + }) + const data: AnyObj[] = [] + cats.forEach((cat) => + cat.space_categories.forEach((c) => + data.push({ + value: c.space_category, + label: c.space_category + }) + ) + ) + return data + }} + required={false} + rules={[ + { + required: true, + message: '不能为空' + } + ]} + /> +
+
+ { + setLayout(e.target.value) + if (e.target.value) { + formRef.current?.setFieldValue('volume', undefined) + } else { + formRef.current?.setFieldValue('height', undefined) + } + } + }} + required={false} + rules={[ + { + required: true, + message: '不能为空' + } + ]} + /> +
+
+ + formRef.current?.setFieldValue( + 'volume', + (v ?? 0) * (formRef.current.getFieldValue('area') ?? 0) || undefined + ) + }} + min={0.01} + max={100} + disabled={layout === 0 || readonly} + required={false} + rules={[ + () => ({ + validator(_, value) { + if (layout === 0 || value) { + return Promise.resolve() + } + return Promise.reject(new Error('不能为空')) + } + }) + ]} + /> + { + if (layout) { + formRef.current?.setFieldValue( + 'volume', + (v ?? 0) * (formRef.current.getFieldValue('height') ?? 0) || undefined + ) + } + } + }} + min={0.01} + max={1000} + required={false} + rules={[ + () => ({ + validator(_, value) { + if (layout === 0 || value) { + return Promise.resolve() + } + return Promise.reject(new Error('不能为空')) + } + }) + ]} + /> + ({ + validator(_, value) { + if (layout === 1 || value) { + return Promise.resolve() + } + return Promise.reject(new Error('不能为空')) + } + }) + ]} + /> +
+
+ + + +
+
+
+
+
+ + 空间污染预计算 +
+
+ { + const stdLst = await api.config.getAPCLmtStdList() + stdLst.sort((a, b) => Number(a.id.slice(-4)) - Number(b.id.slice(-4))) + return stdLst.map((std) => ({ + value: std.id, + label: std.id, + details: std + })) + }} + required={false} + rules={[ + { + required: true, + message: '不能为空' + } + ]} + onChange={(_, opt) => setSelectedApcLmtStd(opt.details)} + /> +
+
+ ( + + {!isNone(methanal_c) ? `${round(methanal_c!, 3)}mg/m³` : '-'} + + ) + }, + { + title: 'TVOC', + dataIndex: 'tvoc_c', + align: 'center', + render: (_, { tvoc_c, tvoc_c_exceed }) => ( + + {!isNone(tvoc_c) ? `${round(tvoc_c!, 3)}mg/m³` : '-'} + + ) + }, + { + title: '苯', + dataIndex: 'benzene_c', + align: 'center', + render: (_, { benzene_c, benzene_c_exceed }) => ( + + {!isNone(benzene_c) ? `${round(benzene_c!, 3)}mg/m³` : '-'} + + ) + }, + { + title: '甲苯', + dataIndex: 'toluene_c', + align: 'center', + render: (_, { toluene_c, toluene_c_exceed }) => ( + + {!isNone(toluene_c) ? `${round(toluene_c!, 3)}mg/m³` : '-'} + + ) + }, + { + title: '二甲苯', + dataIndex: 'p_xylene_c', + align: 'center', + render: (_, { p_xylene_c, p_xylene_c_exceed }) => ( + + {!isNone(p_xylene_c) ? `${round(p_xylene_c!, 3)}mg/m³` : '-'} + + ) + } + ]} + dataSource={([] as AnyObj[]) + .concat([ + { + item_name: '标准限值', + methanal_c: selectedApcLmtStd?.methanal_c_lmt, + tvoc_c: selectedApcLmtStd?.tvoc_c_lmt, + benzene_c: selectedApcLmtStd?.benzene_c_lmt, + toluene_c: selectedApcLmtStd?.toluene_c_lmt, + p_xylene_c: selectedApcLmtStd?.p_xylene_c_lmt + } + ]) + .concat([ + { + item_name: '预测浓度', + ...spacePredictedResult + } + ])} + pagination={false} + /> +
+
+
+ {/* Visualization section: left = concentration bars, right = contribution charts */} + {POLLS.some(p => (spacePredictedResult as any)?.[p.key + '_c'] != null) && ( + <> + +
+ {/* Left: vertical bar chart with normalized threshold line */} +
+
+ + 污染物浓度 +
+ {(() => { + const bars = POLLS.map(({ key, label, lmtKey }) => { + const c = (spacePredictedResult as any)?.[key + '_c'] as number | undefined + const lmt = (selectedApcLmtStd as any)?.[lmtKey] as number | undefined + const ratio = (c != null && lmt && lmt > 0) ? c / lmt : 0 + const exceed = ratio > 1 + return { label, c: c ?? 0, lmt, exceed, ratio } + }) + const svgW = 420, svgH = 260, padTop = 24, padBot = 4 + const chartBot = svgH - padBot + const chartH = chartBot - padTop + const maxRatio = Math.max(...bars.map(b => b.ratio), 1.3) + const lineH = (1 / maxRatio) * chartH + const lineY = chartBot - lineH + const barW = 38 + const gap = (svgW - barW * bars.length) / (bars.length + 1) + const GREEN = '#389e0d' + const RED = '#cf1322' + return ( +
+ + {bars.map((b, i) => { + const cx = gap + i * (barW + gap) + barW / 2 + const bx = cx - barW / 2 + const totalH = Math.max((b.ratio / maxRatio) * chartH, 2) + const barTop = chartBot - totalH + if (b.exceed) { + const greenH = lineH + const redH = totalH - greenH + return ( + + + + + {(b.ratio * 100).toFixed(0)}% + + + ) + } + return ( + + + + {(b.ratio * 100).toFixed(0)}% + + + ) + })} + + 限值 100% + +
+ {bars.map((b, i) => ( +
+
{b.label}
+
{round(b.c, 3)} / {b.lmt ?? '—'} mg/m³
+ + {b.exceed ? '超标' : '达标'} + +
+ ))} +
+
+ ) + })()} +
+ {/* Right: 5 pie charts — each pollutant's material contribution */} +
+
+ + 各污染物材料贡献占比 +
+ {(() => { + const mats = materials + if (!mats?.length) return
暂无预测数据
+ return ( +
+ {POLLS.map(({ key, label }) => { + const slices = mats + .map(m => ({ name: m.name || m.material_id, cr: (m as any)[`${key}_cr`] as number ?? 0 })) + .filter(s => s.cr > 0) + .sort((a, b) => b.cr - a.cr) + if (!slices.length) return ( +
+
{label}
+
无贡献数据
+
+ ) + const total = slices.reduce((s, d) => s + d.cr, 0) + const normalized = slices.map(d => ({ ...d, value: total > 0 ? d.cr / total : 0 })) + return ( +
+
{label}
+
+ ({ name: d.name, value: d.value }))} size={120} /> +
+
+ {normalized.map((d, j) => ( +
+ + {d.name} + {(d.value * 100).toFixed(1)}% +
+ ))} +
+
+ ) + })} +
+ ) + })()} +
+
+ + )} + +
+
+ + 使用材料及空气污染物预计算 ({materials?.length ?? 0}) +
+ {!readonly ? ( +
+ } style={{ paddingInline: 0 }}> + 新建材料 + + } + onFinish={async (data) => { + try { + const mtrl = await api.material.createSelf(data) + message.success('新建成功') + materialCategories?.find((fc) => + fc.second_categories.find((sc) => { + if (`${fc.first_category}/${sc.second_category}` === mtrl.category) { + mtrl.unit = sc.unit + return true + } + return false + }) + ) + if (!mtrl.unit) { + mtrl.unit = MTRL_UNIT.AREA + } + setMaterials((pre) => [mtrl, ...(pre ?? [])]) + return true + } catch (err) { + return false + } + }} + /> + + setOpenSelectMaterialModal(false) + }} + defaultSelected={materials} + onAdd={async (mtrls) => { + try { + if (mtrls) { + mtrls.forEach((m) => { + materialCategories?.find((fc) => + fc.second_categories.find((sc) => { + if (`${fc.first_category}/${sc.second_category}` === m.category) { + m.unit = sc.unit + return true + } + return false + }) + ) + if (!m.unit) { + m.unit = MTRL_UNIT.AREA + } + }) + setMaterials((pre) => mtrls.concat(pre ?? [])) + } + setOpenSelectMaterialModal(false) + } catch (err) {} + }} + /> +
+ ) : ( + '' + )} +
+ + cardProps={{ + bodyStyle: { + paddingInline: 0, + paddingTop: 0, + paddingBottom: 2.5 + } + }} + actionRef={tableRef} + editableFormRef={tableFormRef} + scroll={{ y: 'calc(100vh - 600px)' }} + size="small" + search={false} + options={false} + recordCreatorProps={false} + editable={ + !readonly + ? { + type: 'multiple', + editableKeys: materials?.map((m) => m.material_id) + } + : undefined + } + columns={[ + { + title: '材料ID', + dataIndex: 'material_id', + align: 'center', + width: 120, + editable: false + }, + { + title: '类型', + dataIndex: 'type', + align: 'center', + width: 80, + render: (_, { material_id }) => {material_id.startsWith('P') ? '公共' : '自建'}, + editable: false + }, + { + title: '材料名称', + dataIndex: 'name', + align: 'center', + editable: false + }, + { + title: '材料类别', + dataIndex: 'category', + align: 'center', + editable: false + }, + { + title: '材料品牌', + dataIndex: 'brand', + align: 'center', + editable: false + }, + { + title: '材料厂家', + dataIndex: 'factory', + align: 'center', + editable: false + }, + { + title: '材料规格', + dataIndex: 'spec', + align: 'center', + editable: false + }, + { + title: '用量单位', + dataIndex: 'unit', + width: 130, + align: 'center', + fieldProps: { + style: { width: 100 }, + allowClear: false + }, + valueType: 'select', + formItemProps: { + required: false, + rules: [ + { + required: true, + message: '不能为空' + } + ] + }, + valueEnum: ({ category }) => { + const AREA_TEXT = '面积(m²)' + const LEN_TEXT = '长度(m)' + let disableArea = false + let disableLen = false + for (const fc of materialCategories ?? []) { + const sc = fc.second_categories.find( + (sc) => `${fc.first_category}/${sc.second_category}` === category + ) + if (sc) { + if (sc.unit === MTRL_UNIT.AREA) { + disableLen = true + } else if (sc.unit === MTRL_UNIT.LEN) { + disableArea = true + } + break + } + } + return { + AREA: { + text: disableArea ? ( + {AREA_TEXT} + ) : ( + AREA_TEXT + ), + disabled: disableArea + }, + LEN: { + text: disableLen ? ( + {LEN_TEXT} + ) : ( + LEN_TEXT + ), + disabled: disableLen + } + } + } + }, + { + title: '使用量', + dataIndex: 'qty', + valueType: 'digit', + align: 'center', + width: 130, + fieldProps: { + min: 0, + max: 1000, + maxLength: 10 + }, + formItemProps: { + required: false, + rules: [ + { + required: true, + message: '不能为空' + } + ] + } + }, + { + title: '甲醛贡献率', + dataIndex: 'methanal_cr', + align: 'center', + render: (_, { methanal_cr, methanal_cr_exceed }) => ( + + {!isNone(methanal_cr) ? round(methanal_cr! * 100, 1) + '%' : '-'} + + ), + editable: false + }, + { + title: 'TVOC贡献率', + dataIndex: 'tvoc_cr', + align: 'center', + render: (_, { tvoc_cr, tvoc_cr_exceed }) => ( + + {!isNone(tvoc_cr) ? round(tvoc_cr! * 100, 1) + '%' : '-'} + + ), + editable: false + }, + { + title: '苯贡献率', + dataIndex: 'benzene_cr', + align: 'center', + render: (_, { benzene_cr, benzene_cr_exceed }) => ( + + {!isNone(benzene_cr) ? round(benzene_cr! * 100, 1) + '%' : '-'} + + ), + editable: false + }, + { + title: '甲苯贡献率', + dataIndex: 'toluene_cr', + align: 'center', + render: (_, { toluene_cr, toluene_cr_exceed }) => ( + + {!isNone(toluene_cr) ? round(toluene_cr! * 100, 1) + '%' : '-'} + + ), + editable: false + }, + { + title: '二甲苯贡献率', + dataIndex: 'p_xylene_cr', + align: 'center', + render: (_, { p_xylene_cr, p_xylene_cr_exceed }) => ( + + {!isNone(p_xylene_cr) ? round(p_xylene_cr! * 100, 1) + '%' : '-'} + + ), + editable: false + }, + { + title: '操作', + valueType: 'option', + align: 'center', + editable: false, + width: 100, + render: (_, item) => ( +
+ + {!readonly ? ( + + ) : ( + '' + )} +
+ ) + } + ]} + params={{ materials }} + request={async ({ materials }) => ({ + success: true, + data: materials + })} + rowKey={(data) => data.material_id} + pagination={false} + /> + setMaterialDetails(undefined)} + /> + + ) +} + +export default SpaceModalForm diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/article/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/pages/article/index.tsx index d0cb8ce..a899684 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/pages/article/index.tsx +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/article/index.tsx @@ -1,11 +1,47 @@ -import { history, useParams } from '@umijs/max' +import { useEffect, useState } from 'react' +import { history, useModel, useParams } from '@umijs/max' +import { Upload, message } from 'antd' +import { UploadOutlined } from '@ant-design/icons' import { getArticle } from '@/common/articles' +import api from '@/services/api' +import './article.css' export default function ArticleDetail() { const { id } = useParams<{ id: string }>() + const { initialState } = useModel('@@initialState') + const isAdmin = !!initialState?.cert?.is_admin const article = id ? getArticle(id) : undefined - if (!article) { + const [customHtml, setCustomHtml] = useState(null) + const [customTitle, setCustomTitle] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!id) { setLoading(false); return } + api.config.getArticleContent(id, { skipErrorHandler: true }) + .then(data => { if (data?.html) setCustomHtml(data.html) }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [id]) + + const handleUpload = async (file: File) => { + if (!id) return false + try { + const res = await api.config.uploadArticleDoc(id, file) + setCustomHtml(res.html) + if (res.title) setCustomTitle(res.title) + message.success('文章内容已更新,页面已全量替换') + } catch { + message.error('上传失败,请确认是 .docx / .doc / .pdf 格式') + } + return false + } + + if (loading) { + return
加载中...
+ } + + if (!article && !customHtml) { return (

文章不存在或已下线。

@@ -16,29 +52,58 @@ export default function ArticleDetail() { return (
- history.push('/landing')} style={{ color: '#888' }}>← 返回首页 -
-
{article.tag} · {article.date}
-

{article.title}

-

{article.summary}

- {article.title} +
+ history.push('/landing')} style={{ color: '#888' }}>← 返回首页 + {isAdmin && id && ( + + + + )}
- {article.body.map((sec, i) => ( -
- {sec.heading &&

{sec.heading}

} - {sec.paragraphs.map((p, j) => ( -

{p}

+ + {customHtml ? ( +
+ ) : article ? ( + <> +
+
{article.tag} · {article.date}
+

{article.title}

+

{article.summary}

+ {article.title} +
+ {article.body.map((sec, i) => ( +
+ {sec.heading &&

{sec.heading}

} + {sec.paragraphs.map((p, j) => ( +

{p}

+ ))} +
))} -
- ))} - {article.sources && article.sources.length > 0 && ( -
-
参考来源
-
    - {article.sources.map((s, i) =>
  • {s}
  • )} -
-
- )} + {article.sources && article.sources.length > 0 && ( +
+
参考来源
+
    + {article.sources.map((s, i) =>
  • {s}
  • )} +
+
+ )} + + ) : null}
) } diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/articles/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/pages/articles/index.tsx index 472ae56..3e48f77 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/pages/articles/index.tsx +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/articles/index.tsx @@ -1,19 +1,32 @@ +import { useEffect, useState } from 'react' import { history } from '@umijs/max' import { articles } from '@/common/articles' +import api from '@/services/api' +import type { LandingNewsItem } from '@/services/api/config' export default function ArticleList() { + const [items, setItems] = useState(articles.map(a => ({ + id: a.id, tag: a.tag, date: a.date, title: a.title, summary: a.summary, image: a.image + }))) + + useEffect(() => { + api.config.getLandingNews({ skipErrorHandler: true }) + .then(data => { if (data?.items?.length) setItems(data.items) }) + .catch(() => {}) + }, []) + return (
history.push('/landing')} style={{ color: '#888' }}>← 返回首页

健康装修科普

- {articles.map(a => ( + {items.map(a => (
history.push('/article/' + a.id)} style={{ cursor: 'pointer', display: 'flex', gap: 16, padding: 16, border: '1px solid #eee', borderRadius: 8, transition: 'box-shadow .2s' }} > - {a.title} + {a.image && {a.title}}
{a.tag} · {a.date}

{a.title}

diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/landing/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/pages/landing/index.tsx index b8a0223..f5af085 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/pages/landing/index.tsx +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/landing/index.tsx @@ -1,8 +1,11 @@ import { useEffect, useRef, useState } from 'react' -import { history } from '@umijs/max' +import { history, useModel } from '@umijs/max' +import { Modal, Input, Upload, message, Checkbox } from 'antd' +import { EditOutlined, UploadOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons' import { LOC_STOR_KEY } from '@/common/constants' import PhoneAuthModal from '@/components/phone-auth-modal' import api from '@/services/api' +import type { LandingHero, LandingNewsItem } from '@/services/api/config' import type { EcoMaterial } from '@/services/api/material' import { articles } from '@/common/articles' import './landing.css' @@ -20,17 +23,27 @@ const ChevronIcon = ({ dir }: { dir: 'left' | 'right' }) => ( ) +const DEFAULT_HERO: LandingHero = { + title: '装修住得安心,\n先把污染源头挡在门外', + description: '输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。', + image: '/iapip-web/images/hero-interior.jpg' +} + +const DEFAULT_NEWS_TITLE = '读懂装修污染,从知道哪些是健康材料开始' +const defaultNewsItems: LandingNewsItem[] = articles.map(a => ({ + id: a.id, tag: a.tag, date: a.date, title: a.title, summary: a.summary, image: a.image +})) + const stats = [ { n: '28+', t: '在管装修项目' }, { n: '2,400+', t: '累计预测次数' }, { n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' }, { n: '2 部', t: '国标依据 · 18883 / 50325' } ] -const news = articles const cases = [ - { type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' }, - { type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07', chip: '甲醛达标' }, - { type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46', chip: 'TVOC达标' } + { type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08' }, + { type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07' }, + { type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46' } ] const steps = [ { h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' }, @@ -39,32 +52,200 @@ const steps = [ ] const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN) -const goPro = () => history.push(hasToken() ? '/home' : '/login') const scrollTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }) const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }) export default function Landing() { + const { initialState } = useModel('@@initialState') + const isAdmin = !!initialState?.cert?.is_admin + + // --- 科普轮播 --- + const [newsTitle, setNewsTitle] = useState(DEFAULT_NEWS_TITLE) + const [newsItems, setNewsItems] = useState(defaultNewsItems) const [cur, setCur] = useState(0) const timer = useRef | null>(null) - const go = (n: number) => setCur((((n % news.length) + news.length) % news.length)) + const len = newsItems.length || 1 + const go = (n: number) => setCur((((n % len) + len) % len)) const stop = () => { if (timer.current) { clearInterval(timer.current); timer.current = null } } - const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % news.length), 5500) } - const next = () => { setCur(c => (c + 1) % news.length); start() } - const prev = () => { setCur(c => (c - 1 + news.length) % news.length); start() } - useEffect(() => { start(); return stop }, []) + const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % len), 5500) } + const next = () => { setCur(c => (c + 1) % len); start() } + const prev = () => { setCur(c => (c - 1 + len) % len); start() } + useEffect(() => { start(); return stop }, [len]) const [authOpen, setAuthOpen] = useState(false) const [ecoList, setEcoList] = useState([]) + const [caseImages, setCaseImages] = useState>({}) useEffect(() => { api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([])) + api.config.getCaseImages({ skipErrorHandler: true }).then(setCaseImages).catch(() => {}) }, []) + const uploadCaseImage = async (index: number, file: File) => { + try { + const { url } = await api.config.uploadLandingImage(file) + const updated = await api.config.setCaseImage(index, url) + setCaseImages(updated) + message.success('案例图片已更新') + } catch { message.error('上传失败') } + return false + } + // --- 环保建材编辑 --- + const [ecoEditOpen, setEcoEditOpen] = useState(false) + const [ecoCandidates, setEcoCandidates] = useState([]) + const [ecoSelected, setEcoSelected] = useState([]) + const [ecoKeyword, setEcoKeyword] = useState('') + const openEcoEdit = async () => { + setEcoEditOpen(true) + setEcoSelected(ecoList.map(m => m.material_id)) + try { setEcoCandidates(await api.material.getEcoCandidates('')) } catch {} + } + const searchCandidates = async (kw: string) => { + setEcoKeyword(kw) + try { setEcoCandidates(await api.material.getEcoCandidates(kw)) } catch {} + } + const toggleEco = (id: string) => { + setEcoSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) + } + const saveEcoPicks = async () => { + try { + await api.material.setEcoPicks(ecoSelected) + const updated = await api.material.getEcoMaterials() + setEcoList(updated) + setEcoEditOpen(false) + message.success('环保建材已更新') + } catch { message.error('保存失败') } + } + + const uploadEcoImage = async (materialId: string, file: File) => { + try { + const { url } = await api.config.uploadLandingImage(file) + await api.material.setEcoImage(materialId, url) + setEcoList(prev => prev.map(m => m.material_id === materialId ? { ...m, image: url } : m)) + message.success('材料图片已更新') + } catch { message.error('上传失败') } + } + const openPredict = () => { - if (hasToken()) history.push('/source') + if (hasToken()) history.push('/home') else setAuthOpen(true) } const onAuthed = () => { setAuthOpen(false) - history.push('/source') + history.push('/home') + } + + // --- Hero 编辑 --- + const [hero, setHero] = useState(DEFAULT_HERO) + const [editOpen, setEditOpen] = useState(false) + const [editForm, setEditForm] = useState({}) + const [saving, setSaving] = useState(false) + + useEffect(() => { + api.config.getLandingConfig({ skipErrorHandler: true }) + .then(data => { if (data) setHero({ ...DEFAULT_HERO, ...data }) }) + .catch(() => {}) + api.config.getLandingNews({ skipErrorHandler: true }) + .then(data => { + if (data) { + if (data.sectionTitle) setNewsTitle(data.sectionTitle) + if (data.items?.length) setNewsItems(data.items) + } + }) + .catch(() => {}) + }, []) + + const openEdit = () => { + setEditForm({ title: hero.title, description: hero.description }) + setEditOpen(true) + } + const saveEdit = async () => { + setSaving(true) + try { + await api.config.updateLandingConfig(editForm) + setHero(prev => ({ ...prev, ...editForm })) + setEditOpen(false) + message.success('已保存') + } catch { + message.error('保存失败') + } finally { + setSaving(false) + } + } + const handleImageUpload = async (file: File) => { + try { + const res = await api.config.uploadLandingImage(file) + await api.config.updateLandingConfig({ image: res.url }) + setHero(prev => ({ ...prev, image: res.url })) + message.success('图片已更新') + } catch { + message.error('上传失败') + } + return false + } + + const renderTitle = () => { + const parts = (hero.title || '').split('\n') + return parts.map((line, i) => { + const segs = line.split(/(?=污染源头)|(?<=污染源头)/) + return ( + + {i > 0 &&
} + {segs.map((s, j) => s === '污染源头' ? {s} : {s})} +
+ ) + }) + } + + // --- 科普轮播编辑 --- + const [newsEditOpen, setNewsEditOpen] = useState(false) + const [newsEditTitle, setNewsEditTitle] = useState('') + const [newsEditItems, setNewsEditItems] = useState([]) + const [newsSaving, setNewsSaving] = useState(false) + + const openNewsEdit = () => { + setNewsEditTitle(newsTitle) + setNewsEditItems(newsItems.map(it => ({ ...it }))) + setNewsEditOpen(true) + } + + const updateNewsItem = (idx: number, field: keyof LandingNewsItem, value: string) => { + setNewsEditItems(items => items.map((it, i) => i === idx ? { ...it, [field]: value } : it)) + } + + const addNewsItem = () => { + setNewsEditItems(items => [...items, { id: `article-${Date.now()}`, title: '', tag: '', date: '', summary: '', image: '' }]) + } + + const removeNewsItem = (idx: number) => { + setNewsEditItems(items => items.filter((_, i) => i !== idx)) + } + + const handleNewsImageUpload = async (idx: number, file: File) => { + try { + const res = await api.config.uploadLandingImage(file) + updateNewsItem(idx, 'image', res.url) + message.success('图片已上传') + } catch { + message.error('上传失败') + } + return false + } + + const saveNews = async () => { + const valid = newsEditItems.every(it => it.title.trim()) + if (!valid) { message.warning('每篇文章都需要填写标题'); return } + setNewsSaving(true) + try { + await api.config.updateLandingNews({ sectionTitle: newsEditTitle, items: newsEditItems }) + setNewsTitle(newsEditTitle) + setNewsItems(newsEditItems) + setCur(0) + setNewsEditOpen(false) + message.success('已保存') + } catch { + message.error('保存失败') + } finally { + setNewsSaving(false) + } } return ( @@ -80,9 +261,11 @@ export default function Landing() { scrollToId('cases')}>预测案例 scrollToId('eco')}>环保建材 history.push('/forum')}>健康装修大家谈 + Modal.info({ title: '联系我们', content: '邮箱:zty18395607196@gmail.com', okText: '知道了' })}>联系我们 - 免费试算 + {!hasToken() && history.push('/login')}>登录} + 现在就预测
@@ -90,10 +273,12 @@ export default function Landing() {
对照 GB/T 18883-2022 与 GB 50325-2020 双国标 -

装修住得安心,
先把污染源头挡在门外

-

输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。

+

+ {renderTitle()} + {isAdmin && } +

+

{hero.description}

@@ -103,7 +288,12 @@ export default function Landing() {
- 室内装修实景 + 室内装修实景 + {isAdmin && ( + + + + )}
6 项污染物预测
@@ -118,15 +308,24 @@ export default function Landing() {
-
健康装修科普

读懂装修污染,从知道哪些是健康材料开始

+
+
健康装修科普
+

+ {newsTitle} + {isAdmin && } +

+
history.push('/articles')}>全部文章
- {news.map((n, i) => ( -
history.push('/article/' + n.id)}> -
{n.tag}{n.title}
+ {newsItems.map((n, i) => ( +
history.push('/article/' + n.id)}> +
+ {n.tag} + {n.image && {n.title}} +
{n.date}

{n.title}

@@ -141,7 +340,7 @@ export default function Landing() {
- {news.map((_, i) =>
@@ -154,7 +353,15 @@ export default function Landing() {
{cases.map((c, i) => (
-
案例实景图
+ {caseImages[i] ? ( +
+ {isAdmin && uploadCaseImage(i, f)}>} +
+ ) : ( +
+ {isAdmin ? uploadCaseImage(i, f)}> : '案例实景图'} +
+ )}
{c.type}

{c.name}

@@ -163,7 +370,6 @@ export default function Landing() {
优化前
{c.v1}
优化后
{c.v2}
-
{c.chip}查看溯源
))} @@ -171,14 +377,13 @@ export default function Landing() {
- {ecoList.length > 0 && ( + {(ecoList.length > 0 || isAdmin) && (
环保建材
-

优选低释放材料,从源头减少污染

-

以下为材料库中环保等级 E0/E1 的低释放建材,选材阶段即可降低甲醛与 TVOC 风险。

+

优选低释放材料,从源头减少污染{isAdmin && }

@@ -188,10 +393,26 @@ export default function Landing() {
{m.category} · {m.brand}

{m.name || m.brand || '环保建材'}

{m.factory || '—'}
-
- 环保 {m.eco_level} - {typeof m.methanal === 'number' && 甲醛 {m.methanal}} -
+
+
+ {m.image ? ( +
+ {m.name} + {isAdmin && ( + { uploadEcoImage(m.material_id, f); return false }}> + + + )} +
+ ) : ( + isAdmin ? ( + { uploadEcoImage(m.material_id, f); return false }}> +
上传图片
+
+ ) : ( +
暂无图片
+ ) + )}
))} @@ -200,19 +421,6 @@ export default function Landing() { )} -
-
-
如何使用

三步,得到一份可溯源的预测报告

-
- {steps.map((s, i) => ( -
-
{i + 1}

{s.h}

{s.p}

- {i < steps.length - 1 &&
} -
- ))} -
-
-
@@ -222,8 +430,7 @@ export default function Landing() {

无需上门,输入材料即可估算。先估后测,把检测的钱花在刀刃上。

@@ -241,6 +448,150 @@ export default function Landing() { setAuthOpen(false)} /> + + {/* Hero 编辑弹窗 */} + setEditOpen(false)} + confirmLoading={saving} + okText="保存" + cancelText="取消" + > +
+
+
标题
+ setEditForm(f => ({ ...f, title: e.target.value }))} + placeholder="支持换行" + /> +
+
+
描述
+ setEditForm(f => ({ ...f, description: e.target.value }))} + /> +
+
+
+ + {/* 科普栏目编辑弹窗 */} + setNewsEditOpen(false)} + confirmLoading={newsSaving} + okText="保存" + cancelText="取消" + width={720} + styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }} + > +
+
+
栏目标题
+ setNewsEditTitle(e.target.value)} /> +
+ + {newsEditItems.map((item, idx) => ( +
+
+ 文章 {idx + 1} + {newsEditItems.length > 1 && ( + + )} +
+
+
+
标题 *
+ updateNewsItem(idx, 'title', e.target.value)} /> +
+
+
+
+
标签
+ updateNewsItem(idx, 'tag', e.target.value)} placeholder="如: 科普、政策解读" /> +
+
+
日期
+ updateNewsItem(idx, 'date', e.target.value)} placeholder="如: 专栏 · 2026.04" /> +
+
+
+
摘要
+ updateNewsItem(idx, 'summary', e.target.value)} /> +
+
+
+
配图
+ {item.image && } + handleNewsImageUpload(idx, file)} + > + + +
+
+
+ ))} + + +
+
+ {/* 环保建材编辑弹窗 */} + setEcoEditOpen(false)} + okText="保存" + cancelText="取消" + width={680} + styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }} + > +
+ 已选 {ecoSelected.length} 项 +
+ } + value={ecoKeyword} + onChange={e => searchCandidates(e.target.value)} + allowClear + style={{ marginBottom: 12 }} + /> +
+ {ecoCandidates.map(m => ( + + ))} + {ecoCandidates.length === 0 &&
无匹配材料
} +
+
) -} \ No newline at end of file +} diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/landing/landing.css b/空气质量预测/源码/用户端/iapip-web/src/pages/landing/landing.css index 8587b83..9bdb095 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/pages/landing/landing.css +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/landing/landing.css @@ -35,6 +35,7 @@ .lp .btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 6px 18px rgba(31,122,90,.22);} .lp .btn-primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);color:#fff;} .lp .btn-lg{padding:14px 26px;font-size:15px;border-radius:13px;} +.lp .hero-title-wrap .edit-icon{vertical-align:middle;margin-left:8px;} /* hero */ .lp .hero{padding:64px 0 52px;} @@ -48,7 +49,6 @@ .lp .hero-tags{display:flex;gap:22px;margin-top:26px;flex-wrap:wrap;} .lp .hero-tag{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--sub);} .lp .hero-tag svg{width:17px;height:17px;color:var(--accent);} -.lp .hero-visual{position:relative;} .lp .hero-img{width:100%;height:56vh;min-height:400px;max-height:640px;border-radius:22px;border:1px solid var(--border);} .lp .hero-badge{position:absolute;right:-14px;top:26px;background:var(--ink);color:#fff;border-radius:13px;padding:11px 15px;box-shadow:0 14px 34px rgba(0,0,0,.22);} .lp .hero-badge b{font-family:var(--serif);font-size:20px;display:block;} @@ -94,7 +94,13 @@ .lp .cases-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;} .lp .case{background:var(--panel);border:1px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:.18s;} .lp .case:hover{transform:translateY(-3px);box-shadow:0 16px 36px rgba(60,45,20,.12);} -.lp .case .img-ph{width:100%;height:172px;border-radius:0;} +.lp .case .img-ph{width:100%;height:172px;border-radius:0;position:relative;} +.lp .img-ph-edit{position:absolute;bottom:8px;right:8px;background:rgba(255,255,255,.85);border:1px solid #d9d9d9;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:12px;color:var(--accent);display:inline-flex;align-items:center;gap:4px;} +.lp .img-ph-edit:hover{background:#fff;border-color:var(--accent);} +.lp .eco-img-wrap{padding:0 19px 16px;} +.lp .eco-img{max-width:100%;max-height:120px;object-fit:contain;border-radius:10px;display:block;} +.lp .eco-img-ph{width:100%;height:120px;border-radius:10px;background:var(--bg2);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--faint);font-size:13px;cursor:pointer;border:1.5px dashed var(--border2);transition:.18s;} +.lp .eco-img-ph:hover{border-color:var(--accent);color:var(--accent);} .lp .case-b{padding:18px 19px 20px;} .lp .case-type{font-size:11.5px;font-weight:700;color:var(--accent);letter-spacing:.5px;} .lp .case h4{font-family:var(--serif);font-size:18px;font-weight:700;margin:7px 0 0;} @@ -134,6 +140,21 @@ .lp .footer-in{max-width:var(--maxw);margin:0 auto;padding:34px 28px;display:flex;align-items:center;justify-content:space-between;gap:20px;color:var(--faint);font-size:13px;} .lp .footer .brand-tt{font-size:15px;} +/* admin edit icons */ +.lp .edit-icon{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;border:1px solid var(--border2);background:var(--panel);color:var(--accent);cursor:pointer;font-size:14px;transition:.15s;box-shadow:0 2px 8px rgba(0,0,0,.08);vertical-align:middle;margin-left:12px;flex-shrink:0;} +.lp .edit-icon:hover{background:var(--accent);color:#fff;border-color:var(--accent);} +.lp .hero-title-wrap{display:block;} +.lp .hero-img-edit{position:absolute;bottom:16px;right:16px;z-index:3;} +.lp .hero-visual{position:relative;} + +/* news edit modal */ +.news-edit-card{border:1px solid #e9e1d2;border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px;background:#fafaf6;} +.news-edit-card-head{display:flex;align-items:center;justify-content:space-between;} +.news-edit-row{display:flex;gap:12px;} +.news-edit-label{font-size:13px;color:#6c6353;margin-bottom:4px;} +.news-add-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px;border:1px dashed #ded3bf;border-radius:10px;background:transparent;color:#1f7a5a;font-size:14px;font-weight:600;cursor:pointer;transition:.15s;} +.news-add-btn:hover{border-color:#1f7a5a;background:rgba(31,122,90,.06);} + @media(max-width:900px){ .lp .hero-grid,.lp .cslide{grid-template-columns:1fr;} .lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;} diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx index 7b4d4e1..822a460 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx @@ -31,10 +31,11 @@ export default function SourceTracing() { const [standards, setStandards] = useState([]) const [standardId, setStandardId] = useState('') const [roomId, setRoomId] = useState('demo') + const [customName, setCustomName] = useState('') const [area, setArea] = useState(19.8) const [height, setHeight] = useState(3) - const [temperature, setTemperature] = useState(22) - const [humidity, setHumidity] = useState(45) + const [temperature, setTemperature] = useState(23) + const [humidity, setHumidity] = useState(50) const [ventilationRate, setVentilationRate] = useState(0.5) const [mats, setMats] = useState([]) const [pol, setPol] = useState('methanal') @@ -104,7 +105,7 @@ export default function SourceTracing() { function buildPayload(ventRate = ventilationRate) { const selected = mats.filter(m => m.on && matMap[m.id]) return { - id: 'src', project_id: 'src', name: roomId, + id: 'src', project_id: 'src', name: roomId === '_custom' ? (customName || '自定义空间') : roomId, height, area, env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate, methanal_c_lmt: standard?.methanal_c_lmt, @@ -214,12 +215,36 @@ export default function SourceTracing() {
选择样板间
-
- {PRESET_ROOMS.map(r => ( -
loadRoom(r.id)}>{r.name}
- ))} +
+
+ {roomId === '_custom' && ( +
+
空间名称
+
+ setCustomName(e.target.value)} + style={{ flex: 1, border: 'none', background: 'transparent', padding: '8px 10px', fontSize: 14, fontWeight: 600, outline: 'none', color: 'var(--ink)' }} /> +
+
+ )}
diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/source/source.css b/空气质量预测/源码/用户端/iapip-web/src/pages/source/source.css index 939d8ad..9ef41d2 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/pages/source/source.css +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/source/source.css @@ -65,17 +65,6 @@ .fld-lab .v { font-weight: 700; color: var(--ink); font-size: 13px; } /* Room selector tabs */ -.rooms { display: flex; flex-wrap: wrap; gap: 6px; } -.room-b { - font-size: 12.5px; font-weight: 600; padding: 7px 14px; - border-radius: 6px; border: 1px solid var(--border2); - background: var(--panel); color: var(--sub); - cursor: pointer; transition: border-color .12s, color .12s, background .12s; - white-space: nowrap; -} -.room-b:hover { border-color: var(--accent); color: var(--accent); } -.room-b.on { background: var(--accent); border-color: var(--accent); color: #fff; } - /* Number input group */ .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .row2 > div { min-width: 0; } diff --git a/空气质量预测/源码/用户端/iapip-web/src/services/api/material.ts b/空气质量预测/源码/用户端/iapip-web/src/services/api/material.ts index edd04ef..686aee0 100644 --- a/空气质量预测/源码/用户端/iapip-web/src/services/api/material.ts +++ b/空气质量预测/源码/用户端/iapip-web/src/services/api/material.ts @@ -291,7 +291,29 @@ export interface EcoMaterial { factory: string eco_level: string methanal: number + image?: string | null } export async function getEcoMaterials(options?: AnyObj) { return request('/api/mtrl/eco', { method: 'GET', ...(options || {}) }) } +export async function getEcoCandidates(keyword?: string) { + return request('/api/mtrl/eco/candidates', { + method: 'GET', + params: { keyword }, + headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() } + }) +} +export async function setEcoPicks(ids: string[]) { + return request('/api/mtrl/eco/picks', { + method: 'PUT', + headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }, + data: { ids } + }) +} +export async function setEcoImage(materialId: string, url: string) { + return request>(`/api/mtrl/eco/image/${materialId}`, { + method: 'PUT', + headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }, + data: { url } + }) +} diff --git a/空气质量预测/源码/管理端/iapip-ms/public/fts/imft.xlsx b/空气质量预测/源码/管理端/iapip-ms/public/fts/imft.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e744a72a2ea6c92c54e240b6b388dc4de2a6ae62 GIT binary patch literal 12640 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 HuHgR*!`@$k literal 0 HcmV?d00001 diff --git a/空气质量预测/源码/管理端/iapip-ms/src/common/excel-validator.ts b/空气质量预测/源码/管理端/iapip-ms/src/common/excel-validator.ts new file mode 100644 index 0000000..7554a01 --- /dev/null +++ b/空气质量预测/源码/管理端/iapip-ms/src/common/excel-validator.ts @@ -0,0 +1,75 @@ +import api from '@/services/api' +import { AnyObj } from './types' +import { isNone } from './utils' + +export class ExcelValidator { + public errorMessage: string | undefined = undefined + + public async importTemplates(templates: AnyObj[]) { + const projSpaceCatList = await api.config.getProjSpaceCatList() + const cityInfoList = await api.config.getCityInfoList() + for (const template of templates) { + const projSpaceCat = projSpaceCatList.find((c) => c.project_category === template.category) + if (!projSpaceCat) { + this.errorMessage = `【C${template.idx}】不存在该选项(请选择下拉列表中的选项,或重新下载文件模板以更新下拉选项)` + return false + } + const provinceInfo = cityInfoList.find((c) => c.province === template.province) + if (!provinceInfo) { + this.errorMessage = `【D${template.idx}】不存在该选项(请选择下拉列表中的选项,或重新下载文件模板以更新下拉选项)` + return false + } + const cityInfo = provinceInfo.cities.find((c) => c.city === template.city) + if (!cityInfo) { + this.errorMessage = `【E${template.idx}】不存在该选项(请选择下拉列表中的选项,或重新下载文件模板以更新下拉选项)` + return false + } + template.city = `${provinceInfo.province}/${cityInfo.city}` + template.climatic_zone = cityInfo.climatic_zone ?? undefined + for (const space of template.spaces) { + if (!projSpaceCat.space_categories.find((c) => c.space_category === space.category)) { + this.errorMessage = `【F${space.idx}】不存在该选项(请选择下拉列表中的选项,或重新下载文件模板以更新下拉选项)` + return false + } + if (!['GB39126-2020', 'GB50325-2020', 'GB/T18883-2022'].includes(space.apc_lmt_std_id)) { + this.errorMessage = `【L${space.idx}】不存在该选项` + return false + } + if (isNone(space.env_temp)) { + space.env_temp = cityInfo.env_temp ?? 23 + } + if (isNone(space.env_hum)) { + space.env_hum = cityInfo.env_hum ?? 50 + } + if (isNone(space.env_vent_rate)) { + space.env_vent_rate = 0.5 + } + delete space.idx + } + delete template.idx + delete template.id + } + return true + } + + public async importMaterials(materials: AnyObj[]) { + const mtrlCatList = await api.config.getMtrlCatList() + for (const mtrl of materials) { + const firstCat = mtrlCatList.find((fc) => fc.first_category === mtrl.first_category) + if (!firstCat) { + this.errorMessage = `【B${mtrl.idx}】不存在该选项(请选择下拉列表中的选项,或重新下载文件模板以更新下拉选项)` + return false + } + const secondCat = firstCat.second_categories.find((sc) => sc.second_category === mtrl.second_category) + if (!secondCat) { + this.errorMessage = `【C${mtrl.idx}】不存在该选项(请选择下拉列表中的选项,或重新下载文件模板以更新下拉选项)` + return false + } + mtrl.category = `${firstCat.first_category}/${secondCat.second_category}` + delete mtrl.second_category + delete mtrl.first_category + delete mtrl.idx + } + return true + } +} diff --git a/空气质量预测/源码/管理端/iapip-ms/src/common/types.ts b/空气质量预测/源码/管理端/iapip-ms/src/common/types.ts index 9bb4d72..c29911f 100644 --- a/空气质量预测/源码/管理端/iapip-ms/src/common/types.ts +++ b/空气质量预测/源码/管理端/iapip-ms/src/common/types.ts @@ -142,6 +142,22 @@ export type Material = { display?: number ypt?: number | null health_level?: 'A' | 'B' | 'C' | null + model_name?: string + sample_image?: string + reference_price?: string + application_case?: string + effect_image?: string + supplier?: string + contact_person?: string + contact_phone?: string + production_date?: string + batch_no?: string + sample_source?: string + client_unit?: string + test_date?: string + test_standard?: string + report_no?: string + test_org?: string collectors?: { user_id: string; project_id: string }[] methanal?: number methanal_min_be?: number diff --git a/空气质量预测/源码/管理端/iapip-ms/src/components/import-file-modal/materials.tsx b/空气质量预测/源码/管理端/iapip-ms/src/components/import-file-modal/materials.tsx new file mode 100644 index 0000000..267188c --- /dev/null +++ b/空气质量预测/源码/管理端/iapip-ms/src/components/import-file-modal/materials.tsx @@ -0,0 +1,316 @@ +import { ExcelValidator } from '@/common/excel-validator' +import { AnyObj, FROM_TYPE } from '@/common/types' +import { downloadXLSX, toNum } from '@/common/utils' +import api from '@/services/api' +import { ModalForm } from '@ant-design/pro-components' +import { Alert, Button, Divider, Tag, Upload, message, theme } from 'antd' +import { RcFile } from 'antd/es/upload' +import * as ExcelJS from 'exceljs' +import React, { useState } from 'react' +import ImportFile from '../apply-request-modal/import-file' + +const ImportMaterialsModal: React.FC<{ + title: string + trigger: JSX.Element +}> = ({ title, trigger }) => { + const { token } = theme.useToken() + const [open, setOpen] = useState(false) + const [downloading, setDownloading] = useState(false) + const [uploading, setUploading] = useState(false) + const [errMsg, setErrMsg] = useState() + const [parsedResult, setParseResult] = useState() + const [uploadFile, setUploadFile] = useState() + const beforeUpload = async (file: RcFile) => { + setUploading(true) + const msgKey = 'UPLOADING_MATERIALS' + try { + message.open({ + key: msgKey, + type: 'loading', + content: '正在解析' + }) + const wb = new ExcelJS.Workbook() + await wb.xlsx.load(await file.arrayBuffer()) + const ws = wb.getWorksheet(1) + if (ws) { + const materials: AnyObj[] = [] + ws.getRows(6, 500)?.forEach((row, idx) => { + const cellStr = (col: number) => row.getCell(col).value?.toString() || undefined + const first_category = cellStr(2) + const second_category = cellStr(3) + const name = cellStr(4) + const brand = cellStr(5) + const health_level = cellStr(6) + const model_name = cellStr(8) + const spec = cellStr(9) + const reference_price = cellStr(10) + const application_case = cellStr(11) + const factory = cellStr(13) + const supplier = cellStr(14) + const contact_person = cellStr(15) + const contact_phone = cellStr(16) + const production_date = cellStr(17) + const batch_no = cellStr(18) + const methanal_min_be = toNum(cellStr(19)) + const methanal_be_area = toNum(cellStr(20)) + const methanal_be_roc = toNum(cellStr(21)) + const tvoc_min_be = toNum(cellStr(22)) + const tvoc_be_area = toNum(cellStr(23)) + const tvoc_be_roc = toNum(cellStr(24)) + const benzene_min_be = toNum(cellStr(25)) + const benzene_be_area = toNum(cellStr(26)) + const benzene_be_roc = toNum(cellStr(27)) + const toluene_min_be = toNum(cellStr(28)) + const toluene_be_area = toNum(cellStr(29)) + const toluene_be_roc = toNum(cellStr(30)) + const p_xylene_min_be = toNum(cellStr(31)) + const p_xylene_be_area = toNum(cellStr(32)) + const p_xylene_be_roc = toNum(cellStr(33)) + const sample_source = cellStr(34) + const client_unit = cellStr(35) + const test_date = cellStr(36) + const test_standard = cellStr(37) + const report_no = cellStr(38) + const test_org = cellStr(39) + if ( + name && + first_category && + second_category && + brand && + methanal_min_be != null && + methanal_be_area != null && + methanal_be_roc != null && + tvoc_min_be != null && + tvoc_be_area != null && + tvoc_be_roc != null && + benzene_min_be != null && + benzene_be_area != null && + benzene_be_roc != null && + toluene_min_be != null && + toluene_be_area != null && + toluene_be_roc != null && + p_xylene_min_be != null && + p_xylene_be_area != null && + p_xylene_be_roc != null && + sample_source + ) { + materials.push({ + idx: 6 + idx, + name, + first_category, + second_category, + brand, + factory, + spec, + health_level, + model_name, + reference_price, + application_case, + supplier, + contact_person, + contact_phone, + production_date, + batch_no, + sample_source, + client_unit, + test_date, + test_standard, + report_no, + test_org, + display: 1, + from: FROM_TYPE.IMPORT, + methanal_min_be, + methanal_be_area, + methanal_be_roc, + tvoc_min_be, + tvoc_be_area, + tvoc_be_roc, + benzene_min_be, + benzene_be_area, + benzene_be_roc, + toluene_min_be, + toluene_be_area, + toluene_be_roc, + p_xylene_min_be, + p_xylene_be_area, + p_xylene_be_roc + }) + } + }) + const excelValidator = new ExcelValidator() + if (await excelValidator.importMaterials(materials)) { + message.open({ + key: msgKey, + type: 'success', + content: '上传成功', + duration: 2 + }) + setErrMsg(undefined) + setParseResult(materials) + setUploadFile(file) + setUploading(false) + return file + } + setErrMsg(excelValidator.errorMessage) + } + } catch (err) {} + message.destroy(msgKey) + setUploading(false) + return false + } + return ( + setOpen(false) + }} + onOpenChange={(v) => { + if (!v) { + setParseResult(undefined) + setUploadFile(undefined) + } + }} + submitter={{ + render: () => ( +
+ {!parsedResult ? ( + <> + + + + + + ) : ( + <> + + + + + 确认导入 + + } + onFinish={async (data) => { + if (uploadFile) { + if (parsedResult.length) { + try { + await api.audit.importMaterials({ + file: uploadFile, + data: parsedResult, + qty: parsedResult.length, + desc: data.desc + }) + message.success('申请成功') + setOpen(false) + return true + } catch (err) {} + } else { + message.error('至少包含一条解析数据') + } + } else { + message.error('找不到上传文件,请重新上传') + } + return false + }} + /> + + )} +
+ ) + }} + trigger={React.cloneElement(trigger, { onClick: () => setOpen(true) })} + > + + {errMsg ? : ''} +
+ +
+ 1. 请下载文件模板, 并根据要求填写后上传文件进行解析 +
+
+ 2. 解析成功后, 确认解析数量是否一致, 点击确认即可导入 +
+
+ {parsedResult ? ( + <> + +
+
+ + +
+
+ + 解析成功, 请确认解析数量一致后点击确认导入 +
+
+ 共 + {parsedResult.length} + 条数据 +
+
+ + ) : ( + '' + )} + +
+ ) +} + +export default ImportMaterialsModal diff --git a/空气质量预测/源码/管理端/iapip-ms/src/components/material-card/index.tsx b/空气质量预测/源码/管理端/iapip-ms/src/components/material-card/index.tsx index d60f781..8ecb542 100644 --- a/空气质量预测/源码/管理端/iapip-ms/src/components/material-card/index.tsx +++ b/空气质量预测/源码/管理端/iapip-ms/src/components/material-card/index.tsx @@ -1,5 +1,5 @@ import { DATA_TYPE } from '@/common/constants' -import { AnyObj, ECO_LEVEL, Material } from '@/common/types' +import { AnyObj, Material } from '@/common/types' import { searchParams } from '@/common/utils' import api from '@/services/api' import { @@ -119,12 +119,6 @@ const MaterialCard: React.FC<{ - {tab === DATA_TYPE.PRIVATE ? ( ) : ( @@ -229,7 +223,7 @@ const MaterialCard: React.FC<{ search={false} options={false} actionRef={tableRef} - scroll={modal ? { y: 'calc(100vh - 400px)' } : { y: 'calc(100vh - 360px)' }} + scroll={modal ? { x: 3200, y: 'calc(100vh - 400px)' } : { x: 3200, y: 'calc(100vh - 360px)' }} rowSelection={ modal ? { @@ -280,7 +274,8 @@ const MaterialCard: React.FC<{ title: '材料ID', dataIndex: 'material_id', width: 120, - align: 'center' + align: 'center', + fixed: 'left' }, { title: '材料名称', @@ -311,7 +306,7 @@ const MaterialCard: React.FC<{ align: 'center' }, { - title: '材料厂家', + title: '生产商', dataIndex: 'factory', align: 'center' }, @@ -320,13 +315,6 @@ const MaterialCard: React.FC<{ dataIndex: 'spec', align: 'center' }, - { - title: '环保等级', - dataIndex: 'eco_level', - align: 'center', - width: 90, - render: (_, { eco_level }) => (eco_level ? {eco_level} : '-') - }, { title: '健康等级', dataIndex: 'health_level', @@ -344,6 +332,104 @@ const MaterialCard: React.FC<{ return m.health_level ? {m.health_level} : '—' } }, + { + title: '型号', + dataIndex: 'model_name', + align: 'center', + render: (v) => v || '—' + }, + { + title: '参考价格', + dataIndex: 'reference_price', + align: 'center', + render: (v) => v || '—' + }, + { + title: '应用案例', + dataIndex: 'application_case', + align: 'center', + render: (v) => v || '—' + }, + { + title: '样品图片', + dataIndex: 'sample_image', + align: 'center', + width: 100, + render: (v) => v ? : '—' + }, + { + title: '应用效果图', + dataIndex: 'effect_image', + align: 'center', + width: 100, + render: (v) => v ? : '—' + }, + { + title: '供应商', + dataIndex: 'supplier', + align: 'center', + render: (v) => v || '—' + }, + { + title: '联系人', + dataIndex: 'contact_person', + align: 'center', + render: (v) => v || '—' + }, + { + title: '联系电话', + dataIndex: 'contact_phone', + align: 'center', + render: (v) => v || '—' + }, + { + title: '生产日期', + dataIndex: 'production_date', + align: 'center', + render: (v) => v || '—' + }, + { + title: '生产批号', + dataIndex: 'batch_no', + align: 'center', + render: (v) => v || '—' + }, + { + title: '样品来源', + dataIndex: 'sample_source', + align: 'center', + render: (v) => v || '—' + }, + { + title: '委托单位', + dataIndex: 'client_unit', + align: 'center', + render: (v) => v || '—' + }, + { + title: '检测日期', + dataIndex: 'test_date', + align: 'center', + render: (v) => v || '—' + }, + { + title: '执行标准', + dataIndex: 'test_standard', + align: 'center', + render: (v) => v || '—' + }, + { + title: '报告编号', + dataIndex: 'report_no', + align: 'center', + render: (v) => v || '—' + }, + { + title: '检测机构', + dataIndex: 'test_org', + align: 'center', + render: (v) => v || '—' + }, { title: '状态', dataIndex: 'display', @@ -376,6 +462,7 @@ const MaterialCard: React.FC<{ valueType: 'option', align: 'center', width: 100, + fixed: 'right', render: (_, item) => (
+ + + + )} +
+ ) : ( + !ro ? ( + { + setUploading(true) + try { + const res = await api.config.uploadImage(file) + onChange?.(res.url) + } catch { message.error('上传失败') } + setUploading(false) + return false + }} + > + + + ) : 暂无图片 + )} +
+ ) +} + +const MaterialModalForm: React.FC<{ + title?: string + initialValues?: Partial + displayId?: boolean + displayCreator?: boolean + displayStatus?: boolean + displayDetails?: boolean + trigger?: JSX.Element + open?: boolean + readonly?: boolean + submitText?: string + onFinish?: (values: Material) => Promise + onCancel?: () => void +}> = ({ + title, + initialValues, + displayId, + displayCreator, + displayStatus, + displayDetails, + trigger, + open, + readonly, + submitText, + onFinish, + onCancel +}) => { + const { token } = theme.useToken() + return ( + + title={title} + trigger={trigger} + initialValues={initialValues} + open={open} + onFinish={onFinish} + modalProps={{ + maskClosable: false, + destroyOnClose: true, + onCancel: onCancel, + styles: { + content: { + paddingBottom: 12 + } + } + }} + layout="horizontal" + width="fit-content" + submitter={readonly ? false : { searchConfig: { submitText } }} + > +
+ +
+ + 基本信息 +
+
+
+ {displayId ? ( + + ) : ( + '' + )} + {displayCreator ? ( + v?.username} + /> + ) : ( + '' + )} +
+
+ + { + const cats = await api.config.getMtrlCatList() + return cats.map((item) => ({ + value: item.first_category, + label: item.first_category, + children: item.second_categories.map((c) => ({ + value: c.unit ? `${c.second_category}-${c.unit}` : c.second_category, + label: c.second_category + })) + })) + }} + transform={(val) => { + if (Array.isArray(val)) { + if (val.length > 1) { + const fc = val[0] + const scInfo = val[1].split('-') + return { + category: `${fc}/${scInfo[0]}`, + unit: scInfo.length > 1 ? scInfo[1] : undefined + } + } else { + return val.join('/') + } + } else { + return val + } + }} + convertValue={(val) => val?.split?.('/')} + disabled={readonly} + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + + + + +
+ {displayStatus ? ( +
+ (v ? 1 : 0)} + disabled={readonly} + /> +
+ ) : ( + '' + )} +
+ {displayDetails ? ( + <> + +
+ + 生产商供应商信息 +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + 污染物释放参数 +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + 测试信息 +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + ) : ( + '' + )} + {!readonly ? : ''} +
+ + ) +} + +export default MaterialModalForm diff --git a/空气质量预测/源码/管理端/iapip-ms/src/services/api/config.ts b/空气质量预测/源码/管理端/iapip-ms/src/services/api/config.ts new file mode 100644 index 0000000..a4862fb --- /dev/null +++ b/空气质量预测/源码/管理端/iapip-ms/src/services/api/config.ts @@ -0,0 +1,236 @@ +import { HTTP_HEADER } from '@/common/constants' +import { AnyObj, ApcLimitStandard, CityInformation, MaterialCategory, ProjectSpaceCategory } from '@/common/types' +import { getLocalToken } from '@/common/utils' +import { request } from '@umijs/max' + +/** + * 项目-空间类型 + */ + +export async function createProjSpaceCat(body: ProjectSpaceCategory, options?: AnyObj) { + return request<{ count: number }>(`/api/cfg/proj-space-cat`, { + method: 'POST', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function updateProjSpaceCat(body: ProjectSpaceCategory, options?: AnyObj) { + return request(`/api/cfg/proj-space-cat`, { + method: 'PUT', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function sortProjCat(body: { bfr_no: number; afr_no: number }, options?: AnyObj) { + return request(`/api/cfg/proj-space-cat`, { + method: 'PATCH', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function removeProjSpaceCat(pry_no_1: number, options?: AnyObj) { + return request<{ count: number }>(`/api/cfg/proj-space-cat/${pry_no_1}`, { + method: 'DELETE', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +export async function getProjSpaceCatList( + query?: { project_category?: string; space_category?: string }, + options?: AnyObj +) { + return request('/api/cfg/proj-space-cat', { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + params: query, + ...(options || {}) + }) +} + +export async function getProjCatList(options?: AnyObj) { + return request('/api/cfg/proj-cat', { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +/** + * 材料类别 + */ + +export async function createMtrlCat(body: MaterialCategory, options?: AnyObj) { + return request<{ count: number }>(`/api/cfg/mtrl-cat`, { + method: 'POST', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function updateMtrlCat(body: MaterialCategory, options?: AnyObj) { + return request(`/api/cfg/mtrl-cat`, { + method: 'PUT', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function sortMtrlCat(body: { bfr_no: number; afr_no: number }, options?: AnyObj) { + return request(`/api/cfg/mtrl-cat`, { + method: 'PATCH', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function removeMtrlCat(pry_no_1: number, options?: AnyObj) { + return request<{ count: number }>(`/api/cfg/mtrl-cat/${pry_no_1}`, { + method: 'DELETE', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +export async function getMtrlCatList( + query?: { first_category?: string; second_categories?: string }, + options?: AnyObj +) { + return request('/api/cfg/mtrl-cat', { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + params: query, + ...(options || {}) + }) +} + +export async function getMtrlFirstCatList(options?: AnyObj) { + return request('/api/cfg/mtrl-first-cat', { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +/** + * 城市信息 + */ + +export async function createCityInfo(body: CityInformation, options?: AnyObj) { + return request<{ count: number }>(`/api/cfg/city-info`, { + method: 'POST', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function updateCityInfo(body: CityInformation, options?: AnyObj) { + return request(`/api/cfg/city-info`, { + method: 'PUT', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function sortCityInfo(body: { bfr_no: number; afr_no: number }, options?: AnyObj) { + return request(`/api/cfg/city-info`, { + method: 'PATCH', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: body, + ...(options || {}) + }) +} + +export async function removeCityInfo(pry_no_1: number, options?: AnyObj) { + return request<{ count: number }>(`/api/cfg/city-info/${pry_no_1}`, { + method: 'DELETE', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +export async function getCityInfoList(query?: { province?: string; city?: string }, options?: AnyObj) { + return request('/api/cfg/city-info', { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + params: query, + ...(options || {}) + }) +} + +export async function getProvinceList(options?: AnyObj) { + return request('/api/cfg/province', { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +export async function getAPCLmtStdList(options?: AnyObj) { + return request(`/api/cfg/apc-lmt-std`, { + method: 'GET', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + ...(options || {}) + }) +} + +export async function uploadImage(file: File) { + const formData = new FormData() + formData.append('file', file) + return request<{ url: string }>('/api/cfg/landing/upload', { + method: 'POST', + headers: { + [HTTP_HEADER.AUTHORIZATION]: getLocalToken() + }, + data: formData + }) +}