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-router": "^12.0.1",
|
||||
"log4js": "^6.9.1",
|
||||
"mammoth": "^1.12.0",
|
||||
"moment": "^2.29.4",
|
||||
"node-schedule": "^2.1.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"source-map-support": "^0.5.21"
|
||||
},
|
||||
|
|
@ -1751,6 +1753,15 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
||||
|
|
@ -2556,6 +2567,12 @@
|
|||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
|
|
@ -2622,6 +2639,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
||||
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
|
|
@ -4157,6 +4183,17 @@
|
|||
"resolved": "https://registry.npmmirror.com/long-timeout/-/long-timeout-0.1.1.tgz",
|
||||
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
|
||||
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"duck": "^0.1.12",
|
||||
"option": "~0.2.1",
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz",
|
||||
|
|
@ -4191,6 +4228,39 @@
|
|||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
||||
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
"argparse": "~1.0.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"bluebird": "~3.4.0",
|
||||
"dingbat-to-unicode": "^1.0.1",
|
||||
"jszip": "^3.7.1",
|
||||
"lop": "^0.4.2",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"underscore": "^1.13.1",
|
||||
"xmlbuilder": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mammoth": "bin/mammoth"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
|
||||
|
|
@ -4368,6 +4438,12 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-schedule": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz",
|
||||
|
|
@ -4466,6 +4542,12 @@
|
|||
"resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz",
|
||||
"integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="
|
||||
},
|
||||
"node_modules/option": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -4595,6 +4677,28 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/pdf-parse/-/pdf-parse-1.1.1.tgz",
|
||||
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^3.1.0",
|
||||
"node-ensure": "^0.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
|
||||
|
|
@ -5159,6 +5263,12 @@
|
|||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
|
||||
|
|
@ -5510,6 +5620,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
|
||||
|
|
@ -5814,6 +5930,15 @@
|
|||
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@
|
|||
"koa-body": "^6.0.1",
|
||||
"koa-router": "^12.0.1",
|
||||
"log4js": "^6.9.1",
|
||||
"mammoth": "^1.12.0",
|
||||
"moment": "^2.29.4",
|
||||
"node-schedule": "^2.1.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"source-map-support": "^0.5.21"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -439,6 +439,22 @@ model PublicMaterial {
|
|||
spec String?
|
||||
eco_level String?
|
||||
health_level String?
|
||||
model_name String?
|
||||
sample_image String?
|
||||
reference_price String?
|
||||
application_case String?
|
||||
effect_image String?
|
||||
supplier String?
|
||||
contact_person String?
|
||||
contact_phone String?
|
||||
production_date String?
|
||||
batch_no String?
|
||||
sample_source String?
|
||||
client_unit String?
|
||||
test_date String?
|
||||
test_standard String?
|
||||
report_no String?
|
||||
test_org String?
|
||||
methanal Float?
|
||||
methanal_min_be Float?
|
||||
methanal_be_area Float?
|
||||
|
|
@ -486,6 +502,22 @@ model SelfMaterial {
|
|||
spec String?
|
||||
eco_level String?
|
||||
health_level String?
|
||||
model_name String?
|
||||
sample_image String?
|
||||
reference_price String?
|
||||
application_case String?
|
||||
effect_image String?
|
||||
supplier String?
|
||||
contact_person String?
|
||||
contact_phone String?
|
||||
production_date String?
|
||||
batch_no String?
|
||||
sample_source String?
|
||||
client_unit String?
|
||||
test_date String?
|
||||
test_standard String?
|
||||
report_no String?
|
||||
test_org String?
|
||||
methanal Float?
|
||||
methanal_min_be Float?
|
||||
methanal_be_area Float?
|
||||
|
|
@ -634,6 +666,16 @@ model ApcLimitStandard {
|
|||
|
||||
// ------ 论坛 ------
|
||||
|
||||
model SiteConfig {
|
||||
key String @id
|
||||
value String @db.Text
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("site_config")
|
||||
}
|
||||
|
||||
// ------ 论坛 ------
|
||||
|
||||
model ForumThread {
|
||||
id Int @id @default(autoincrement())
|
||||
board String // 板块(取值属于 FORUM_BOARDS)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import Router from 'koa-router'
|
||||
import authn from '../middlewares/authn'
|
||||
import { ADMIN_TYPE, AcctCert, AdminCert, AnyObj, CERT_TYPE, UserCert } from '../common/types'
|
||||
|
|
@ -52,20 +54,39 @@ function rankAndPaginate(materials: AnyObj[], query: AnyObj): AnyObj[] {
|
|||
return list
|
||||
}
|
||||
|
||||
// 环保建材展示(公开,免认证):E0 优先,不足补 E1,最多 8 条
|
||||
materialRouter.get('/eco', async ctx => {
|
||||
const take = 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 => {
|
||||
const picks = readEcoPicks()
|
||||
const images = readEcoImages()
|
||||
let list: any[]
|
||||
if (picks.length > 0) {
|
||||
const rows = await db.publicMaterial.findMany({
|
||||
where: { material_id: { in: picks }, display: 1 },
|
||||
select: selectFields
|
||||
})
|
||||
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
|
||||
})
|
||||
let list = e0
|
||||
list = e0
|
||||
if (list.length < take) {
|
||||
const e1 = await db.publicMaterial.findMany({
|
||||
where: { display: 1, eco_level: 'E1' },
|
||||
|
|
@ -75,7 +96,62 @@ materialRouter.get('/eco', async ctx => {
|
|||
})
|
||||
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',
|
||||
component: './home'
|
||||
},
|
||||
{
|
||||
name: '污染源识别',
|
||||
path: '/source',
|
||||
component: './source'
|
||||
},
|
||||
{
|
||||
name: '模板库',
|
||||
path: '/template',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { searchParams } from '@/common/utils'
|
|||
import MaterialModalForm from '@/components/material-modal-form'
|
||||
import PageCard from '@/components/page-card'
|
||||
import api from '@/services/api'
|
||||
import * as ExcelJS from 'exceljs'
|
||||
import {
|
||||
ActionType,
|
||||
LightFilter,
|
||||
|
|
@ -14,7 +15,10 @@ import {
|
|||
QueryFilter
|
||||
} from '@ant-design/pro-components'
|
||||
import { Button, Form, InputNumber, Modal, Popconfirm, Select, Switch, Tag, message } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
|
||||
const CAT_TAG_COLORS = ['blue', 'green', 'orange', 'purple', 'cyan', 'magenta', 'gold', 'volcano', 'geekblue', 'lime'] as const
|
||||
const CAT_HEX: Record<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 ImportMaterialsModal from '../import-file-modal/materials'
|
||||
|
||||
|
|
@ -46,6 +50,15 @@ const MaterialCard: React.FC<{
|
|||
(cert?.type === ADMIN_TYPE.SUPER_ADMIN ||
|
||||
(cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1))
|
||||
const pageDataRef = useRef<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 语义)。
|
||||
// 顶部「排序依据」Select 才是实际决定请求排序字段的来源(见下方 request),
|
||||
// 因此这里额外要求 sortKey === 'sort_order' 且未开启分组,避免在 Ypt/污染物排序或分组视图下
|
||||
|
|
@ -95,6 +108,51 @@ const MaterialCard: React.FC<{
|
|||
}
|
||||
// 取材料在全部数据中的全局序号(0-based),用于排序输入框显示
|
||||
const globalIndexOf = (material_id: string) => (pageDataRef.current ?? []).findIndex((m) => m.material_id === material_id)
|
||||
const exportExcel = async () => {
|
||||
const data = pageDataRef.current ?? []
|
||||
if (!data.length) {
|
||||
message.warning('当前无数据可导出')
|
||||
return
|
||||
}
|
||||
const wb = new ExcelJS.Workbook()
|
||||
const ws = wb.addWorksheet('材料库')
|
||||
ws.columns = [
|
||||
{ header: '材料ID', key: 'material_id', width: 18 },
|
||||
{ header: '材料名称', key: 'name', width: 22 },
|
||||
{ header: '材料类别', key: 'category', width: 22 },
|
||||
{ header: '材料品牌', key: 'brand', width: 18 },
|
||||
{ header: '材料厂家', key: 'factory', width: 18 },
|
||||
{ header: '材料规格', key: 'spec', width: 18 },
|
||||
{ header: '健康等级', key: 'health_level', width: 10 },
|
||||
{ header: '甲醛 min_be', key: 'methanal_min_be', width: 14 },
|
||||
{ header: '甲醛 be_area', key: 'methanal_be_area', width: 14 },
|
||||
{ header: '甲醛 be_roc', key: 'methanal_be_roc', width: 14 },
|
||||
{ header: 'TVOC min_be', key: 'tvoc_min_be', width: 14 },
|
||||
{ header: 'TVOC be_area', key: 'tvoc_be_area', width: 14 },
|
||||
{ header: 'TVOC be_roc', key: 'tvoc_be_roc', width: 14 },
|
||||
{ header: '苯 min_be', key: 'benzene_min_be', width: 14 },
|
||||
{ header: '苯 be_area', key: 'benzene_be_area', width: 14 },
|
||||
{ header: '苯 be_roc', key: 'benzene_be_roc', width: 14 },
|
||||
{ header: '甲苯 min_be', key: 'toluene_min_be', width: 14 },
|
||||
{ header: '甲苯 be_area', key: 'toluene_be_area', width: 14 },
|
||||
{ header: '甲苯 be_roc', key: 'toluene_be_roc', width: 14 },
|
||||
{ header: '二甲苯 min_be', key: 'p_xylene_min_be', width: 14 },
|
||||
{ header: '二甲苯 be_area', key: 'p_xylene_be_area', width: 14 },
|
||||
{ header: '二甲苯 be_roc', key: 'p_xylene_be_roc', width: 14 },
|
||||
]
|
||||
const headerRow = ws.getRow(1)
|
||||
headerRow.font = { bold: true }
|
||||
headerRow.alignment = { horizontal: 'center' }
|
||||
data.forEach((m) => ws.addRow(m))
|
||||
const buf = await wb.xlsx.writeBuffer()
|
||||
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `材料库导出_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const content = (
|
||||
<PageCard
|
||||
title={title}
|
||||
|
|
@ -111,7 +169,10 @@ const MaterialCard: React.FC<{
|
|||
]}
|
||||
onTabChange={(key) => setTab(key as DATA_TYPE)}
|
||||
extra={
|
||||
!modal && tab === DATA_TYPE.PRIVATE
|
||||
!modal
|
||||
? [
|
||||
...(isAdmin ? [<Button key="export-btn" onClick={exportExcel}>导出Excel</Button>] : []),
|
||||
...(tab === DATA_TYPE.PRIVATE
|
||||
? [
|
||||
<ImportMaterialsModal
|
||||
key="import-modal"
|
||||
|
|
@ -138,6 +199,8 @@ const MaterialCard: React.FC<{
|
|||
}}
|
||||
/>
|
||||
]
|
||||
: [])
|
||||
]
|
||||
: []
|
||||
}
|
||||
cardStyle={
|
||||
|
|
@ -160,9 +223,20 @@ const MaterialCard: React.FC<{
|
|||
fieldProps={{
|
||||
placeholder: '请选择大类/子类(可多选跨类)',
|
||||
multiple: true,
|
||||
showSearch: true,
|
||||
changeOnSelect: true,
|
||||
maxTagCount: 'responsive'
|
||||
maxTagCount: 'responsive',
|
||||
onChange: (val: any) => setCatSelections((val ?? []) as string[][]),
|
||||
tagRender: (props: any) => {
|
||||
const parts = Array.isArray(props.label) ? props.label : [String(props.label ?? '')]
|
||||
const labelText = parts.join(' / ')
|
||||
const first = String(parts[0])
|
||||
const color = catColorMap[first] ?? 'default'
|
||||
return (
|
||||
<Tag color={color} closable={props.closable} onClose={props.onClose} style={{ marginInlineEnd: 4 }}>
|
||||
{labelText}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
}}
|
||||
request={async () => {
|
||||
const cats = await api.config.getMtrlCatList()
|
||||
|
|
@ -170,22 +244,20 @@ const MaterialCard: React.FC<{
|
|||
value: item.first_category,
|
||||
label: item.first_category,
|
||||
children: item.second_categories.map((c) => ({
|
||||
value: c.second_category,
|
||||
value: `${item.first_category}/${c.second_category}`,
|
||||
label: c.second_category
|
||||
}))
|
||||
}))
|
||||
}}
|
||||
transform={(val) => ({ category: (val as string[][])?.map((v: string[]) => v.join('/')) ?? undefined })}
|
||||
transform={(val) => ({
|
||||
category: (val as string[][])?.map((v: string[]) =>
|
||||
v.length === 2 ? v[1] : v[0]
|
||||
) ?? undefined
|
||||
})}
|
||||
/>
|
||||
<ProFormText name="brand" label="材料品牌" />
|
||||
<ProFormText name="factory" 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
|
||||
name="health_level"
|
||||
label="健康等级"
|
||||
|
|
@ -252,6 +324,7 @@ const MaterialCard: React.FC<{
|
|||
onClick={() => {
|
||||
setFilterConditions(undefined)
|
||||
filterForm.resetFields()
|
||||
setCatSelections([])
|
||||
}}
|
||||
>
|
||||
重置
|
||||
|
|
@ -383,7 +456,17 @@ const MaterialCard: React.FC<{
|
|||
{
|
||||
title: '材料类别',
|
||||
dataIndex: 'category',
|
||||
align: 'center'
|
||||
align: 'center',
|
||||
render: (_, m) => {
|
||||
const first = (m.category ?? '').split('/')[0]
|
||||
const color = catColorMap[first]
|
||||
const hex = color ? (CAT_HEX[color] ?? undefined) : undefined
|
||||
return (
|
||||
<span style={hex ? { color: hex, fontWeight: 600 } : undefined}>
|
||||
{m.category}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '材料品牌',
|
||||
|
|
@ -400,13 +483,6 @@ const MaterialCard: React.FC<{
|
|||
dataIndex: 'spec',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '环保等级',
|
||||
dataIndex: 'eco_level',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
|
||||
},
|
||||
{
|
||||
title: '健康等级',
|
||||
dataIndex: 'health_level',
|
||||
|
|
@ -573,11 +649,12 @@ const MaterialCard: React.FC<{
|
|||
)
|
||||
)
|
||||
pageDataRef.current = data
|
||||
const uniqueFirstCats = [...new Set(data.map((m) => (m.category ?? '').split('/')[0]).filter(Boolean))]
|
||||
setDataFirstCats(uniqueFirstCats)
|
||||
if (sortByCategory) {
|
||||
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
|
||||
}
|
||||
if (gbtParam) {
|
||||
// 分组开时后端已按健康等级排序;为每档第一条打标,供「材料名称」列渲染分区小标题
|
||||
let last: string | null | undefined = undefined
|
||||
data.forEach((m) => {
|
||||
if (m.health_level !== last) {
|
||||
|
|
@ -586,6 +663,17 @@ const MaterialCard: React.FC<{
|
|||
}
|
||||
})
|
||||
}
|
||||
const catFilter = (otherConditions as AnyObj)?.category as string[] | undefined
|
||||
if (catFilter && catFilter.length > 1) {
|
||||
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
|
||||
let lastCat: string | undefined = undefined
|
||||
data.forEach((m) => {
|
||||
if (m.category !== lastCat) {
|
||||
;(m as Material & { __catHead?: string }).__catHead = m.category ?? '未分类'
|
||||
lastCat = m.category
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data
|
||||
|
|
@ -690,6 +778,7 @@ const MaterialCard: React.FC<{
|
|||
setTab(DATA_TYPE.PUBLIC)
|
||||
setFilterConditions(undefined)
|
||||
filterForm.resetFields()
|
||||
setCatSelections([])
|
||||
setSelected(undefined)
|
||||
}}
|
||||
onCancel={modalProps?.onCancel}
|
||||
|
|
|
|||
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 api from '@/services/api'
|
||||
import './article.css'
|
||||
|
||||
export default function ArticleDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { initialState } = useModel('@@initialState')
|
||||
const isAdmin = !!initialState?.cert?.is_admin
|
||||
const article = id ? getArticle(id) : undefined
|
||||
|
||||
if (!article) {
|
||||
const [customHtml, setCustomHtml] = useState<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 (
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#666' }}>文章不存在或已下线。</p>
|
||||
|
|
@ -16,8 +52,35 @@ export default function ArticleDetail() {
|
|||
|
||||
return (
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: '32px 16px 64px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}>← 返回首页</a>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
{isAdmin && id && (
|
||||
<Upload
|
||||
accept=".docx,.doc,.pdf"
|
||||
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>
|
||||
|
||||
{customHtml ? (
|
||||
<div
|
||||
className="article-body"
|
||||
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>
|
||||
|
|
@ -39,6 +102,8 @@ export default function ArticleDetail() {
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { history } from '@umijs/max'
|
||||
import { articles } from '@/common/articles'
|
||||
import api from '@/services/api'
|
||||
import type { LandingNewsItem } from '@/services/api/config'
|
||||
|
||||
export default function ArticleList() {
|
||||
const [items, setItems] = useState(articles.map(a => ({
|
||||
id: a.id, tag: a.tag, date: a.date, title: a.title, summary: a.summary, image: a.image
|
||||
})))
|
||||
|
||||
useEffect(() => {
|
||||
api.config.getLandingNews({ skipErrorHandler: true })
|
||||
.then(data => { if (data?.items?.length) setItems(data.items) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', padding: '32px 16px 64px' }}>
|
||||
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}>← 返回首页</a>
|
||||
<h1 style={{ fontSize: 24, margin: '16px 0 24px' }}>健康装修科普</h1>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{articles.map(a => (
|
||||
{items.map(a => (
|
||||
<article
|
||||
key={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' }}
|
||||
>
|
||||
<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={{ color: '#B43533', fontSize: 13 }}>{a.tag} · {a.date}</div>
|
||||
<h2 style={{ fontSize: 18, margin: '6px 0 8px' }}>{a.title}</h2>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { history } from '@umijs/max'
|
||||
import { history, useModel } from '@umijs/max'
|
||||
import { Modal, Input, Upload, message, Checkbox } from 'antd'
|
||||
import { EditOutlined, UploadOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { LOC_STOR_KEY } from '@/common/constants'
|
||||
import PhoneAuthModal from '@/components/phone-auth-modal'
|
||||
import api from '@/services/api'
|
||||
import type { LandingHero, LandingNewsItem } from '@/services/api/config'
|
||||
import type { EcoMaterial } from '@/services/api/material'
|
||||
import { articles } from '@/common/articles'
|
||||
import './landing.css'
|
||||
|
|
@ -20,17 +23,27 @@ const ChevronIcon = ({ dir }: { dir: 'left' | 'right' }) => (
|
|||
<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 = [
|
||||
{ n: '28+', t: '在管装修项目' },
|
||||
{ n: '2,400+', t: '累计预测次数' },
|
||||
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
|
||||
{ n: '2 部', t: '国标依据 · 18883 / 50325' }
|
||||
]
|
||||
const news = articles
|
||||
const cases = [
|
||||
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' },
|
||||
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07', chip: '甲醛达标' },
|
||||
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46', chip: 'TVOC达标' }
|
||||
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08' },
|
||||
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07' },
|
||||
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46' }
|
||||
]
|
||||
const steps = [
|
||||
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
|
||||
|
|
@ -39,32 +52,200 @@ const steps = [
|
|||
]
|
||||
|
||||
const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN)
|
||||
const goPro = () => history.push(hasToken() ? '/home' : '/login')
|
||||
const scrollTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
export default function Landing() {
|
||||
const { initialState } = useModel('@@initialState')
|
||||
const isAdmin = !!initialState?.cert?.is_admin
|
||||
|
||||
// --- 科普轮播 ---
|
||||
const [newsTitle, setNewsTitle] = useState(DEFAULT_NEWS_TITLE)
|
||||
const [newsItems, setNewsItems] = useState<LandingNewsItem[]>(defaultNewsItems)
|
||||
const [cur, setCur] = useState(0)
|
||||
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 start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % news.length), 5500) }
|
||||
const next = () => { setCur(c => (c + 1) % news.length); start() }
|
||||
const prev = () => { setCur(c => (c - 1 + news.length) % news.length); start() }
|
||||
useEffect(() => { start(); return stop }, [])
|
||||
const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % len), 5500) }
|
||||
const next = () => { setCur(c => (c + 1) % len); start() }
|
||||
const prev = () => { setCur(c => (c - 1 + len) % len); start() }
|
||||
useEffect(() => { start(); return stop }, [len])
|
||||
|
||||
const [authOpen, setAuthOpen] = useState(false)
|
||||
const [ecoList, setEcoList] = useState<EcoMaterial[]>([])
|
||||
const [caseImages, setCaseImages] = useState<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([]))
|
||||
api.config.getCaseImages({ skipErrorHandler: true }).then(setCaseImages).catch(() => {})
|
||||
}, [])
|
||||
const uploadCaseImage = async (index: number, file: File) => {
|
||||
try {
|
||||
const { url } = await api.config.uploadLandingImage(file)
|
||||
const updated = await api.config.setCaseImage(index, url)
|
||||
setCaseImages(updated)
|
||||
message.success('案例图片已更新')
|
||||
} catch { message.error('上传失败') }
|
||||
return false
|
||||
}
|
||||
// --- 环保建材编辑 ---
|
||||
const [ecoEditOpen, setEcoEditOpen] = useState(false)
|
||||
const [ecoCandidates, setEcoCandidates] = useState<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 = () => {
|
||||
if (hasToken()) history.push('/source')
|
||||
if (hasToken()) history.push('/home')
|
||||
else setAuthOpen(true)
|
||||
}
|
||||
const onAuthed = () => {
|
||||
setAuthOpen(false)
|
||||
history.push('/source')
|
||||
history.push('/home')
|
||||
}
|
||||
|
||||
// --- Hero 编辑 ---
|
||||
const [hero, setHero] = useState<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 (
|
||||
|
|
@ -80,9 +261,11 @@ export default function Landing() {
|
|||
<a onClick={() => scrollToId('cases')}>预测案例</a>
|
||||
<a onClick={() => scrollToId('eco')}>环保建材</a>
|
||||
<a onClick={() => history.push('/forum')}>健康装修大家谈</a>
|
||||
<a onClick={() => Modal.info({ title: '联系我们', content: '邮箱:zty18395607196@gmail.com', okText: '知道了' })}>联系我们</a>
|
||||
</nav>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
|
@ -90,10 +273,12 @@ export default function Landing() {
|
|||
<div className="wrap hero-grid">
|
||||
<div>
|
||||
<span className="eyebrow"><span className="pip" />对照 GB/T 18883-2022 与 GB 50325-2020 双国标</span>
|
||||
<h1>装修住得安心,<br />先把<em>污染源头</em>挡在门外</h1>
|
||||
<p className="hero-lead">输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。</p>
|
||||
<h1 className="hero-title-wrap">
|
||||
{renderTitle()}
|
||||
{isAdmin && <button className="edit-icon" title="编辑标题与内容" onClick={openEdit}><EditOutlined /></button>}
|
||||
</h1>
|
||||
<p className="hero-lead">{hero.description}</p>
|
||||
<div className="hero-cta">
|
||||
<a className="btn btn-primary btn-lg" onClick={openPredict}>装修空气质量预测<ArrowIcon /></a>
|
||||
<a className="btn btn-lg" onClick={() => scrollToId('cases')}>查看治理案例</a>
|
||||
</div>
|
||||
<div className="hero-tags">
|
||||
|
|
@ -103,7 +288,12 @@ export default function Landing() {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -118,15 +308,24 @@ export default function Landing() {
|
|||
<section className="sec" id="news">
|
||||
<div className="wrap">
|
||||
<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>
|
||||
</div>
|
||||
<div className="carousel">
|
||||
<div className="cviewport">
|
||||
<div className="ctrack" style={{ transform: `translateX(${-cur * 100}%)` }}>
|
||||
{news.map((n, i) => (
|
||||
<article className="cslide" key={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>
|
||||
{newsItems.map((n, i) => (
|
||||
<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>
|
||||
{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-date">{n.date}</div>
|
||||
<h3>{n.title}</h3>
|
||||
|
|
@ -141,7 +340,7 @@ export default function Landing() {
|
|||
<button className="cnav next" onClick={next}><ChevronIcon dir="right" /></button>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
|
@ -154,7 +353,15 @@ export default function Landing() {
|
|||
<div className="cases-grid">
|
||||
{cases.map((c, 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-type">{c.type}</div>
|
||||
<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.w2 + '%', background: 'var(--good)' }} /></div><span className="v" style={{ color: 'var(--good)' }}>{c.v2}</span></div>
|
||||
</div>
|
||||
<div className="case-foot"><span className="chip chip-good">{c.chip}</span><span className="lk">查看溯源<ArrowIcon small /></span></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -171,14 +377,13 @@ export default function Landing() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{ecoList.length > 0 && (
|
||||
{(ecoList.length > 0 || isAdmin) && (
|
||||
<section className="sec" id="eco">
|
||||
<div className="wrap">
|
||||
<div className="sec-head">
|
||||
<div>
|
||||
<div className="sec-tag">环保建材</div>
|
||||
<h2 className="sec-h">优选低释放材料,从源头减少污染</h2>
|
||||
<p className="sec-sub">以下为材料库中环保等级 E0/E1 的低释放建材,选材阶段即可降低甲醛与 TVOC 风险。</p>
|
||||
<h2 className="sec-h">优选低释放材料,从源头减少污染{isAdmin && <button className="edit-icon" title="编辑展示材料" onClick={openEcoEdit}><EditOutlined /></button>}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cases-grid">
|
||||
|
|
@ -188,10 +393,26 @@ export default function Landing() {
|
|||
<div className="case-type">{m.category} · {m.brand}</div>
|
||||
<h4>{m.name || m.brand || '环保建材'}</h4>
|
||||
<div className="case-meta">{m.factory || '—'}</div>
|
||||
<div className="case-foot">
|
||||
<span className="chip chip-good">环保 {m.eco_level}</span>
|
||||
{typeof m.methanal === 'number' && <span className="lk">甲醛 {m.methanal}</span>}
|
||||
</div>
|
||||
<div className="eco-img-wrap">
|
||||
{m.image ? (
|
||||
<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>
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -200,19 +421,6 @@ export default function Landing() {
|
|||
</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 }}>
|
||||
<div className="wrap">
|
||||
|
|
@ -222,8 +430,7 @@ export default function Landing() {
|
|||
<p>无需上门,输入材料即可估算。先估后测,把检测的钱花在刀刃上。</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, position: 'relative', zIndex: 2 }}>
|
||||
<a className="btn btn-primary btn-lg" onClick={openPredict}>免费开始预测</a>
|
||||
<a className="btn btn-lg" onClick={goPro}>查看专业看板</a>
|
||||
<a className="btn btn-primary btn-lg" onClick={openPredict}>现在就预测</a>
|
||||
</div>
|
||||
<div className="deco" />
|
||||
</div>
|
||||
|
|
@ -241,6 +448,150 @@ export default function Landing() {
|
|||
</footer>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
.lp .btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 6px 18px rgba(31,122,90,.22);}
|
||||
.lp .btn-primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);color:#fff;}
|
||||
.lp .btn-lg{padding:14px 26px;font-size:15px;border-radius:13px;}
|
||||
.lp .hero-title-wrap .edit-icon{vertical-align:middle;margin-left:8px;}
|
||||
|
||||
/* hero */
|
||||
.lp .hero{padding:64px 0 52px;}
|
||||
|
|
@ -48,7 +49,6 @@
|
|||
.lp .hero-tags{display:flex;gap:22px;margin-top:26px;flex-wrap:wrap;}
|
||||
.lp .hero-tag{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--sub);}
|
||||
.lp .hero-tag svg{width:17px;height:17px;color:var(--accent);}
|
||||
.lp .hero-visual{position:relative;}
|
||||
.lp .hero-img{width:100%;height:56vh;min-height:400px;max-height:640px;border-radius:22px;border:1px solid var(--border);}
|
||||
.lp .hero-badge{position:absolute;right:-14px;top:26px;background:var(--ink);color:#fff;border-radius:13px;padding:11px 15px;box-shadow:0 14px 34px rgba(0,0,0,.22);}
|
||||
.lp .hero-badge b{font-family:var(--serif);font-size:20px;display:block;}
|
||||
|
|
@ -94,7 +94,13 @@
|
|||
.lp .cases-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;}
|
||||
.lp .case{background:var(--panel);border:1px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:.18s;}
|
||||
.lp .case:hover{transform:translateY(-3px);box-shadow:0 16px 36px rgba(60,45,20,.12);}
|
||||
.lp .case .img-ph{width:100%;height:172px;border-radius:0;}
|
||||
.lp .case .img-ph{width:100%;height:172px;border-radius:0;position:relative;}
|
||||
.lp .img-ph-edit{position:absolute;bottom:8px;right:8px;background:rgba(255,255,255,.85);border:1px solid #d9d9d9;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:12px;color:var(--accent);display:inline-flex;align-items:center;gap:4px;}
|
||||
.lp .img-ph-edit:hover{background:#fff;border-color:var(--accent);}
|
||||
.lp .eco-img-wrap{padding:0 19px 16px;}
|
||||
.lp .eco-img{max-width:100%;max-height:120px;object-fit:contain;border-radius:10px;display:block;}
|
||||
.lp .eco-img-ph{width:100%;height:120px;border-radius:10px;background:var(--bg2);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--faint);font-size:13px;cursor:pointer;border:1.5px dashed var(--border2);transition:.18s;}
|
||||
.lp .eco-img-ph:hover{border-color:var(--accent);color:var(--accent);}
|
||||
.lp .case-b{padding:18px 19px 20px;}
|
||||
.lp .case-type{font-size:11.5px;font-weight:700;color:var(--accent);letter-spacing:.5px;}
|
||||
.lp .case h4{font-family:var(--serif);font-size:18px;font-weight:700;margin:7px 0 0;}
|
||||
|
|
@ -134,6 +140,21 @@
|
|||
.lp .footer-in{max-width:var(--maxw);margin:0 auto;padding:34px 28px;display:flex;align-items:center;justify-content:space-between;gap:20px;color:var(--faint);font-size:13px;}
|
||||
.lp .footer .brand-tt{font-size:15px;}
|
||||
|
||||
/* admin edit icons */
|
||||
.lp .edit-icon{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;border:1px solid var(--border2);background:var(--panel);color:var(--accent);cursor:pointer;font-size:14px;transition:.15s;box-shadow:0 2px 8px rgba(0,0,0,.08);vertical-align:middle;margin-left:12px;flex-shrink:0;}
|
||||
.lp .edit-icon:hover{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.lp .hero-title-wrap{display:block;}
|
||||
.lp .hero-img-edit{position:absolute;bottom:16px;right:16px;z-index:3;}
|
||||
.lp .hero-visual{position:relative;}
|
||||
|
||||
/* news edit modal */
|
||||
.news-edit-card{border:1px solid #e9e1d2;border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px;background:#fafaf6;}
|
||||
.news-edit-card-head{display:flex;align-items:center;justify-content:space-between;}
|
||||
.news-edit-row{display:flex;gap:12px;}
|
||||
.news-edit-label{font-size:13px;color:#6c6353;margin-bottom:4px;}
|
||||
.news-add-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px;border:1px dashed #ded3bf;border-radius:10px;background:transparent;color:#1f7a5a;font-size:14px;font-weight:600;cursor:pointer;transition:.15s;}
|
||||
.news-add-btn:hover{border-color:#1f7a5a;background:rgba(31,122,90,.06);}
|
||||
|
||||
@media(max-width:900px){
|
||||
.lp .hero-grid,.lp .cslide{grid-template-columns:1fr;}
|
||||
.lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,11 @@ export default function SourceTracing() {
|
|||
const [standards, setStandards] = useState<ApcLimitStandard[]>([])
|
||||
const [standardId, setStandardId] = useState('')
|
||||
const [roomId, setRoomId] = useState('demo')
|
||||
const [customName, setCustomName] = useState('')
|
||||
const [area, setArea] = useState(19.8)
|
||||
const [height, setHeight] = useState(3)
|
||||
const [temperature, setTemperature] = useState(22)
|
||||
const [humidity, setHumidity] = useState(45)
|
||||
const [temperature, setTemperature] = useState(23)
|
||||
const [humidity, setHumidity] = useState(50)
|
||||
const [ventilationRate, setVentilationRate] = useState(0.5)
|
||||
const [mats, setMats] = useState<MatRow[]>([])
|
||||
const [pol, setPol] = useState<Pollutant>('methanal')
|
||||
|
|
@ -104,7 +105,7 @@ export default function SourceTracing() {
|
|||
function buildPayload(ventRate = ventilationRate) {
|
||||
const selected = mats.filter(m => m.on && matMap[m.id])
|
||||
return {
|
||||
id: 'src', project_id: 'src', name: roomId,
|
||||
id: 'src', project_id: 'src', name: roomId === '_custom' ? (customName || '自定义空间') : roomId,
|
||||
height, area,
|
||||
env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate,
|
||||
methanal_c_lmt: standard?.methanal_c_lmt,
|
||||
|
|
@ -214,12 +215,36 @@ export default function SourceTracing() {
|
|||
|
||||
<div className="fld">
|
||||
<div className="fld-lab">选择样板间</div>
|
||||
<div className="rooms">
|
||||
{PRESET_ROOMS.map(r => (
|
||||
<div key={r.id} className={'room-b' + (roomId === r.id ? ' on' : '')} onClick={() => loadRoom(r.id)}>{r.name}</div>
|
||||
))}
|
||||
<div className="num">
|
||||
<select
|
||||
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>
|
||||
{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="row2">
|
||||
|
|
|
|||
|
|
@ -65,17 +65,6 @@
|
|||
.fld-lab .v { font-weight: 700; color: var(--ink); font-size: 13px; }
|
||||
|
||||
/* Room selector tabs */
|
||||
.rooms { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.room-b {
|
||||
font-size: 12.5px; font-weight: 600; padding: 7px 14px;
|
||||
border-radius: 6px; border: 1px solid var(--border2);
|
||||
background: var(--panel); color: var(--sub);
|
||||
cursor: pointer; transition: border-color .12s, color .12s, background .12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.room-b:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.room-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* Number input group */
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.row2 > div { min-width: 0; }
|
||||
|
|
|
|||
|
|
@ -291,7 +291,29 @@ export interface EcoMaterial {
|
|||
factory: string
|
||||
eco_level: string
|
||||
methanal: number
|
||||
image?: string | null
|
||||
}
|
||||
export async function getEcoMaterials(options?: AnyObj) {
|
||||
return request<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
|
||||
ypt?: number | null
|
||||
health_level?: 'A' | 'B' | 'C' | null
|
||||
model_name?: string
|
||||
sample_image?: string
|
||||
reference_price?: string
|
||||
application_case?: string
|
||||
effect_image?: string
|
||||
supplier?: string
|
||||
contact_person?: string
|
||||
contact_phone?: string
|
||||
production_date?: string
|
||||
batch_no?: string
|
||||
sample_source?: string
|
||||
client_unit?: string
|
||||
test_date?: string
|
||||
test_standard?: string
|
||||
report_no?: string
|
||||
test_org?: string
|
||||
collectors?: { user_id: string; project_id: string }[]
|
||||
methanal?: number
|
||||
methanal_min_be?: number
|
||||
|
|
|
|||
|
|
@ -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 { AnyObj, ECO_LEVEL, Material } from '@/common/types'
|
||||
import { AnyObj, Material } from '@/common/types'
|
||||
import { searchParams } from '@/common/utils'
|
||||
import api from '@/services/api'
|
||||
import {
|
||||
|
|
@ -119,12 +119,6 @@ const MaterialCard: React.FC<{
|
|||
<ProFormText name="brand" label="材料品牌" />
|
||||
<ProFormText name="factory" 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 ? (
|
||||
<ProFormText name="creator_name" label="创建用户" />
|
||||
) : (
|
||||
|
|
@ -229,7 +223,7 @@ const MaterialCard: React.FC<{
|
|||
search={false}
|
||||
options={false}
|
||||
actionRef={tableRef}
|
||||
scroll={modal ? { y: 'calc(100vh - 400px)' } : { y: 'calc(100vh - 360px)' }}
|
||||
scroll={modal ? { x: 3200, y: 'calc(100vh - 400px)' } : { x: 3200, y: 'calc(100vh - 360px)' }}
|
||||
rowSelection={
|
||||
modal
|
||||
? {
|
||||
|
|
@ -280,7 +274,8 @@ const MaterialCard: React.FC<{
|
|||
title: '材料ID',
|
||||
dataIndex: 'material_id',
|
||||
width: 120,
|
||||
align: 'center'
|
||||
align: 'center',
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '材料名称',
|
||||
|
|
@ -311,7 +306,7 @@ const MaterialCard: React.FC<{
|
|||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '材料厂家',
|
||||
title: '生产商',
|
||||
dataIndex: 'factory',
|
||||
align: 'center'
|
||||
},
|
||||
|
|
@ -320,13 +315,6 @@ const MaterialCard: React.FC<{
|
|||
dataIndex: 'spec',
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '环保等级',
|
||||
dataIndex: 'eco_level',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
|
||||
},
|
||||
{
|
||||
title: '健康等级',
|
||||
dataIndex: 'health_level',
|
||||
|
|
@ -344,6 +332,104 @@ const MaterialCard: React.FC<{
|
|||
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: '状态',
|
||||
dataIndex: 'display',
|
||||
|
|
@ -376,6 +462,7 @@ const MaterialCard: React.FC<{
|
|||
valueType: 'option',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, item) => (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<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