feat(ms/web): 管理端材料库扩展字段 + 导入模板更新 + 饼图浅色
- 管理端: 去掉环保等级, 材料表单/列表新增16个字段(型号/参考价格/供应商/联系人/生产日期/样品来源/检测信息等) - 管理端: 新建材料必填项加*号(名称/类别/品牌/污染物参数/样品来源) - 管理端: 材料导入Excel模板更新为39列新格式, 解析逻辑同步 - 管理端: 材料表单增加样品图片/应用效果图上传 - 后端: PublicMaterial/SelfMaterial schema 新增16个字段 - 用户端: 饼图颜色改为浅色系 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T71oHHhfhLAWAL9EuoFuYh
This commit is contained in:
parent
57ff12d8d0
commit
8ee0784f20
|
|
@ -22,8 +22,10 @@
|
||||||
"koa-body": "^6.0.1",
|
"koa-body": "^6.0.1",
|
||||||
"koa-router": "^12.0.1",
|
"koa-router": "^12.0.1",
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"source-map-support": "^0.5.21"
|
"source-map-support": "^0.5.21"
|
||||||
},
|
},
|
||||||
|
|
@ -1751,6 +1753,15 @@
|
||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
"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": "^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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
|
|
@ -2622,6 +2639,15 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/duplexer2": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
|
"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",
|
"resolved": "https://registry.npmmirror.com/long-timeout/-/long-timeout-0.1.1.tgz",
|
||||||
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="
|
"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": {
|
"node_modules/loupe": {
|
||||||
"version": "2.3.7",
|
"version": "2.3.7",
|
||||||
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz",
|
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz",
|
||||||
|
|
@ -4191,6 +4228,39 @@
|
||||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
|
@ -4368,6 +4438,12 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-schedule": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz",
|
||||||
"integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -4595,6 +4677,28 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/performance-now": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
|
@ -5159,6 +5263,12 @@
|
||||||
"source-map": "^0.6.0"
|
"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": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
|
@ -5510,6 +5620,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "5.26.5",
|
"version": "5.26.5",
|
||||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
|
"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",
|
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"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": {
|
"node_modules/xmlchars": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,10 @@
|
||||||
"koa-body": "^6.0.1",
|
"koa-body": "^6.0.1",
|
||||||
"koa-router": "^12.0.1",
|
"koa-router": "^12.0.1",
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"node-schedule": "^2.1.1",
|
"node-schedule": "^2.1.1",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"source-map-support": "^0.5.21"
|
"source-map-support": "^0.5.21"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,22 @@ model PublicMaterial {
|
||||||
spec String?
|
spec String?
|
||||||
eco_level String?
|
eco_level String?
|
||||||
health_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 Float?
|
||||||
methanal_min_be Float?
|
methanal_min_be Float?
|
||||||
methanal_be_area Float?
|
methanal_be_area Float?
|
||||||
|
|
@ -486,6 +502,22 @@ model SelfMaterial {
|
||||||
spec String?
|
spec String?
|
||||||
eco_level String?
|
eco_level String?
|
||||||
health_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 Float?
|
||||||
methanal_min_be Float?
|
methanal_min_be Float?
|
||||||
methanal_be_area 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 {
|
model ForumThread {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
board String // 板块(取值属于 FORUM_BOARDS)
|
board String // 板块(取值属于 FORUM_BOARDS)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import Router from 'koa-router'
|
import Router from 'koa-router'
|
||||||
import authn from '../middlewares/authn'
|
import authn from '../middlewares/authn'
|
||||||
import { ADMIN_TYPE, AcctCert, AdminCert, AnyObj, CERT_TYPE, UserCert } from '../common/types'
|
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
|
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<string, string> => {
|
||||||
|
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 => {
|
materialRouter.get('/eco', async ctx => {
|
||||||
const take = 8
|
const picks = readEcoPicks()
|
||||||
const selectFields = {
|
const images = readEcoImages()
|
||||||
material_id: true, name: true, category: true,
|
let list: any[]
|
||||||
brand: true, factory: true, eco_level: true, methanal: true
|
if (picks.length > 0) {
|
||||||
}
|
const rows = await db.publicMaterial.findMany({
|
||||||
const e0 = await db.publicMaterial.findMany({
|
where: { material_id: { in: picks }, display: 1 },
|
||||||
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,
|
|
||||||
select: selectFields
|
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<AdminCert>(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<AdminCert>(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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查询材料
|
// 查询材料
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,6 @@ export default defineConfig({
|
||||||
path: '/home',
|
path: '/home',
|
||||||
component: './home'
|
component: './home'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: '污染源识别',
|
|
||||||
path: '/source',
|
|
||||||
component: './source'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: '模板库',
|
name: '模板库',
|
||||||
path: '/template',
|
path: '/template',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { searchParams } from '@/common/utils'
|
||||||
import MaterialModalForm from '@/components/material-modal-form'
|
import MaterialModalForm from '@/components/material-modal-form'
|
||||||
import PageCard from '@/components/page-card'
|
import PageCard from '@/components/page-card'
|
||||||
import api from '@/services/api'
|
import api from '@/services/api'
|
||||||
|
import * as ExcelJS from 'exceljs'
|
||||||
import {
|
import {
|
||||||
ActionType,
|
ActionType,
|
||||||
LightFilter,
|
LightFilter,
|
||||||
|
|
@ -14,7 +15,10 @@ import {
|
||||||
QueryFilter
|
QueryFilter
|
||||||
} from '@ant-design/pro-components'
|
} from '@ant-design/pro-components'
|
||||||
import { Button, Form, InputNumber, Modal, Popconfirm, Select, Switch, Tag, message } from 'antd'
|
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<string, string> = { 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 { useModel } from '@umijs/max'
|
||||||
import ImportMaterialsModal from '../import-file-modal/materials'
|
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.SUPER_ADMIN ||
|
||||||
(cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1))
|
(cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1))
|
||||||
const pageDataRef = useRef<Material[]>([])
|
const pageDataRef = useRef<Material[]>([])
|
||||||
|
const [catSelections, setCatSelections] = useState<string[][]>([])
|
||||||
|
const [dataFirstCats, setDataFirstCats] = useState<string[]>([])
|
||||||
|
const catColorMap = useMemo(() => {
|
||||||
|
const fromFilter = [...new Set(catSelections.map((path) => path[0]))]
|
||||||
|
const firsts = fromFilter.length > 0 ? fromFilter : dataFirstCats
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
firsts.forEach((f, i) => { map[f] = CAT_TAG_COLORS[i % CAT_TAG_COLORS.length] })
|
||||||
|
return map
|
||||||
|
}, [catSelections, dataFirstCats])
|
||||||
// 仅在「手动排序」视图下启用排序控件(避免在按其他字段排序时破坏 sort_order 语义)。
|
// 仅在「手动排序」视图下启用排序控件(避免在按其他字段排序时破坏 sort_order 语义)。
|
||||||
// 顶部「排序依据」Select 才是实际决定请求排序字段的来源(见下方 request),
|
// 顶部「排序依据」Select 才是实际决定请求排序字段的来源(见下方 request),
|
||||||
// 因此这里额外要求 sortKey === 'sort_order' 且未开启分组,避免在 Ypt/污染物排序或分组视图下
|
// 因此这里额外要求 sortKey === 'sort_order' 且未开启分组,避免在 Ypt/污染物排序或分组视图下
|
||||||
|
|
@ -95,6 +108,51 @@ const MaterialCard: React.FC<{
|
||||||
}
|
}
|
||||||
// 取材料在全部数据中的全局序号(0-based),用于排序输入框显示
|
// 取材料在全部数据中的全局序号(0-based),用于排序输入框显示
|
||||||
const globalIndexOf = (material_id: string) => (pageDataRef.current ?? []).findIndex((m) => m.material_id === material_id)
|
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 = (
|
const content = (
|
||||||
<PageCard
|
<PageCard
|
||||||
title={title}
|
title={title}
|
||||||
|
|
@ -111,32 +169,37 @@ const MaterialCard: React.FC<{
|
||||||
]}
|
]}
|
||||||
onTabChange={(key) => setTab(key as DATA_TYPE)}
|
onTabChange={(key) => setTab(key as DATA_TYPE)}
|
||||||
extra={
|
extra={
|
||||||
!modal && tab === DATA_TYPE.PRIVATE
|
!modal
|
||||||
? [
|
? [
|
||||||
<ImportMaterialsModal
|
...(isAdmin ? [<Button key="export-btn" onClick={exportExcel}>导出Excel</Button>] : []),
|
||||||
key="import-modal"
|
...(tab === DATA_TYPE.PRIVATE
|
||||||
title="导入自建材料"
|
? [
|
||||||
trigger={<Button>导入材料</Button>}
|
<ImportMaterialsModal
|
||||||
onSuccess={async () => {
|
key="import-modal"
|
||||||
await tableRef?.current?.reload?.()
|
title="导入自建材料"
|
||||||
}}
|
trigger={<Button>导入材料</Button>}
|
||||||
/>,
|
onSuccess={async () => {
|
||||||
<MaterialModalForm
|
await tableRef?.current?.reload?.()
|
||||||
key="create-modal-form"
|
}}
|
||||||
title="新建自建材料"
|
/>,
|
||||||
displayDetails
|
<MaterialModalForm
|
||||||
trigger={<Button type="primary">新建材料</Button>}
|
key="create-modal-form"
|
||||||
onFinish={async (data) => {
|
title="新建自建材料"
|
||||||
try {
|
displayDetails
|
||||||
await api.material.createSelf(data)
|
trigger={<Button type="primary">新建材料</Button>}
|
||||||
message.success('新建成功')
|
onFinish={async (data) => {
|
||||||
await tableRef?.current?.reload?.()
|
try {
|
||||||
return true
|
await api.material.createSelf(data)
|
||||||
} catch (err) {
|
message.success('新建成功')
|
||||||
return false
|
await tableRef?.current?.reload?.()
|
||||||
}
|
return true
|
||||||
}}
|
} catch (err) {
|
||||||
/>
|
return false
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
]
|
||||||
|
: [])
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
@ -160,9 +223,20 @@ const MaterialCard: React.FC<{
|
||||||
fieldProps={{
|
fieldProps={{
|
||||||
placeholder: '请选择大类/子类(可多选跨类)',
|
placeholder: '请选择大类/子类(可多选跨类)',
|
||||||
multiple: true,
|
multiple: true,
|
||||||
showSearch: true,
|
|
||||||
changeOnSelect: 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 (
|
||||||
|
<Tag color={color} closable={props.closable} onClose={props.onClose} style={{ marginInlineEnd: 4 }}>
|
||||||
|
{labelText}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
request={async () => {
|
request={async () => {
|
||||||
const cats = await api.config.getMtrlCatList()
|
const cats = await api.config.getMtrlCatList()
|
||||||
|
|
@ -170,22 +244,20 @@ const MaterialCard: React.FC<{
|
||||||
value: item.first_category,
|
value: item.first_category,
|
||||||
label: item.first_category,
|
label: item.first_category,
|
||||||
children: item.second_categories.map((c) => ({
|
children: item.second_categories.map((c) => ({
|
||||||
value: c.second_category,
|
value: `${item.first_category}/${c.second_category}`,
|
||||||
label: 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
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<ProFormText name="brand" label="材料品牌" />
|
<ProFormText name="brand" label="材料品牌" />
|
||||||
<ProFormText name="factory" label="材料厂家" />
|
<ProFormText name="factory" label="材料厂家" />
|
||||||
<ProFormText name="spec" label="材料规格" />
|
<ProFormText name="spec" label="材料规格" />
|
||||||
<ProFormSelect
|
|
||||||
name="eco_level"
|
|
||||||
label="环保级别"
|
|
||||||
options={[{ value: ECO_LEVEL.E0 }, { value: ECO_LEVEL.E1 }, { value: ECO_LEVEL.E2 }]}
|
|
||||||
mode="multiple"
|
|
||||||
/>
|
|
||||||
<ProFormSelect
|
<ProFormSelect
|
||||||
name="health_level"
|
name="health_level"
|
||||||
label="健康等级"
|
label="健康等级"
|
||||||
|
|
@ -252,6 +324,7 @@ const MaterialCard: React.FC<{
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilterConditions(undefined)
|
setFilterConditions(undefined)
|
||||||
filterForm.resetFields()
|
filterForm.resetFields()
|
||||||
|
setCatSelections([])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
|
|
@ -383,7 +456,17 @@ const MaterialCard: React.FC<{
|
||||||
{
|
{
|
||||||
title: '材料类别',
|
title: '材料类别',
|
||||||
dataIndex: 'category',
|
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 (
|
||||||
|
<span style={hex ? { color: hex, fontWeight: 600 } : undefined}>
|
||||||
|
{m.category}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '材料品牌',
|
title: '材料品牌',
|
||||||
|
|
@ -400,13 +483,6 @@ const MaterialCard: React.FC<{
|
||||||
dataIndex: 'spec',
|
dataIndex: 'spec',
|
||||||
align: 'center'
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '环保等级',
|
|
||||||
dataIndex: 'eco_level',
|
|
||||||
align: 'center',
|
|
||||||
width: 90,
|
|
||||||
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '健康等级',
|
title: '健康等级',
|
||||||
dataIndex: 'health_level',
|
dataIndex: 'health_level',
|
||||||
|
|
@ -573,11 +649,12 @@ const MaterialCard: React.FC<{
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pageDataRef.current = data
|
pageDataRef.current = data
|
||||||
|
const uniqueFirstCats = [...new Set(data.map((m) => (m.category ?? '').split('/')[0]).filter(Boolean))]
|
||||||
|
setDataFirstCats(uniqueFirstCats)
|
||||||
if (sortByCategory) {
|
if (sortByCategory) {
|
||||||
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
|
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
|
||||||
}
|
}
|
||||||
if (gbtParam) {
|
if (gbtParam) {
|
||||||
// 分组开时后端已按健康等级排序;为每档第一条打标,供「材料名称」列渲染分区小标题
|
|
||||||
let last: string | null | undefined = undefined
|
let last: string | null | undefined = undefined
|
||||||
data.forEach((m) => {
|
data.forEach((m) => {
|
||||||
if (m.health_level !== last) {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data
|
data
|
||||||
|
|
@ -690,6 +778,7 @@ const MaterialCard: React.FC<{
|
||||||
setTab(DATA_TYPE.PUBLIC)
|
setTab(DATA_TYPE.PUBLIC)
|
||||||
setFilterConditions(undefined)
|
setFilterConditions(undefined)
|
||||||
filterForm.resetFields()
|
filterForm.resetFields()
|
||||||
|
setCatSelections([])
|
||||||
setSelected(undefined)
|
setSelected(undefined)
|
||||||
}}
|
}}
|
||||||
onCancel={modalProps?.onCancel}
|
onCancel={modalProps?.onCancel}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 { getArticle } from '@/common/articles'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import './article.css'
|
||||||
|
|
||||||
export default function ArticleDetail() {
|
export default function ArticleDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const { initialState } = useModel('@@initialState')
|
||||||
|
const isAdmin = !!initialState?.cert?.is_admin
|
||||||
const article = id ? getArticle(id) : undefined
|
const article = id ? getArticle(id) : undefined
|
||||||
|
|
||||||
if (!article) {
|
const [customHtml, setCustomHtml] = useState<string | null>(null)
|
||||||
|
const [customTitle, setCustomTitle] = useState<string | null>(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 <div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center', color: '#888' }}>加载中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!article && !customHtml) {
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center' }}>
|
<div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center' }}>
|
||||||
<p style={{ color: '#666' }}>文章不存在或已下线。</p>
|
<p style={{ color: '#666' }}>文章不存在或已下线。</p>
|
||||||
|
|
@ -16,29 +52,58 @@ export default function ArticleDetail() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: '32px 16px 64px' }}>
|
<div style={{ maxWidth: 760, margin: '0 auto', padding: '32px 16px 64px' }}>
|
||||||
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}>← 返回首页</a>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
<div style={{ marginTop: 20 }}>
|
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}>← 返回首页</a>
|
||||||
<div style={{ color: '#B43533', fontSize: 13 }}>{article.tag} · {article.date}</div>
|
{isAdmin && id && (
|
||||||
<h1 style={{ fontSize: 26, margin: '10px 0 4px', lineHeight: 1.35 }}>{article.title}</h1>
|
<Upload
|
||||||
<p style={{ color: '#666', fontSize: 15, margin: '8px 0 20px' }}>{article.summary}</p>
|
accept=".docx,.doc,.pdf"
|
||||||
<img src={article.image} alt={article.title} style={{ width: '100%', height: 320, objectFit: 'cover', borderRadius: 12, display: 'block', marginBottom: 8 }} />
|
showUploadList={false}
|
||||||
|
beforeUpload={handleUpload}
|
||||||
|
>
|
||||||
|
<button style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '8px 16px', borderRadius: 8, border: '1px solid #d9d9d9',
|
||||||
|
background: '#fff', cursor: 'pointer', fontSize: 13, color: '#1f7a5a',
|
||||||
|
fontWeight: 600
|
||||||
|
}}>
|
||||||
|
<UploadOutlined /> 上传文档更新内容
|
||||||
|
</button>
|
||||||
|
</Upload>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{article.body.map((sec, i) => (
|
|
||||||
<section key={i} style={{ marginBottom: 20 }}>
|
{customHtml ? (
|
||||||
{sec.heading && <h2 style={{ fontSize: 18, margin: '18px 0 8px' }}>{sec.heading}</h2>}
|
<div
|
||||||
{sec.paragraphs.map((p, j) => (
|
className="article-body"
|
||||||
<p key={j} style={{ lineHeight: 1.9, color: '#333', margin: '0 0 10px' }}>{p}</p>
|
dangerouslySetInnerHTML={{ __html: customHtml }}
|
||||||
|
style={{ lineHeight: 1.9, color: '#333', marginTop: 20 }}
|
||||||
|
/>
|
||||||
|
) : article ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#B43533', fontSize: 13 }}>{article.tag} · {article.date}</div>
|
||||||
|
<h1 style={{ fontSize: 26, margin: '10px 0 4px', lineHeight: 1.35 }}>{article.title}</h1>
|
||||||
|
<p style={{ color: '#666', fontSize: 15, margin: '8px 0 20px' }}>{article.summary}</p>
|
||||||
|
<img src={article.image} alt={article.title} style={{ width: '100%', height: 320, objectFit: 'cover', borderRadius: 12, display: 'block', marginBottom: 8 }} />
|
||||||
|
</div>
|
||||||
|
{article.body.map((sec, i) => (
|
||||||
|
<section key={i} style={{ marginBottom: 20 }}>
|
||||||
|
{sec.heading && <h2 style={{ fontSize: 18, margin: '18px 0 8px' }}>{sec.heading}</h2>}
|
||||||
|
{sec.paragraphs.map((p, j) => (
|
||||||
|
<p key={j} style={{ lineHeight: 1.9, color: '#333', margin: '0 0 10px' }}>{p}</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
))}
|
))}
|
||||||
</section>
|
{article.sources && article.sources.length > 0 && (
|
||||||
))}
|
<div style={{ marginTop: 28, paddingTop: 16, borderTop: '1px solid #eee', color: '#999', fontSize: 13 }}>
|
||||||
{article.sources && article.sources.length > 0 && (
|
<div style={{ marginBottom: 6 }}>参考来源</div>
|
||||||
<div style={{ marginTop: 28, paddingTop: 16, borderTop: '1px solid #eee', color: '#999', fontSize: 13 }}>
|
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
<div style={{ marginBottom: 6 }}>参考来源</div>
|
{article.sources.map((s, i) => <li key={i}>{s}</li>)}
|
||||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
</ul>
|
||||||
{article.sources.map((s, i) => <li key={i}>{s}</li>)}
|
</div>
|
||||||
</ul>
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { history } from '@umijs/max'
|
import { history } from '@umijs/max'
|
||||||
import { articles } from '@/common/articles'
|
import { articles } from '@/common/articles'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import type { LandingNewsItem } from '@/services/api/config'
|
||||||
|
|
||||||
export default function ArticleList() {
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: '32px 16px 64px' }}>
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: '32px 16px 64px' }}>
|
||||||
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}>← 返回首页</a>
|
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}>← 返回首页</a>
|
||||||
<h1 style={{ fontSize: 24, margin: '16px 0 24px' }}>健康装修科普</h1>
|
<h1 style={{ fontSize: 24, margin: '16px 0 24px' }}>健康装修科普</h1>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{articles.map(a => (
|
{items.map(a => (
|
||||||
<article
|
<article
|
||||||
key={a.id}
|
key={a.id}
|
||||||
onClick={() => history.push('/article/' + a.id)}
|
onClick={() => history.push('/article/' + a.id)}
|
||||||
style={{ cursor: 'pointer', display: 'flex', gap: 16, padding: 16, border: '1px solid #eee', borderRadius: 8, transition: 'box-shadow .2s' }}
|
style={{ cursor: 'pointer', display: 'flex', gap: 16, padding: 16, border: '1px solid #eee', borderRadius: 8, transition: 'box-shadow .2s' }}
|
||||||
>
|
>
|
||||||
<img src={a.image} alt={a.title} style={{ width: 160, height: 110, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }} />
|
{a.image && <img src={a.image} alt={a.title} style={{ width: 160, height: 110, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }} />}
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ color: '#B43533', fontSize: 13 }}>{a.tag} · {a.date}</div>
|
<div style={{ color: '#B43533', fontSize: 13 }}>{a.tag} · {a.date}</div>
|
||||||
<h2 style={{ fontSize: 18, margin: '6px 0 8px' }}>{a.title}</h2>
|
<h2 style={{ fontSize: 18, margin: '6px 0 8px' }}>{a.title}</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { LOC_STOR_KEY } from '@/common/constants'
|
||||||
import PhoneAuthModal from '@/components/phone-auth-modal'
|
import PhoneAuthModal from '@/components/phone-auth-modal'
|
||||||
import api from '@/services/api'
|
import api from '@/services/api'
|
||||||
|
import type { LandingHero, LandingNewsItem } from '@/services/api/config'
|
||||||
import type { EcoMaterial } from '@/services/api/material'
|
import type { EcoMaterial } from '@/services/api/material'
|
||||||
import { articles } from '@/common/articles'
|
import { articles } from '@/common/articles'
|
||||||
import './landing.css'
|
import './landing.css'
|
||||||
|
|
@ -20,17 +23,27 @@ const ChevronIcon = ({ dir }: { dir: 'left' | 'right' }) => (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 = [
|
const stats = [
|
||||||
{ n: '28+', t: '在管装修项目' },
|
{ n: '28+', t: '在管装修项目' },
|
||||||
{ n: '2,400+', t: '累计预测次数' },
|
{ n: '2,400+', t: '累计预测次数' },
|
||||||
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
|
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
|
||||||
{ n: '2 部', t: '国标依据 · 18883 / 50325' }
|
{ n: '2 部', t: '国标依据 · 18883 / 50325' }
|
||||||
]
|
]
|
||||||
const news = articles
|
|
||||||
const cases = [
|
const cases = [
|
||||||
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' },
|
{ 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', chip: '甲醛达标' },
|
{ 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', chip: 'TVOC达标' }
|
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46' }
|
||||||
]
|
]
|
||||||
const steps = [
|
const steps = [
|
||||||
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
|
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
|
||||||
|
|
@ -39,32 +52,200 @@ const steps = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN)
|
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 scrollTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
|
const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
|
||||||
export default function Landing() {
|
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<LandingNewsItem[]>(defaultNewsItems)
|
||||||
const [cur, setCur] = useState(0)
|
const [cur, setCur] = useState(0)
|
||||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timer = useRef<ReturnType<typeof setInterval> | 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 stop = () => { if (timer.current) { clearInterval(timer.current); timer.current = null } }
|
||||||
const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % news.length), 5500) }
|
const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % len), 5500) }
|
||||||
const next = () => { setCur(c => (c + 1) % news.length); start() }
|
const next = () => { setCur(c => (c + 1) % len); start() }
|
||||||
const prev = () => { setCur(c => (c - 1 + news.length) % news.length); start() }
|
const prev = () => { setCur(c => (c - 1 + len) % len); start() }
|
||||||
useEffect(() => { start(); return stop }, [])
|
useEffect(() => { start(); return stop }, [len])
|
||||||
|
|
||||||
const [authOpen, setAuthOpen] = useState(false)
|
const [authOpen, setAuthOpen] = useState(false)
|
||||||
const [ecoList, setEcoList] = useState<EcoMaterial[]>([])
|
const [ecoList, setEcoList] = useState<EcoMaterial[]>([])
|
||||||
|
const [caseImages, setCaseImages] = useState<Record<string, string>>({})
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([]))
|
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<EcoMaterial[]>([])
|
||||||
|
const [ecoSelected, setEcoSelected] = useState<string[]>([])
|
||||||
|
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 = () => {
|
const openPredict = () => {
|
||||||
if (hasToken()) history.push('/source')
|
if (hasToken()) history.push('/home')
|
||||||
else setAuthOpen(true)
|
else setAuthOpen(true)
|
||||||
}
|
}
|
||||||
const onAuthed = () => {
|
const onAuthed = () => {
|
||||||
setAuthOpen(false)
|
setAuthOpen(false)
|
||||||
history.push('/source')
|
history.push('/home')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hero 编辑 ---
|
||||||
|
const [hero, setHero] = useState<LandingHero>(DEFAULT_HERO)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editForm, setEditForm] = useState<LandingHero>({})
|
||||||
|
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 (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <br />}
|
||||||
|
{segs.map((s, j) => s === '污染源头' ? <em key={j}>{s}</em> : <span key={j}>{s}</span>)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 科普轮播编辑 ---
|
||||||
|
const [newsEditOpen, setNewsEditOpen] = useState(false)
|
||||||
|
const [newsEditTitle, setNewsEditTitle] = useState('')
|
||||||
|
const [newsEditItems, setNewsEditItems] = useState<LandingNewsItem[]>([])
|
||||||
|
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 (
|
return (
|
||||||
|
|
@ -80,9 +261,11 @@ export default function Landing() {
|
||||||
<a onClick={() => scrollToId('cases')}>预测案例</a>
|
<a onClick={() => scrollToId('cases')}>预测案例</a>
|
||||||
<a onClick={() => scrollToId('eco')}>环保建材</a>
|
<a onClick={() => scrollToId('eco')}>环保建材</a>
|
||||||
<a onClick={() => history.push('/forum')}>健康装修大家谈</a>
|
<a onClick={() => history.push('/forum')}>健康装修大家谈</a>
|
||||||
|
<a onClick={() => Modal.info({ title: '联系我们', content: '邮箱:zty18395607196@gmail.com', okText: '知道了' })}>联系我们</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span className="nav-sp" />
|
<span className="nav-sp" />
|
||||||
<a className="btn btn-primary" onClick={openPredict}>免费试算<ArrowIcon /></a>
|
{!hasToken() && <a className="btn" onClick={() => history.push('/login')}>登录</a>}
|
||||||
|
<a className="btn btn-primary" onClick={openPredict}>现在就预测<ArrowIcon /></a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -90,10 +273,12 @@ export default function Landing() {
|
||||||
<div className="wrap hero-grid">
|
<div className="wrap hero-grid">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow"><span className="pip" />对照 GB/T 18883-2022 与 GB 50325-2020 双国标</span>
|
<span className="eyebrow"><span className="pip" />对照 GB/T 18883-2022 与 GB 50325-2020 双国标</span>
|
||||||
<h1>装修住得安心,<br />先把<em>污染源头</em>挡在门外</h1>
|
<h1 className="hero-title-wrap">
|
||||||
<p className="hero-lead">输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。</p>
|
{renderTitle()}
|
||||||
|
{isAdmin && <button className="edit-icon" title="编辑标题与内容" onClick={openEdit}><EditOutlined /></button>}
|
||||||
|
</h1>
|
||||||
|
<p className="hero-lead">{hero.description}</p>
|
||||||
<div className="hero-cta">
|
<div className="hero-cta">
|
||||||
<a className="btn btn-primary btn-lg" onClick={openPredict}>装修空气质量预测<ArrowIcon /></a>
|
|
||||||
<a className="btn btn-lg" onClick={() => scrollToId('cases')}>查看治理案例</a>
|
<a className="btn btn-lg" onClick={() => scrollToId('cases')}>查看治理案例</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-tags">
|
<div className="hero-tags">
|
||||||
|
|
@ -103,7 +288,12 @@ export default function Landing() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-visual">
|
<div className="hero-visual">
|
||||||
<img className="hero-img" src="/iapip-web/images/hero-interior.jpg" alt="室内装修实景" style={{ objectFit: 'cover', display: 'block' }} />
|
<img className="hero-img" src={hero.image} alt="室内装修实景" style={{ objectFit: 'cover', display: 'block' }} />
|
||||||
|
{isAdmin && (
|
||||||
|
<Upload accept="image/jpeg,image/png,image/webp,image/gif" showUploadList={false} beforeUpload={handleImageUpload}>
|
||||||
|
<button className="edit-icon hero-img-edit" title="更换图片"><UploadOutlined /></button>
|
||||||
|
</Upload>
|
||||||
|
)}
|
||||||
<div className="hero-badge"><b>6 项</b><span>污染物预测</span></div>
|
<div className="hero-badge"><b>6 项</b><span>污染物预测</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,15 +308,24 @@ export default function Landing() {
|
||||||
<section className="sec" id="news">
|
<section className="sec" id="news">
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="sec-head">
|
<div className="sec-head">
|
||||||
<div><div className="sec-tag">健康装修科普</div><h2 className="sec-h">读懂装修污染,从知道哪些是健康材料开始</h2></div>
|
<div>
|
||||||
|
<div className="sec-tag">健康装修科普</div>
|
||||||
|
<h2 className="sec-h">
|
||||||
|
{newsTitle}
|
||||||
|
{isAdmin && <button className="edit-icon" title="编辑科普栏目" onClick={openNewsEdit} style={{ marginLeft: 12 }}><EditOutlined /></button>}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<a className="btn" onClick={() => history.push('/articles')}>全部文章</a>
|
<a className="btn" onClick={() => history.push('/articles')}>全部文章</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="carousel">
|
<div className="carousel">
|
||||||
<div className="cviewport">
|
<div className="cviewport">
|
||||||
<div className="ctrack" style={{ transform: `translateX(${-cur * 100}%)` }}>
|
<div className="ctrack" style={{ transform: `translateX(${-cur * 100}%)` }}>
|
||||||
{news.map((n, i) => (
|
{newsItems.map((n, i) => (
|
||||||
<article className="cslide" key={i} style={{ cursor: 'pointer' }} onClick={() => history.push('/article/' + n.id)}>
|
<article className="cslide" key={n.id || i} style={{ cursor: 'pointer' }} onClick={() => history.push('/article/' + n.id)}>
|
||||||
<div className="cslide-img"><span className="cslide-tag">{n.tag}</span><img src={n.image} alt={n.title} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} /></div>
|
<div className="cslide-img">
|
||||||
|
<span className="cslide-tag">{n.tag}</span>
|
||||||
|
{n.image && <img src={n.image} alt={n.title} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />}
|
||||||
|
</div>
|
||||||
<div className="cslide-body">
|
<div className="cslide-body">
|
||||||
<div className="cslide-date">{n.date}</div>
|
<div className="cslide-date">{n.date}</div>
|
||||||
<h3>{n.title}</h3>
|
<h3>{n.title}</h3>
|
||||||
|
|
@ -141,7 +340,7 @@ export default function Landing() {
|
||||||
<button className="cnav next" onClick={next}><ChevronIcon dir="right" /></button>
|
<button className="cnav next" onClick={next}><ChevronIcon dir="right" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="cdots">
|
<div className="cdots">
|
||||||
{news.map((_, i) => <button key={i} className={'cdot' + (i === cur ? ' on' : '')} onClick={() => go(i)} />)}
|
{newsItems.map((_, i) => <button key={i} className={'cdot' + (i === cur ? ' on' : '')} onClick={() => go(i)} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -154,7 +353,15 @@ export default function Landing() {
|
||||||
<div className="cases-grid">
|
<div className="cases-grid">
|
||||||
{cases.map((c, i) => (
|
{cases.map((c, i) => (
|
||||||
<article className="case" key={i}>
|
<article className="case" key={i}>
|
||||||
<div className="img-ph">案例实景图</div>
|
{caseImages[i] ? (
|
||||||
|
<div className="img-ph" style={{ backgroundImage: `url(${caseImages[i]})`, backgroundSize: 'cover', backgroundPosition: 'center' }}>
|
||||||
|
{isAdmin && <Upload accept="image/*" showUploadList={false} beforeUpload={f => uploadCaseImage(i, f)}><button className="img-ph-edit"><UploadOutlined /></button></Upload>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="img-ph">
|
||||||
|
{isAdmin ? <Upload accept="image/*" showUploadList={false} beforeUpload={f => uploadCaseImage(i, f)}><button className="img-ph-edit"><UploadOutlined /> 上传图片</button></Upload> : '案例实景图'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="case-b">
|
<div className="case-b">
|
||||||
<div className="case-type">{c.type}</div>
|
<div className="case-type">{c.type}</div>
|
||||||
<h4>{c.name}</h4>
|
<h4>{c.name}</h4>
|
||||||
|
|
@ -163,7 +370,6 @@ export default function Landing() {
|
||||||
<div className="ba-row"><span className="k">优化前</span><div className="ba-track"><div className="ba-fill" style={{ width: c.w1 + '%', background: 'var(--bad)' }} /></div><span className="v" style={{ color: 'var(--bad)' }}>{c.v1}</span></div>
|
<div className="ba-row"><span className="k">优化前</span><div className="ba-track"><div className="ba-fill" style={{ width: c.w1 + '%', background: 'var(--bad)' }} /></div><span className="v" style={{ color: 'var(--bad)' }}>{c.v1}</span></div>
|
||||||
<div className="ba-row"><span className="k">优化后</span><div className="ba-track"><div className="ba-fill" style={{ width: c.w2 + '%', background: 'var(--good)' }} /></div><span className="v" style={{ color: 'var(--good)' }}>{c.v2}</span></div>
|
<div className="ba-row"><span className="k">优化后</span><div className="ba-track"><div className="ba-fill" style={{ width: c.w2 + '%', background: 'var(--good)' }} /></div><span className="v" style={{ color: 'var(--good)' }}>{c.v2}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="case-foot"><span className="chip chip-good">{c.chip}</span><span className="lk">查看溯源<ArrowIcon small /></span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
@ -171,14 +377,13 @@ export default function Landing() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{ecoList.length > 0 && (
|
{(ecoList.length > 0 || isAdmin) && (
|
||||||
<section className="sec" id="eco">
|
<section className="sec" id="eco">
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<div className="sec-head">
|
<div className="sec-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="sec-tag">环保建材</div>
|
<div className="sec-tag">环保建材</div>
|
||||||
<h2 className="sec-h">优选低释放材料,从源头减少污染</h2>
|
<h2 className="sec-h">优选低释放材料,从源头减少污染{isAdmin && <button className="edit-icon" title="编辑展示材料" onClick={openEcoEdit}><EditOutlined /></button>}</h2>
|
||||||
<p className="sec-sub">以下为材料库中环保等级 E0/E1 的低释放建材,选材阶段即可降低甲醛与 TVOC 风险。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="cases-grid">
|
<div className="cases-grid">
|
||||||
|
|
@ -188,10 +393,26 @@ export default function Landing() {
|
||||||
<div className="case-type">{m.category} · {m.brand}</div>
|
<div className="case-type">{m.category} · {m.brand}</div>
|
||||||
<h4>{m.name || m.brand || '环保建材'}</h4>
|
<h4>{m.name || m.brand || '环保建材'}</h4>
|
||||||
<div className="case-meta">{m.factory || '—'}</div>
|
<div className="case-meta">{m.factory || '—'}</div>
|
||||||
<div className="case-foot">
|
</div>
|
||||||
<span className="chip chip-good">环保 {m.eco_level}</span>
|
<div className="eco-img-wrap">
|
||||||
{typeof m.methanal === 'number' && <span className="lk">甲醛 {m.methanal}</span>}
|
{m.image ? (
|
||||||
</div>
|
<div style={{ position: 'relative' }}>
|
||||||
|
<img src={m.image} alt={m.name} className="eco-img" />
|
||||||
|
{isAdmin && (
|
||||||
|
<Upload accept="image/*" showUploadList={false} beforeUpload={f => { uploadEcoImage(m.material_id, f); return false }}>
|
||||||
|
<button className="img-ph-edit" style={{ position: 'absolute', bottom: 6, right: 6 }}><UploadOutlined /></button>
|
||||||
|
</Upload>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
isAdmin ? (
|
||||||
|
<Upload accept="image/*" showUploadList={false} beforeUpload={f => { uploadEcoImage(m.material_id, f); return false }}>
|
||||||
|
<div className="eco-img-ph"><UploadOutlined style={{ fontSize: 20, marginBottom: 4 }} /><span>上传图片</span></div>
|
||||||
|
</Upload>
|
||||||
|
) : (
|
||||||
|
<div className="eco-img-ph"><span style={{ color: '#ccc' }}>暂无图片</span></div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
@ -200,19 +421,6 @@ export default function Landing() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="sec" id="how">
|
|
||||||
<div className="wrap">
|
|
||||||
<div className="sec-head"><div><div className="sec-tag">如何使用</div><h2 className="sec-h">三步,得到一份可溯源的预测报告</h2></div></div>
|
|
||||||
<div className="steps">
|
|
||||||
{steps.map((s, i) => (
|
|
||||||
<div className="step" key={i}>
|
|
||||||
<div className="step-n">{i + 1}</div><h4>{s.h}</h4><p>{s.p}</p>
|
|
||||||
{i < steps.length - 1 && <div className="step-ar"><ArrowIcon /></div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sec" style={{ paddingTop: 0 }}>
|
<section className="sec" style={{ paddingTop: 0 }}>
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
|
|
@ -222,8 +430,7 @@ export default function Landing() {
|
||||||
<p>无需上门,输入材料即可估算。先估后测,把检测的钱花在刀刃上。</p>
|
<p>无需上门,输入材料即可估算。先估后测,把检测的钱花在刀刃上。</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 14, position: 'relative', zIndex: 2 }}>
|
<div style={{ display: 'flex', gap: 14, position: 'relative', zIndex: 2 }}>
|
||||||
<a className="btn btn-primary btn-lg" onClick={openPredict}>免费开始预测</a>
|
<a className="btn btn-primary btn-lg" onClick={openPredict}>现在就预测</a>
|
||||||
<a className="btn btn-lg" onClick={goPro}>查看专业看板</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="deco" />
|
<div className="deco" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,6 +448,150 @@ export default function Landing() {
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<PhoneAuthModal open={authOpen} onSuccess={onAuthed} onCancel={() => setAuthOpen(false)} />
|
<PhoneAuthModal open={authOpen} onSuccess={onAuthed} onCancel={() => setAuthOpen(false)} />
|
||||||
|
|
||||||
|
{/* Hero 编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑首屏内容"
|
||||||
|
open={editOpen}
|
||||||
|
onOk={saveEdit}
|
||||||
|
onCancel={() => setEditOpen(false)}
|
||||||
|
confirmLoading={saving}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 6, fontWeight: 500 }}>标题</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={editForm.title}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))}
|
||||||
|
placeholder="支持换行"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 6, fontWeight: 500 }}>描述</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
value={editForm.description}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 科普栏目编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑科普栏目"
|
||||||
|
open={newsEditOpen}
|
||||||
|
onOk={saveNews}
|
||||||
|
onCancel={() => setNewsEditOpen(false)}
|
||||||
|
confirmLoading={newsSaving}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
width={720}
|
||||||
|
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 6, fontWeight: 500 }}>栏目标题</div>
|
||||||
|
<Input value={newsEditTitle} onChange={e => setNewsEditTitle(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newsEditItems.map((item, idx) => (
|
||||||
|
<div key={idx} className="news-edit-card">
|
||||||
|
<div className="news-edit-card-head">
|
||||||
|
<span style={{ fontWeight: 600 }}>文章 {idx + 1}</span>
|
||||||
|
{newsEditItems.length > 1 && (
|
||||||
|
<button className="edit-icon" style={{ width: 28, height: 28 }} title="删除" onClick={() => removeNewsItem(idx)}>
|
||||||
|
<DeleteOutlined style={{ color: '#bf4a30' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="news-edit-row">
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="news-edit-label">标题 *</div>
|
||||||
|
<Input value={item.title} onChange={e => updateNewsItem(idx, 'title', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="news-edit-row">
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="news-edit-label">标签</div>
|
||||||
|
<Input value={item.tag} onChange={e => updateNewsItem(idx, 'tag', e.target.value)} placeholder="如: 科普、政策解读" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="news-edit-label">日期</div>
|
||||||
|
<Input value={item.date} onChange={e => updateNewsItem(idx, 'date', e.target.value)} placeholder="如: 专栏 · 2026.04" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="news-edit-label">摘要</div>
|
||||||
|
<Input.TextArea rows={2} value={item.summary} onChange={e => updateNewsItem(idx, 'summary', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="news-edit-row" style={{ alignItems: 'center' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="news-edit-label">配图</div>
|
||||||
|
{item.image && <img src={item.image} alt="" style={{ height: 60, borderRadius: 6, objectFit: 'cover', marginRight: 8 }} />}
|
||||||
|
<Upload
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={file => handleNewsImageUpload(idx, file)}
|
||||||
|
>
|
||||||
|
<button className="edit-icon" style={{ width: 28, height: 28 }} title="上传图片"><UploadOutlined /></button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="news-add-btn"
|
||||||
|
onClick={addNewsItem}
|
||||||
|
>
|
||||||
|
<PlusOutlined /> 添加文章
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/* 环保建材编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="选择展示的环保建材"
|
||||||
|
open={ecoEditOpen}
|
||||||
|
onOk={saveEcoPicks}
|
||||||
|
onCancel={() => setEcoEditOpen(false)}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
width={680}
|
||||||
|
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<span style={{ color: '#888', fontSize: 13 }}>已选 {ecoSelected.length} 项</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索材料名称/品牌/分类..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={ecoKeyword}
|
||||||
|
onChange={e => searchCandidates(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{ecoCandidates.map(m => (
|
||||||
|
<label key={m.material_id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||||
|
background: ecoSelected.includes(m.material_id) ? '#f0f9f4' : '#fafafa',
|
||||||
|
borderRadius: 8, cursor: 'pointer', border: '1px solid',
|
||||||
|
borderColor: ecoSelected.includes(m.material_id) ? '#1f7a5a' : '#eee'
|
||||||
|
}}>
|
||||||
|
<Checkbox checked={ecoSelected.includes(m.material_id)} onChange={() => toggleEco(m.material_id)} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{m.name || m.brand}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#888' }}>{m.category} · {m.brand} · {m.factory || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{ecoCandidates.length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>无匹配材料</div>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{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-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 .btn-lg{padding:14px 26px;font-size:15px;border-radius:13px;}
|
||||||
|
.lp .hero-title-wrap .edit-icon{vertical-align:middle;margin-left:8px;}
|
||||||
|
|
||||||
/* hero */
|
/* hero */
|
||||||
.lp .hero{padding:64px 0 52px;}
|
.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-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{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-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-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{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;}
|
.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 .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{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: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-b{padding:18px 19px 20px;}
|
||||||
.lp .case-type{font-size:11.5px;font-weight:700;color:var(--accent);letter-spacing:.5px;}
|
.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;}
|
.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-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;}
|
.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){
|
@media(max-width:900px){
|
||||||
.lp .hero-grid,.lp .cslide{grid-template-columns:1fr;}
|
.lp .hero-grid,.lp .cslide{grid-template-columns:1fr;}
|
||||||
.lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;}
|
.lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,11 @@ export default function SourceTracing() {
|
||||||
const [standards, setStandards] = useState<ApcLimitStandard[]>([])
|
const [standards, setStandards] = useState<ApcLimitStandard[]>([])
|
||||||
const [standardId, setStandardId] = useState('')
|
const [standardId, setStandardId] = useState('')
|
||||||
const [roomId, setRoomId] = useState('demo')
|
const [roomId, setRoomId] = useState('demo')
|
||||||
|
const [customName, setCustomName] = useState('')
|
||||||
const [area, setArea] = useState(19.8)
|
const [area, setArea] = useState(19.8)
|
||||||
const [height, setHeight] = useState(3)
|
const [height, setHeight] = useState(3)
|
||||||
const [temperature, setTemperature] = useState(22)
|
const [temperature, setTemperature] = useState(23)
|
||||||
const [humidity, setHumidity] = useState(45)
|
const [humidity, setHumidity] = useState(50)
|
||||||
const [ventilationRate, setVentilationRate] = useState(0.5)
|
const [ventilationRate, setVentilationRate] = useState(0.5)
|
||||||
const [mats, setMats] = useState<MatRow[]>([])
|
const [mats, setMats] = useState<MatRow[]>([])
|
||||||
const [pol, setPol] = useState<Pollutant>('methanal')
|
const [pol, setPol] = useState<Pollutant>('methanal')
|
||||||
|
|
@ -104,7 +105,7 @@ export default function SourceTracing() {
|
||||||
function buildPayload(ventRate = ventilationRate) {
|
function buildPayload(ventRate = ventilationRate) {
|
||||||
const selected = mats.filter(m => m.on && matMap[m.id])
|
const selected = mats.filter(m => m.on && matMap[m.id])
|
||||||
return {
|
return {
|
||||||
id: 'src', project_id: 'src', name: roomId,
|
id: 'src', project_id: 'src', name: roomId === '_custom' ? (customName || '自定义空间') : roomId,
|
||||||
height, area,
|
height, area,
|
||||||
env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate,
|
env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate,
|
||||||
methanal_c_lmt: standard?.methanal_c_lmt,
|
methanal_c_lmt: standard?.methanal_c_lmt,
|
||||||
|
|
@ -214,12 +215,36 @@ export default function SourceTracing() {
|
||||||
|
|
||||||
<div className="fld">
|
<div className="fld">
|
||||||
<div className="fld-lab">选择样板间</div>
|
<div className="fld-lab">选择样板间</div>
|
||||||
<div className="rooms">
|
<div className="num">
|
||||||
{PRESET_ROOMS.map(r => (
|
<select
|
||||||
<div key={r.id} className={'room-b' + (roomId === r.id ? ' on' : '')} onClick={() => loadRoom(r.id)}>{r.name}</div>
|
style={{ flex: 1, border: 'none', background: 'transparent', padding: '8px 10px', fontSize: 14, fontWeight: 600, outline: 'none', color: 'var(--ink)' }}
|
||||||
))}
|
value={roomId}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '_custom') {
|
||||||
|
setRoomId('_custom')
|
||||||
|
setCustomName('')
|
||||||
|
setArea(0); setHeight(2.7); setTemperature(23); setHumidity(50)
|
||||||
|
setVentilationRate(0.5); setMats([])
|
||||||
|
} else {
|
||||||
|
loadRoom(v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PRESET_ROOMS.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
|
<option value="_custom">手动输入</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{roomId === '_custom' && (
|
||||||
|
<div className="fld">
|
||||||
|
<div className="fld-lab">空间名称</div>
|
||||||
|
<div className="num">
|
||||||
|
<input type="text" placeholder="请输入空间名称" value={customName} onChange={e => setCustomName(e.target.value)}
|
||||||
|
style={{ flex: 1, border: 'none', background: 'transparent', padding: '8px 10px', fontSize: 14, fontWeight: 600, outline: 'none', color: 'var(--ink)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="fld">
|
<div className="fld">
|
||||||
<div className="row2">
|
<div className="row2">
|
||||||
|
|
|
||||||
|
|
@ -65,17 +65,6 @@
|
||||||
.fld-lab .v { font-weight: 700; color: var(--ink); font-size: 13px; }
|
.fld-lab .v { font-weight: 700; color: var(--ink); font-size: 13px; }
|
||||||
|
|
||||||
/* Room selector tabs */
|
/* 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 */
|
/* Number input group */
|
||||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
.row2 > div { min-width: 0; }
|
.row2 > div { min-width: 0; }
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,29 @@ export interface EcoMaterial {
|
||||||
factory: string
|
factory: string
|
||||||
eco_level: string
|
eco_level: string
|
||||||
methanal: number
|
methanal: number
|
||||||
|
image?: string | null
|
||||||
}
|
}
|
||||||
export async function getEcoMaterials(options?: AnyObj) {
|
export async function getEcoMaterials(options?: AnyObj) {
|
||||||
return request<EcoMaterial[]>('/api/mtrl/eco', { method: 'GET', ...(options || {}) })
|
return request<EcoMaterial[]>('/api/mtrl/eco', { method: 'GET', ...(options || {}) })
|
||||||
}
|
}
|
||||||
|
export async function getEcoCandidates(keyword?: string) {
|
||||||
|
return request<EcoMaterial[]>('/api/mtrl/eco/candidates', {
|
||||||
|
method: 'GET',
|
||||||
|
params: { keyword },
|
||||||
|
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function setEcoPicks(ids: string[]) {
|
||||||
|
return request<string[]>('/api/mtrl/eco/picks', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() },
|
||||||
|
data: { ids }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function setEcoImage(materialId: string, url: string) {
|
||||||
|
return request<Record<string, string>>(`/api/mtrl/eco/image/${materialId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() },
|
||||||
|
data: { url }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,22 @@ export type Material = {
|
||||||
display?: number
|
display?: number
|
||||||
ypt?: number | null
|
ypt?: number | null
|
||||||
health_level?: 'A' | 'B' | 'C' | 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 }[]
|
collectors?: { user_id: string; project_id: string }[]
|
||||||
methanal?: number
|
methanal?: number
|
||||||
methanal_min_be?: number
|
methanal_min_be?: number
|
||||||
|
|
|
||||||
|
|
@ -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<string>()
|
||||||
|
const [parsedResult, setParseResult] = useState<AnyObj[]>()
|
||||||
|
const [uploadFile, setUploadFile] = useState<RcFile>()
|
||||||
|
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 (
|
||||||
|
<ModalForm
|
||||||
|
title={title}
|
||||||
|
width={690}
|
||||||
|
open={open}
|
||||||
|
modalProps={{
|
||||||
|
maskClosable: false,
|
||||||
|
destroyOnClose: true,
|
||||||
|
styles: {
|
||||||
|
content: {
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel: () => setOpen(false)
|
||||||
|
}}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) {
|
||||||
|
setParseResult(undefined)
|
||||||
|
setUploadFile(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
submitter={{
|
||||||
|
render: () => (
|
||||||
|
<div className="w-full flex pt-1 justify-center space-x-4">
|
||||||
|
{!parsedResult ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
style={{ width: 120 }}
|
||||||
|
loading={downloading}
|
||||||
|
onClick={async () => {
|
||||||
|
setDownloading(true)
|
||||||
|
try {
|
||||||
|
const file = await fetch('/fts/imft.xlsx')
|
||||||
|
// const file = await fetch('/iapip-ms/fts/imft.xlsx')
|
||||||
|
const wb = new ExcelJS.Workbook()
|
||||||
|
await wb.xlsx.load(await file.arrayBuffer())
|
||||||
|
const ws = wb.getWorksheet(1)
|
||||||
|
if (ws) {
|
||||||
|
const categories = await api.config.getMtrlCatList()
|
||||||
|
categories.forEach((fc, i) => {
|
||||||
|
ws.getCell(1 + i, 43).value = fc.first_category
|
||||||
|
ws.getColumn(44 + i).values = fc.second_categories.map(
|
||||||
|
(sc) => sc.second_category
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await downloadXLSX(
|
||||||
|
await wb.xlsx.writeBuffer(),
|
||||||
|
'批量导入材料(文件模板).xlsx'
|
||||||
|
)
|
||||||
|
} catch (err) {}
|
||||||
|
setDownloading(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载文件模板
|
||||||
|
</Button>
|
||||||
|
<Upload accept=".xlsx" showUploadList={false} beforeUpload={beforeUpload}>
|
||||||
|
<Button type="primary" style={{ width: 120 }} disabled={uploading}>
|
||||||
|
上传文件解析
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload accept=".xlsx" showUploadList={false} beforeUpload={beforeUpload}>
|
||||||
|
<Button style={{ width: 120 }} disabled={uploading}>
|
||||||
|
重新上传
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
<ImportFile
|
||||||
|
title="导入公共材料申请"
|
||||||
|
trigger={
|
||||||
|
<Button type="primary" style={{ width: 120 }}>
|
||||||
|
确认导入
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
trigger={React.cloneElement(trigger, { onClick: () => setOpen(true) })}
|
||||||
|
>
|
||||||
|
<Divider style={{ marginTop: 16, marginBottom: 12 }} />
|
||||||
|
{errMsg ? <Alert message={errMsg} type="error" showIcon style={{ marginBottom: 12 }} /> : ''}
|
||||||
|
<div className="px-6 pt-3 pb-6 space-y-2">
|
||||||
|
<label>导入说明</label>
|
||||||
|
<div style={{ color: token.colorTextSecondary }}>
|
||||||
|
1. 请下载文件模板, 并根据要求填写后上传文件进行解析
|
||||||
|
</div>
|
||||||
|
<div style={{ color: token.colorTextSecondary }}>
|
||||||
|
2. 解析成功后, 确认解析数量是否一致, 点击确认即可导入
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{parsedResult ? (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<div className="p-6 space-y-2">
|
||||||
|
<div>
|
||||||
|
<label className="mr-4">导入文件:</label>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
onClick={async () => {
|
||||||
|
if (uploadFile) {
|
||||||
|
await downloadXLSX(await uploadFile.arrayBuffer(), uploadFile.name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadFile?.name ?? '下载'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mr-4">解析结果:</label>
|
||||||
|
解析成功, 请确认解析数量一致后点击确认导入
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mr-4">解析数据:</label>共
|
||||||
|
<Tag style={{ marginInline: 5 }}>{parsedResult.length}</Tag>
|
||||||
|
条数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
</ModalForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportMaterialsModal
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { DATA_TYPE } from '@/common/constants'
|
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 { searchParams } from '@/common/utils'
|
||||||
import api from '@/services/api'
|
import api from '@/services/api'
|
||||||
import {
|
import {
|
||||||
|
|
@ -119,12 +119,6 @@ const MaterialCard: React.FC<{
|
||||||
<ProFormText name="brand" label="材料品牌" />
|
<ProFormText name="brand" label="材料品牌" />
|
||||||
<ProFormText name="factory" label="材料厂家" />
|
<ProFormText name="factory" label="材料厂家" />
|
||||||
<ProFormText name="spec" label="材料规格" />
|
<ProFormText name="spec" label="材料规格" />
|
||||||
<ProFormSelect
|
|
||||||
name="eco_level"
|
|
||||||
label="环保级别"
|
|
||||||
options={[{ value: ECO_LEVEL.E0 }, { value: ECO_LEVEL.E1 }, { value: ECO_LEVEL.E2 }]}
|
|
||||||
mode="multiple"
|
|
||||||
/>
|
|
||||||
{tab === DATA_TYPE.PRIVATE ? (
|
{tab === DATA_TYPE.PRIVATE ? (
|
||||||
<ProFormText name="creator_name" label="创建用户" />
|
<ProFormText name="creator_name" label="创建用户" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -229,7 +223,7 @@ const MaterialCard: React.FC<{
|
||||||
search={false}
|
search={false}
|
||||||
options={false}
|
options={false}
|
||||||
actionRef={tableRef}
|
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={
|
rowSelection={
|
||||||
modal
|
modal
|
||||||
? {
|
? {
|
||||||
|
|
@ -280,7 +274,8 @@ const MaterialCard: React.FC<{
|
||||||
title: '材料ID',
|
title: '材料ID',
|
||||||
dataIndex: 'material_id',
|
dataIndex: 'material_id',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'center'
|
align: 'center',
|
||||||
|
fixed: 'left'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '材料名称',
|
title: '材料名称',
|
||||||
|
|
@ -311,7 +306,7 @@ const MaterialCard: React.FC<{
|
||||||
align: 'center'
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '材料厂家',
|
title: '生产商',
|
||||||
dataIndex: 'factory',
|
dataIndex: 'factory',
|
||||||
align: 'center'
|
align: 'center'
|
||||||
},
|
},
|
||||||
|
|
@ -320,13 +315,6 @@ const MaterialCard: React.FC<{
|
||||||
dataIndex: 'spec',
|
dataIndex: 'spec',
|
||||||
align: 'center'
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '环保等级',
|
|
||||||
dataIndex: 'eco_level',
|
|
||||||
align: 'center',
|
|
||||||
width: 90,
|
|
||||||
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '健康等级',
|
title: '健康等级',
|
||||||
dataIndex: 'health_level',
|
dataIndex: 'health_level',
|
||||||
|
|
@ -344,6 +332,104 @@ const MaterialCard: React.FC<{
|
||||||
return m.health_level ? <Tag color={color}>{m.health_level}</Tag> : '—'
|
return m.health_level ? <Tag color={color}>{m.health_level}</Tag> : '—'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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 ? <img src={v as string} style={{ maxWidth: 60, maxHeight: 40, objectFit: 'contain' }} /> : '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '应用效果图',
|
||||||
|
dataIndex: 'effect_image',
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => v ? <img src={v as string} style={{ maxWidth: 60, maxHeight: 40, objectFit: 'contain' }} /> : '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'display',
|
dataIndex: 'display',
|
||||||
|
|
@ -376,6 +462,7 @@ const MaterialCard: React.FC<{
|
||||||
valueType: 'option',
|
valueType: 'option',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 100,
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, item) => (
|
render: (_, item) => (
|
||||||
<div className="flex justify-center space-x-2">
|
<div className="flex justify-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,617 @@
|
||||||
|
import { Material } from '@/common/types'
|
||||||
|
import IconFont from '@/components/icon-font'
|
||||||
|
import api from '@/services/api'
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProForm,
|
||||||
|
ProFormCascader,
|
||||||
|
ProFormDigit,
|
||||||
|
ProFormSwitch,
|
||||||
|
ProFormText
|
||||||
|
} from '@ant-design/pro-components'
|
||||||
|
import { Button, Divider, Image, Upload, message, theme } from 'antd'
|
||||||
|
import { UploadOutlined } from '@ant-design/icons'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const ImageField: React.FC<{ value?: string | null; readonly?: boolean; onChange?: (url: string | null) => void }> = ({
|
||||||
|
value, readonly: ro, onChange
|
||||||
|
}) => {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{value ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Image src={value} style={{ maxWidth: 120, maxHeight: 80, objectFit: 'contain' }} />
|
||||||
|
{!ro && (
|
||||||
|
<>
|
||||||
|
<Upload
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.gif"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={async (file) => {
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.config.uploadImage(file)
|
||||||
|
onChange?.(res.url)
|
||||||
|
} catch { message.error('上传失败') }
|
||||||
|
setUploading(false)
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="small" loading={uploading}>替换</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button size="small" danger onClick={() => onChange?.(null)}>删除</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!ro ? (
|
||||||
|
<Upload
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.gif"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={async (file) => {
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.config.uploadImage(file)
|
||||||
|
onChange?.(res.url)
|
||||||
|
} catch { message.error('上传失败') }
|
||||||
|
setUploading(false)
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={uploading}>上传图片</Button>
|
||||||
|
</Upload>
|
||||||
|
) : <span style={{ color: '#999' }}>暂无图片</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaterialModalForm: React.FC<{
|
||||||
|
title?: string
|
||||||
|
initialValues?: Partial<Material>
|
||||||
|
displayId?: boolean
|
||||||
|
displayCreator?: boolean
|
||||||
|
displayStatus?: boolean
|
||||||
|
displayDetails?: boolean
|
||||||
|
trigger?: JSX.Element
|
||||||
|
open?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
submitText?: string
|
||||||
|
onFinish?: (values: Material) => Promise<boolean | void>
|
||||||
|
onCancel?: () => void
|
||||||
|
}> = ({
|
||||||
|
title,
|
||||||
|
initialValues,
|
||||||
|
displayId,
|
||||||
|
displayCreator,
|
||||||
|
displayStatus,
|
||||||
|
displayDetails,
|
||||||
|
trigger,
|
||||||
|
open,
|
||||||
|
readonly,
|
||||||
|
submitText,
|
||||||
|
onFinish,
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
return (
|
||||||
|
<ModalForm<Material>
|
||||||
|
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 } }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Divider style={{ marginTop: 12, marginBottom: 16 }} />
|
||||||
|
<div style={{ fontSize: token.fontSizeLG }}>
|
||||||
|
<IconFont type="icon-fangjian" style={{ marginRight: 8, fontSize: token.fontSizeXL }} />
|
||||||
|
基本信息
|
||||||
|
</div>
|
||||||
|
<div className="pl-10 pt-4">
|
||||||
|
<div className="flex ml-[6px] space-x-2">
|
||||||
|
{displayId ? (
|
||||||
|
<ProFormText
|
||||||
|
name="material_id"
|
||||||
|
label="材料ID"
|
||||||
|
disabled
|
||||||
|
width={250}
|
||||||
|
labelCol={{ span: 5 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
{displayCreator ? (
|
||||||
|
<ProFormText
|
||||||
|
name="creator"
|
||||||
|
label="创建用户"
|
||||||
|
disabled
|
||||||
|
width={250}
|
||||||
|
labelCol={{ span: 7 }}
|
||||||
|
convertValue={(v) => v?.username}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label="材料名称"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{
|
||||||
|
maxLength: 30
|
||||||
|
}}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormCascader
|
||||||
|
name="category"
|
||||||
|
label="材料类别"
|
||||||
|
width={250}
|
||||||
|
fieldProps={{
|
||||||
|
showSearch: true
|
||||||
|
}}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
request={async () => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText
|
||||||
|
name="brand"
|
||||||
|
label="材料品牌"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{
|
||||||
|
maxLength: 30
|
||||||
|
}}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText
|
||||||
|
name="spec"
|
||||||
|
label="材料规格"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{
|
||||||
|
maxLength: 30
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="health_level"
|
||||||
|
label="健康等级"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{
|
||||||
|
maxLength: 10
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText
|
||||||
|
name="model_name"
|
||||||
|
label="型号"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{ maxLength: 30 }}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="reference_price"
|
||||||
|
label="参考价格"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{ maxLength: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText
|
||||||
|
name="application_case"
|
||||||
|
label="应用案例"
|
||||||
|
disabled={readonly}
|
||||||
|
width={250}
|
||||||
|
fieldProps={{ maxLength: 50 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProForm.Item name="sample_image" label="样品图片">
|
||||||
|
<ImageField readonly={readonly} />
|
||||||
|
</ProForm.Item>
|
||||||
|
<ProForm.Item name="effect_image" label="应用效果图">
|
||||||
|
<ImageField readonly={readonly} />
|
||||||
|
</ProForm.Item>
|
||||||
|
</div>
|
||||||
|
{displayStatus ? (
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormSwitch
|
||||||
|
name="display"
|
||||||
|
label="展示状态"
|
||||||
|
checkedChildren="展示"
|
||||||
|
unCheckedChildren="隐藏"
|
||||||
|
transform={(v) => (v ? 1 : 0)}
|
||||||
|
disabled={readonly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{displayDetails ? (
|
||||||
|
<>
|
||||||
|
<Divider style={{ marginTop: 12, marginBottom: 16 }} />
|
||||||
|
<div style={{ fontSize: token.fontSizeLG }}>
|
||||||
|
<IconFont type="icon-fangjian" style={{ marginRight: 8, fontSize: token.fontSizeXL }} />
|
||||||
|
生产商供应商信息
|
||||||
|
</div>
|
||||||
|
<div className="pl-10 pt-4">
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText name="factory" label="生产商" disabled={readonly} width={250} fieldProps={{ maxLength: 50 }} />
|
||||||
|
<ProFormText name="supplier" label="供应商" disabled={readonly} width={250} fieldProps={{ maxLength: 50 }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText name="contact_person" label="联系人" disabled={readonly} width={250} fieldProps={{ maxLength: 20 }} />
|
||||||
|
<ProFormText name="contact_phone" label="联系电话" disabled={readonly} width={250} fieldProps={{ maxLength: 20 }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText name="production_date" label="生产日期" disabled={readonly} width={250} fieldProps={{ maxLength: 20 }} />
|
||||||
|
<ProFormText name="batch_no" label="生产批号/商标" disabled={readonly} width={250} fieldProps={{ maxLength: 30 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ marginTop: 12, marginBottom: 16 }} />
|
||||||
|
<div style={{ fontSize: token.fontSizeLG }}>
|
||||||
|
<IconFont type="icon-wuranyuan" style={{ marginRight: 8, fontSize: token.fontSizeXL }} />
|
||||||
|
污染物释放参数
|
||||||
|
</div>
|
||||||
|
<div className="px-8 pt-4">
|
||||||
|
<div className="flex space-x-6 ml-[14.5px]">
|
||||||
|
<ProFormDigit
|
||||||
|
name="methanal_min_be"
|
||||||
|
label="甲醛: 最低平衡释放量(Y0)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="methanal_be_area"
|
||||||
|
label="平衡释放量范围(Yp)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="methanal_be_roc"
|
||||||
|
label="平衡释放量变化率(B)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="m²/m³"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6 ml-[8.5px]">
|
||||||
|
<ProFormDigit
|
||||||
|
name="tvoc_min_be"
|
||||||
|
label="TVOC: 最低平衡释放量(Y0)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="tvoc_be_area"
|
||||||
|
label="平衡释放量范围(Yp)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="tvoc_be_roc"
|
||||||
|
label="平衡释放量变化率(B)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="m²/m³"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6 ml-[28px]">
|
||||||
|
<ProFormDigit
|
||||||
|
name="benzene_min_be"
|
||||||
|
label="苯: 最低平衡释放量(Y0)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="benzene_be_area"
|
||||||
|
label="平衡释放量范围(Yp)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="benzene_be_roc"
|
||||||
|
label="平衡释放量变化率(B)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="m²/m³"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6 ml-[14px]">
|
||||||
|
<ProFormDigit
|
||||||
|
name="toluene_min_be"
|
||||||
|
label="甲苯: 最低平衡释放量(Y0)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="toluene_be_area"
|
||||||
|
label="平衡释放量范围(Yp)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="toluene_be_roc"
|
||||||
|
label="平衡释放量变化率(B)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="m²/m³"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormDigit
|
||||||
|
name="p_xylene_min_be"
|
||||||
|
label="二甲苯: 最低平衡释放量(Y0)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="p_xylene_be_area"
|
||||||
|
label="平衡释放量范围(Yp)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="mg/m²"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDigit
|
||||||
|
name="p_xylene_be_roc"
|
||||||
|
label="平衡释放量变化率(B)"
|
||||||
|
width={90}
|
||||||
|
colon={false}
|
||||||
|
addonAfter="m²/m³"
|
||||||
|
disabled={readonly}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
fieldProps={{ maxLength: 10 }}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '不能为空'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ marginTop: 12, marginBottom: 16 }} />
|
||||||
|
<div style={{ fontSize: token.fontSizeLG }}>
|
||||||
|
<IconFont type="icon-fangjian" style={{ marginRight: 8, fontSize: token.fontSizeXL }} />
|
||||||
|
测试信息
|
||||||
|
</div>
|
||||||
|
<div className="pl-10 pt-4">
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText name="sample_source" label="样品来源" disabled={readonly} width={250} fieldProps={{ maxLength: 30 }} rules={[{ required: true, message: '不能为空' }]} />
|
||||||
|
<ProFormText name="client_unit" label="委托单位" disabled={readonly} width={250} fieldProps={{ maxLength: 50 }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText name="test_date" label="检测日期" disabled={readonly} width={250} fieldProps={{ maxLength: 20 }} />
|
||||||
|
<ProFormText name="test_standard" label="执行标准" disabled={readonly} width={250} fieldProps={{ maxLength: 30 }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<ProFormText name="report_no" label="报告编号" disabled={readonly} width={250} fieldProps={{ maxLength: 30 }} />
|
||||||
|
<ProFormText name="test_org" label="检测机构" disabled={readonly} width={250} fieldProps={{ maxLength: 50 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
{!readonly ? <Divider style={{ margin: 0 }} /> : ''}
|
||||||
|
</div>
|
||||||
|
</ModalForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MaterialModalForm
|
||||||
|
|
@ -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<AnyObj[]>(`/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<AnyObj[]>(`/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<ProjectSpaceCategory[]>('/api/cfg/proj-space-cat', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
|
||||||
|
},
|
||||||
|
params: query,
|
||||||
|
...(options || {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjCatList(options?: AnyObj) {
|
||||||
|
return request<string[]>('/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<AnyObj[]>(`/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<AnyObj[]>(`/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<MaterialCategory[]>('/api/cfg/mtrl-cat', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
|
||||||
|
},
|
||||||
|
params: query,
|
||||||
|
...(options || {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMtrlFirstCatList(options?: AnyObj) {
|
||||||
|
return request<string[]>('/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<AnyObj[]>(`/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<AnyObj[]>(`/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<CityInformation[]>('/api/cfg/city-info', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
|
||||||
|
},
|
||||||
|
params: query,
|
||||||
|
...(options || {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProvinceList(options?: AnyObj) {
|
||||||
|
return request<string[]>('/api/cfg/province', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
|
||||||
|
},
|
||||||
|
...(options || {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAPCLmtStdList(options?: AnyObj) {
|
||||||
|
return request<ApcLimitStandard[]>(`/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
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue