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:
zty 2026-07-28 21:16:38 -04:00
parent 57ff12d8d0
commit 8ee0784f20
21 changed files with 3437 additions and 180 deletions

View File

@ -22,8 +22,10 @@
"koa-body": "^6.0.1", "koa-body": "^6.0.1",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"mammoth": "^1.12.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pdf-parse": "^1.1.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"source-map-support": "^0.5.21" "source-map-support": "^0.5.21"
}, },
@ -1751,6 +1753,15 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
@ -2556,6 +2567,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dir-glob": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
@ -2622,6 +2639,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/duplexer2": { "node_modules/duplexer2": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz", "resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
@ -4157,6 +4183,17 @@
"resolved": "https://registry.npmmirror.com/long-timeout/-/long-timeout-0.1.1.tgz", "resolved": "https://registry.npmmirror.com/long-timeout/-/long-timeout-0.1.1.tgz",
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="
}, },
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "2.3.7", "version": "2.3.7",
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz", "resolved": "https://registry.npmmirror.com/loupe/-/loupe-2.3.7.tgz",
@ -4191,6 +4228,39 @@
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true "dev": true
}, },
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/mammoth/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
@ -4368,6 +4438,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmmirror.com/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-schedule": { "node_modules/node-schedule": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz",
@ -4466,6 +4542,12 @@
"resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz", "resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz",
"integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="
}, },
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
@ -4595,6 +4677,28 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/pdf-parse": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/pdf-parse/-/pdf-parse-1.1.1.tgz",
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
"license": "MIT",
"dependencies": {
"debug": "^3.1.0",
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
}
},
"node_modules/pdf-parse/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/performance-now": { "node_modules/performance-now": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
@ -5159,6 +5263,12 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stackback": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
@ -5510,6 +5620,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz",
@ -5814,6 +5930,15 @@
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}, },
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlchars": { "node_modules/xmlchars": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",

View File

@ -37,8 +37,10 @@
"koa-body": "^6.0.1", "koa-body": "^6.0.1",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"mammoth": "^1.12.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pdf-parse": "^1.1.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"source-map-support": "^0.5.21" "source-map-support": "^0.5.21"
}, },

View File

@ -439,6 +439,22 @@ model PublicMaterial {
spec String? spec String?
eco_level String? eco_level String?
health_level String? health_level String?
model_name String?
sample_image String?
reference_price String?
application_case String?
effect_image String?
supplier String?
contact_person String?
contact_phone String?
production_date String?
batch_no String?
sample_source String?
client_unit String?
test_date String?
test_standard String?
report_no String?
test_org String?
methanal Float? methanal Float?
methanal_min_be Float? methanal_min_be Float?
methanal_be_area Float? methanal_be_area Float?
@ -486,6 +502,22 @@ model SelfMaterial {
spec String? spec String?
eco_level String? eco_level String?
health_level String? health_level String?
model_name String?
sample_image String?
reference_price String?
application_case String?
effect_image String?
supplier String?
contact_person String?
contact_phone String?
production_date String?
batch_no String?
sample_source String?
client_unit String?
test_date String?
test_standard String?
report_no String?
test_org String?
methanal Float? methanal Float?
methanal_min_be Float? methanal_min_be Float?
methanal_be_area Float? methanal_be_area Float?
@ -634,6 +666,16 @@ model ApcLimitStandard {
// ------ 论坛 ------ // ------ 论坛 ------
model SiteConfig {
key String @id
value String @db.Text
updated_at DateTime @updatedAt
@@map("site_config")
}
// ------ 论坛 ------
model ForumThread { model ForumThread {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
board String // 板块(取值属于 FORUM_BOARDS board String // 板块(取值属于 FORUM_BOARDS

View File

@ -1,3 +1,5 @@
import fs from 'fs'
import path from 'path'
import Router from 'koa-router' import Router from 'koa-router'
import authn from '../middlewares/authn' import authn from '../middlewares/authn'
import { ADMIN_TYPE, AcctCert, AdminCert, AnyObj, CERT_TYPE, UserCert } from '../common/types' import { ADMIN_TYPE, AcctCert, AdminCert, AnyObj, CERT_TYPE, UserCert } from '../common/types'
@ -52,30 +54,104 @@ function rankAndPaginate(materials: AnyObj[], query: AnyObj): AnyObj[] {
return list return list
} }
// 环保建材展示公开免认证E0 优先,不足补 E1最多 8 条 const ECO_PICKS_FILE = path.join(__dirname, '../../uploads/eco-picks.json')
const readEcoPicks = (): string[] => {
try { return JSON.parse(fs.readFileSync(ECO_PICKS_FILE, 'utf8')) } catch { return [] }
}
const ECO_IMAGES_FILE = path.join(__dirname, '../../uploads/eco-images.json')
const readEcoImages = (): Record<string, string> => {
try { return JSON.parse(fs.readFileSync(ECO_IMAGES_FILE, 'utf8')) } catch { return {} }
}
const selectFields = {
material_id: true, name: true, category: true,
brand: true, factory: true, eco_level: true, methanal: true
}
materialRouter.get('/eco', async ctx => { materialRouter.get('/eco', async ctx => {
const take = 8 const picks = readEcoPicks()
const selectFields = { const images = readEcoImages()
material_id: true, name: true, category: true, let list: any[]
brand: true, factory: true, eco_level: true, methanal: true if (picks.length > 0) {
} const rows = await db.publicMaterial.findMany({
const e0 = await db.publicMaterial.findMany({ where: { material_id: { in: picks }, display: 1 },
where: { display: 1, eco_level: 'E0' },
orderBy: { material_id: 'desc' },
take,
select: selectFields
})
let list = e0
if (list.length < take) {
const e1 = await db.publicMaterial.findMany({
where: { display: 1, eco_level: 'E1' },
orderBy: { material_id: 'desc' },
take: take - list.length,
select: selectFields select: selectFields
}) })
list = list.concat(e1) rows.sort((a, b) => picks.indexOf(a.material_id) - picks.indexOf(b.material_id))
list = rows
} else {
const take = 8
const e0 = await db.publicMaterial.findMany({
where: { display: 1, eco_level: 'E0' },
orderBy: { material_id: 'desc' },
take,
select: selectFields
})
list = e0
if (list.length < take) {
const e1 = await db.publicMaterial.findMany({
where: { display: 1, eco_level: 'E1' },
orderBy: { material_id: 'desc' },
take: take - list.length,
select: selectFields
})
list = list.concat(e1)
}
} }
ctx.body = list ctx.body = list.map(m => ({ ...m, image: images[m.material_id] || null }))
})
materialRouter.get('/eco/picks', authn(CERT_TYPE.ADMIN), async ctx => {
ctx.body = readEcoPicks()
})
materialRouter.put(
'/eco/picks',
authn(CERT_TYPE.ADMIN),
authz<AdminCert>(cert => cert.type == ADMIN_TYPE.SUPER_ADMIN || (cert.type == ADMIN_TYPE.ADMIN && cert.authz_sys_cfg == 1)),
async ctx => {
const { ids } = ctx.request.body as { ids: string[] }
if (!Array.isArray(ids)) { ctx.throw(422, fail(ERR_CODE.INVALID_REQ_DATA, '需要 ids 数组')); return }
const dir = path.dirname(ECO_PICKS_FILE)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(ECO_PICKS_FILE, JSON.stringify(ids), 'utf8')
ctx.body = ids
}
)
materialRouter.put(
'/eco/image/:material_id',
authn(CERT_TYPE.ADMIN),
authz<AdminCert>(cert => cert.type == ADMIN_TYPE.SUPER_ADMIN || (cert.type == ADMIN_TYPE.ADMIN && cert.authz_sys_cfg == 1)),
async ctx => {
const mid = ctx.params.material_id
const { url } = ctx.request.body as { url: string }
if (!url) { ctx.throw(422, fail(ERR_CODE.INVALID_REQ_DATA, '缺少 url')); return }
const dir = path.dirname(ECO_IMAGES_FILE)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const data = readEcoImages()
data[mid] = url
fs.writeFileSync(ECO_IMAGES_FILE, JSON.stringify(data), 'utf8')
ctx.body = data
}
)
materialRouter.get('/eco/candidates', authn(CERT_TYPE.ADMIN), async ctx => {
const keyword = (ctx.query.keyword as string || '').trim()
const where: any = { display: 1 }
if (keyword) {
where.OR = [
{ name: { contains: keyword } },
{ brand: { contains: keyword } },
{ category: { contains: keyword } },
{ factory: { contains: keyword } }
]
}
ctx.body = await db.publicMaterial.findMany({
where,
select: selectFields,
orderBy: { material_id: 'desc' },
take: 50
})
}) })
// 查询材料 // 查询材料

View File

@ -78,11 +78,6 @@ export default defineConfig({
path: '/home', path: '/home',
component: './home' component: './home'
}, },
{
name: '污染源识别',
path: '/source',
component: './source'
},
{ {
name: '模板库', name: '模板库',
path: '/template', path: '/template',

View File

@ -4,6 +4,7 @@ import { searchParams } from '@/common/utils'
import MaterialModalForm from '@/components/material-modal-form' import MaterialModalForm from '@/components/material-modal-form'
import PageCard from '@/components/page-card' import PageCard from '@/components/page-card'
import api from '@/services/api' import api from '@/services/api'
import * as ExcelJS from 'exceljs'
import { import {
ActionType, ActionType,
LightFilter, LightFilter,
@ -14,7 +15,10 @@ import {
QueryFilter QueryFilter
} from '@ant-design/pro-components' } from '@ant-design/pro-components'
import { Button, Form, InputNumber, Modal, Popconfirm, Select, Switch, Tag, message } from 'antd' import { Button, Form, InputNumber, Modal, Popconfirm, Select, Switch, Tag, message } from 'antd'
import { useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
const CAT_TAG_COLORS = ['blue', 'green', 'orange', 'purple', 'cyan', 'magenta', 'gold', 'volcano', 'geekblue', 'lime'] as const
const CAT_HEX: Record<string, string> = { blue: '#1677ff', green: '#52c41a', orange: '#fa8c16', purple: '#722ed1', cyan: '#13c2c2', magenta: '#eb2f96', gold: '#faad14', volcano: '#fa541c', geekblue: '#2f54eb', lime: '#a0d911' }
import { useModel } from '@umijs/max' import { useModel } from '@umijs/max'
import ImportMaterialsModal from '../import-file-modal/materials' import ImportMaterialsModal from '../import-file-modal/materials'
@ -46,6 +50,15 @@ const MaterialCard: React.FC<{
(cert?.type === ADMIN_TYPE.SUPER_ADMIN || (cert?.type === ADMIN_TYPE.SUPER_ADMIN ||
(cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1)) (cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1))
const pageDataRef = useRef<Material[]>([]) const pageDataRef = useRef<Material[]>([])
const [catSelections, setCatSelections] = useState<string[][]>([])
const [dataFirstCats, setDataFirstCats] = useState<string[]>([])
const catColorMap = useMemo(() => {
const fromFilter = [...new Set(catSelections.map((path) => path[0]))]
const firsts = fromFilter.length > 0 ? fromFilter : dataFirstCats
const map: Record<string, string> = {}
firsts.forEach((f, i) => { map[f] = CAT_TAG_COLORS[i % CAT_TAG_COLORS.length] })
return map
}, [catSelections, dataFirstCats])
// 仅在「手动排序」视图下启用排序控件(避免在按其他字段排序时破坏 sort_order 语义)。 // 仅在「手动排序」视图下启用排序控件(避免在按其他字段排序时破坏 sort_order 语义)。
// 顶部「排序依据」Select 才是实际决定请求排序字段的来源(见下方 request // 顶部「排序依据」Select 才是实际决定请求排序字段的来源(见下方 request
// 因此这里额外要求 sortKey === 'sort_order' 且未开启分组,避免在 Ypt/污染物排序或分组视图下 // 因此这里额外要求 sortKey === 'sort_order' 且未开启分组,避免在 Ypt/污染物排序或分组视图下
@ -95,6 +108,51 @@ const MaterialCard: React.FC<{
} }
// 取材料在全部数据中的全局序号(0-based),用于排序输入框显示 // 取材料在全部数据中的全局序号(0-based),用于排序输入框显示
const globalIndexOf = (material_id: string) => (pageDataRef.current ?? []).findIndex((m) => m.material_id === material_id) const globalIndexOf = (material_id: string) => (pageDataRef.current ?? []).findIndex((m) => m.material_id === material_id)
const exportExcel = async () => {
const data = pageDataRef.current ?? []
if (!data.length) {
message.warning('当前无数据可导出')
return
}
const wb = new ExcelJS.Workbook()
const ws = wb.addWorksheet('材料库')
ws.columns = [
{ header: '材料ID', key: 'material_id', width: 18 },
{ header: '材料名称', key: 'name', width: 22 },
{ header: '材料类别', key: 'category', width: 22 },
{ header: '材料品牌', key: 'brand', width: 18 },
{ header: '材料厂家', key: 'factory', width: 18 },
{ header: '材料规格', key: 'spec', width: 18 },
{ header: '健康等级', key: 'health_level', width: 10 },
{ header: '甲醛 min_be', key: 'methanal_min_be', width: 14 },
{ header: '甲醛 be_area', key: 'methanal_be_area', width: 14 },
{ header: '甲醛 be_roc', key: 'methanal_be_roc', width: 14 },
{ header: 'TVOC min_be', key: 'tvoc_min_be', width: 14 },
{ header: 'TVOC be_area', key: 'tvoc_be_area', width: 14 },
{ header: 'TVOC be_roc', key: 'tvoc_be_roc', width: 14 },
{ header: '苯 min_be', key: 'benzene_min_be', width: 14 },
{ header: '苯 be_area', key: 'benzene_be_area', width: 14 },
{ header: '苯 be_roc', key: 'benzene_be_roc', width: 14 },
{ header: '甲苯 min_be', key: 'toluene_min_be', width: 14 },
{ header: '甲苯 be_area', key: 'toluene_be_area', width: 14 },
{ header: '甲苯 be_roc', key: 'toluene_be_roc', width: 14 },
{ header: '二甲苯 min_be', key: 'p_xylene_min_be', width: 14 },
{ header: '二甲苯 be_area', key: 'p_xylene_be_area', width: 14 },
{ header: '二甲苯 be_roc', key: 'p_xylene_be_roc', width: 14 },
]
const headerRow = ws.getRow(1)
headerRow.font = { bold: true }
headerRow.alignment = { horizontal: 'center' }
data.forEach((m) => ws.addRow(m))
const buf = await wb.xlsx.writeBuffer()
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `材料库导出_${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
}
const content = ( const content = (
<PageCard <PageCard
title={title} title={title}
@ -111,32 +169,37 @@ const MaterialCard: React.FC<{
]} ]}
onTabChange={(key) => setTab(key as DATA_TYPE)} onTabChange={(key) => setTab(key as DATA_TYPE)}
extra={ extra={
!modal && tab === DATA_TYPE.PRIVATE !modal
? [ ? [
<ImportMaterialsModal ...(isAdmin ? [<Button key="export-btn" onClick={exportExcel}>Excel</Button>] : []),
key="import-modal" ...(tab === DATA_TYPE.PRIVATE
title="导入自建材料" ? [
trigger={<Button></Button>} <ImportMaterialsModal
onSuccess={async () => { key="import-modal"
await tableRef?.current?.reload?.() title="导入自建材料"
}} trigger={<Button></Button>}
/>, onSuccess={async () => {
<MaterialModalForm await tableRef?.current?.reload?.()
key="create-modal-form" }}
title="新建自建材料" />,
displayDetails <MaterialModalForm
trigger={<Button type="primary"></Button>} key="create-modal-form"
onFinish={async (data) => { title="新建自建材料"
try { displayDetails
await api.material.createSelf(data) trigger={<Button type="primary"></Button>}
message.success('新建成功') onFinish={async (data) => {
await tableRef?.current?.reload?.() try {
return true await api.material.createSelf(data)
} catch (err) { message.success('新建成功')
return false await tableRef?.current?.reload?.()
} return true
}} } catch (err) {
/> return false
}
}}
/>
]
: [])
] ]
: [] : []
} }
@ -160,9 +223,20 @@ const MaterialCard: React.FC<{
fieldProps={{ fieldProps={{
placeholder: '请选择大类/子类(可多选跨类)', placeholder: '请选择大类/子类(可多选跨类)',
multiple: true, multiple: true,
showSearch: true,
changeOnSelect: true, changeOnSelect: true,
maxTagCount: 'responsive' maxTagCount: 'responsive',
onChange: (val: any) => setCatSelections((val ?? []) as string[][]),
tagRender: (props: any) => {
const parts = Array.isArray(props.label) ? props.label : [String(props.label ?? '')]
const labelText = parts.join(' / ')
const first = String(parts[0])
const color = catColorMap[first] ?? 'default'
return (
<Tag color={color} closable={props.closable} onClose={props.onClose} style={{ marginInlineEnd: 4 }}>
{labelText}
</Tag>
)
}
}} }}
request={async () => { request={async () => {
const cats = await api.config.getMtrlCatList() const cats = await api.config.getMtrlCatList()
@ -170,22 +244,20 @@ const MaterialCard: React.FC<{
value: item.first_category, value: item.first_category,
label: item.first_category, label: item.first_category,
children: item.second_categories.map((c) => ({ children: item.second_categories.map((c) => ({
value: c.second_category, value: `${item.first_category}/${c.second_category}`,
label: c.second_category label: c.second_category
})) }))
})) }))
}} }}
transform={(val) => ({ category: (val as string[][])?.map((v: string[]) => v.join('/')) ?? undefined })} transform={(val) => ({
category: (val as string[][])?.map((v: string[]) =>
v.length === 2 ? v[1] : v[0]
) ?? undefined
})}
/> />
<ProFormText name="brand" label="材料品牌" /> <ProFormText name="brand" label="材料品牌" />
<ProFormText name="factory" label="材料厂家" /> <ProFormText name="factory" label="材料厂家" />
<ProFormText name="spec" label="材料规格" /> <ProFormText name="spec" label="材料规格" />
<ProFormSelect
name="eco_level"
label="环保级别"
options={[{ value: ECO_LEVEL.E0 }, { value: ECO_LEVEL.E1 }, { value: ECO_LEVEL.E2 }]}
mode="multiple"
/>
<ProFormSelect <ProFormSelect
name="health_level" name="health_level"
label="健康等级" label="健康等级"
@ -252,6 +324,7 @@ const MaterialCard: React.FC<{
onClick={() => { onClick={() => {
setFilterConditions(undefined) setFilterConditions(undefined)
filterForm.resetFields() filterForm.resetFields()
setCatSelections([])
}} }}
> >
@ -383,7 +456,17 @@ const MaterialCard: React.FC<{
{ {
title: '材料类别', title: '材料类别',
dataIndex: 'category', dataIndex: 'category',
align: 'center' align: 'center',
render: (_, m) => {
const first = (m.category ?? '').split('/')[0]
const color = catColorMap[first]
const hex = color ? (CAT_HEX[color] ?? undefined) : undefined
return (
<span style={hex ? { color: hex, fontWeight: 600 } : undefined}>
{m.category}
</span>
)
}
}, },
{ {
title: '材料品牌', title: '材料品牌',
@ -400,13 +483,6 @@ const MaterialCard: React.FC<{
dataIndex: 'spec', dataIndex: 'spec',
align: 'center' align: 'center'
}, },
{
title: '环保等级',
dataIndex: 'eco_level',
align: 'center',
width: 90,
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
},
{ {
title: '健康等级', title: '健康等级',
dataIndex: 'health_level', dataIndex: 'health_level',
@ -573,11 +649,12 @@ const MaterialCard: React.FC<{
) )
) )
pageDataRef.current = data pageDataRef.current = data
const uniqueFirstCats = [...new Set(data.map((m) => (m.category ?? '').split('/')[0]).filter(Boolean))]
setDataFirstCats(uniqueFirstCats)
if (sortByCategory) { if (sortByCategory) {
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? '')) data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
} }
if (gbtParam) { if (gbtParam) {
// 分组开时后端已按健康等级排序;为每档第一条打标,供「材料名称」列渲染分区小标题
let last: string | null | undefined = undefined let last: string | null | undefined = undefined
data.forEach((m) => { data.forEach((m) => {
if (m.health_level !== last) { if (m.health_level !== last) {
@ -586,6 +663,17 @@ const MaterialCard: React.FC<{
} }
}) })
} }
const catFilter = (otherConditions as AnyObj)?.category as string[] | undefined
if (catFilter && catFilter.length > 1) {
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
let lastCat: string | undefined = undefined
data.forEach((m) => {
if (m.category !== lastCat) {
;(m as Material & { __catHead?: string }).__catHead = m.category ?? '未分类'
lastCat = m.category
}
})
}
return { return {
success: true, success: true,
data data
@ -690,6 +778,7 @@ const MaterialCard: React.FC<{
setTab(DATA_TYPE.PUBLIC) setTab(DATA_TYPE.PUBLIC)
setFilterConditions(undefined) setFilterConditions(undefined)
filterForm.resetFields() filterForm.resetFields()
setCatSelections([])
setSelected(undefined) setSelected(undefined)
}} }}
onCancel={modalProps?.onCancel} onCancel={modalProps?.onCancel}

View File

@ -1,11 +1,47 @@
import { history, useParams } from '@umijs/max' import { useEffect, useState } from 'react'
import { history, useModel, useParams } from '@umijs/max'
import { Upload, message } from 'antd'
import { UploadOutlined } from '@ant-design/icons'
import { getArticle } from '@/common/articles' import { getArticle } from '@/common/articles'
import api from '@/services/api'
import './article.css'
export default function ArticleDetail() { export default function ArticleDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const { initialState } = useModel('@@initialState')
const isAdmin = !!initialState?.cert?.is_admin
const article = id ? getArticle(id) : undefined const article = id ? getArticle(id) : undefined
if (!article) { const [customHtml, setCustomHtml] = useState<string | null>(null)
const [customTitle, setCustomTitle] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) { setLoading(false); return }
api.config.getArticleContent(id, { skipErrorHandler: true })
.then(data => { if (data?.html) setCustomHtml(data.html) })
.catch(() => {})
.finally(() => setLoading(false))
}, [id])
const handleUpload = async (file: File) => {
if (!id) return false
try {
const res = await api.config.uploadArticleDoc(id, file)
setCustomHtml(res.html)
if (res.title) setCustomTitle(res.title)
message.success('文章内容已更新,页面已全量替换')
} catch {
message.error('上传失败,请确认是 .docx / .doc / .pdf 格式')
}
return false
}
if (loading) {
return <div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center', color: '#888' }}>...</div>
}
if (!article && !customHtml) {
return ( return (
<div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center' }}> <div style={{ maxWidth: 760, margin: '0 auto', padding: '80px 16px', textAlign: 'center' }}>
<p style={{ color: '#666' }}>线</p> <p style={{ color: '#666' }}>线</p>
@ -16,29 +52,58 @@ export default function ArticleDetail() {
return ( return (
<div style={{ maxWidth: 760, margin: '0 auto', padding: '32px 16px 64px' }}> <div style={{ maxWidth: 760, margin: '0 auto', padding: '32px 16px 64px' }}>
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}> </a> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ marginTop: 20 }}> <a onClick={() => history.push('/landing')} style={{ color: '#888' }}> </a>
<div style={{ color: '#B43533', fontSize: 13 }}>{article.tag} · {article.date}</div> {isAdmin && id && (
<h1 style={{ fontSize: 26, margin: '10px 0 4px', lineHeight: 1.35 }}>{article.title}</h1> <Upload
<p style={{ color: '#666', fontSize: 15, margin: '8px 0 20px' }}>{article.summary}</p> accept=".docx,.doc,.pdf"
<img src={article.image} alt={article.title} style={{ width: '100%', height: 320, objectFit: 'cover', borderRadius: 12, display: 'block', marginBottom: 8 }} /> showUploadList={false}
beforeUpload={handleUpload}
>
<button style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8, border: '1px solid #d9d9d9',
background: '#fff', cursor: 'pointer', fontSize: 13, color: '#1f7a5a',
fontWeight: 600
}}>
<UploadOutlined />
</button>
</Upload>
)}
</div> </div>
{article.body.map((sec, i) => (
<section key={i} style={{ marginBottom: 20 }}> {customHtml ? (
{sec.heading && <h2 style={{ fontSize: 18, margin: '18px 0 8px' }}>{sec.heading}</h2>} <div
{sec.paragraphs.map((p, j) => ( className="article-body"
<p key={j} style={{ lineHeight: 1.9, color: '#333', margin: '0 0 10px' }}>{p}</p> dangerouslySetInnerHTML={{ __html: customHtml }}
style={{ lineHeight: 1.9, color: '#333', marginTop: 20 }}
/>
) : article ? (
<>
<div>
<div style={{ color: '#B43533', fontSize: 13 }}>{article.tag} · {article.date}</div>
<h1 style={{ fontSize: 26, margin: '10px 0 4px', lineHeight: 1.35 }}>{article.title}</h1>
<p style={{ color: '#666', fontSize: 15, margin: '8px 0 20px' }}>{article.summary}</p>
<img src={article.image} alt={article.title} style={{ width: '100%', height: 320, objectFit: 'cover', borderRadius: 12, display: 'block', marginBottom: 8 }} />
</div>
{article.body.map((sec, i) => (
<section key={i} style={{ marginBottom: 20 }}>
{sec.heading && <h2 style={{ fontSize: 18, margin: '18px 0 8px' }}>{sec.heading}</h2>}
{sec.paragraphs.map((p, j) => (
<p key={j} style={{ lineHeight: 1.9, color: '#333', margin: '0 0 10px' }}>{p}</p>
))}
</section>
))} ))}
</section> {article.sources && article.sources.length > 0 && (
))} <div style={{ marginTop: 28, paddingTop: 16, borderTop: '1px solid #eee', color: '#999', fontSize: 13 }}>
{article.sources && article.sources.length > 0 && ( <div style={{ marginBottom: 6 }}></div>
<div style={{ marginTop: 28, paddingTop: 16, borderTop: '1px solid #eee', color: '#999', fontSize: 13 }}> <ul style={{ margin: 0, paddingLeft: 18 }}>
<div style={{ marginBottom: 6 }}></div> {article.sources.map((s, i) => <li key={i}>{s}</li>)}
<ul style={{ margin: 0, paddingLeft: 18 }}> </ul>
{article.sources.map((s, i) => <li key={i}>{s}</li>)} </div>
</ul> )}
</div> </>
)} ) : null}
</div> </div>
) )
} }

View File

@ -1,19 +1,32 @@
import { useEffect, useState } from 'react'
import { history } from '@umijs/max' import { history } from '@umijs/max'
import { articles } from '@/common/articles' import { articles } from '@/common/articles'
import api from '@/services/api'
import type { LandingNewsItem } from '@/services/api/config'
export default function ArticleList() { export default function ArticleList() {
const [items, setItems] = useState(articles.map(a => ({
id: a.id, tag: a.tag, date: a.date, title: a.title, summary: a.summary, image: a.image
})))
useEffect(() => {
api.config.getLandingNews({ skipErrorHandler: true })
.then(data => { if (data?.items?.length) setItems(data.items) })
.catch(() => {})
}, [])
return ( return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: '32px 16px 64px' }}> <div style={{ maxWidth: 820, margin: '0 auto', padding: '32px 16px 64px' }}>
<a onClick={() => history.push('/landing')} style={{ color: '#888' }}> </a> <a onClick={() => history.push('/landing')} style={{ color: '#888' }}> </a>
<h1 style={{ fontSize: 24, margin: '16px 0 24px' }}></h1> <h1 style={{ fontSize: 24, margin: '16px 0 24px' }}></h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{articles.map(a => ( {items.map(a => (
<article <article
key={a.id} key={a.id}
onClick={() => history.push('/article/' + a.id)} onClick={() => history.push('/article/' + a.id)}
style={{ cursor: 'pointer', display: 'flex', gap: 16, padding: 16, border: '1px solid #eee', borderRadius: 8, transition: 'box-shadow .2s' }} style={{ cursor: 'pointer', display: 'flex', gap: 16, padding: 16, border: '1px solid #eee', borderRadius: 8, transition: 'box-shadow .2s' }}
> >
<img src={a.image} alt={a.title} style={{ width: 160, height: 110, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }} /> {a.image && <img src={a.image} alt={a.title} style={{ width: 160, height: 110, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }} />}
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ color: '#B43533', fontSize: 13 }}>{a.tag} · {a.date}</div> <div style={{ color: '#B43533', fontSize: 13 }}>{a.tag} · {a.date}</div>
<h2 style={{ fontSize: 18, margin: '6px 0 8px' }}>{a.title}</h2> <h2 style={{ fontSize: 18, margin: '6px 0 8px' }}>{a.title}</h2>

View File

@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { history } from '@umijs/max' import { history, useModel } from '@umijs/max'
import { Modal, Input, Upload, message, Checkbox } from 'antd'
import { EditOutlined, UploadOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons'
import { LOC_STOR_KEY } from '@/common/constants' import { LOC_STOR_KEY } from '@/common/constants'
import PhoneAuthModal from '@/components/phone-auth-modal' import PhoneAuthModal from '@/components/phone-auth-modal'
import api from '@/services/api' import api from '@/services/api'
import type { LandingHero, LandingNewsItem } from '@/services/api/config'
import type { EcoMaterial } from '@/services/api/material' import type { EcoMaterial } from '@/services/api/material'
import { articles } from '@/common/articles' import { articles } from '@/common/articles'
import './landing.css' import './landing.css'
@ -20,17 +23,27 @@ const ChevronIcon = ({ dir }: { dir: 'left' | 'right' }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg>
) )
const DEFAULT_HERO: LandingHero = {
title: '装修住得安心,\n先把污染源头挡在门外',
description: '输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。',
image: '/iapip-web/images/hero-interior.jpg'
}
const DEFAULT_NEWS_TITLE = '读懂装修污染,从知道哪些是健康材料开始'
const defaultNewsItems: LandingNewsItem[] = articles.map(a => ({
id: a.id, tag: a.tag, date: a.date, title: a.title, summary: a.summary, image: a.image
}))
const stats = [ const stats = [
{ n: '28+', t: '在管装修项目' }, { n: '28+', t: '在管装修项目' },
{ n: '2,400+', t: '累计预测次数' }, { n: '2,400+', t: '累计预测次数' },
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' }, { n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
{ n: '2 部', t: '国标依据 · 18883 / 50325' } { n: '2 部', t: '国标依据 · 18883 / 50325' }
] ]
const news = articles
const cases = [ const cases = [
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' }, { type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08' },
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07', chip: '甲醛达标' }, { type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07' },
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46', chip: 'TVOC达标' } { type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46' }
] ]
const steps = [ const steps = [
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' }, { h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
@ -39,32 +52,200 @@ const steps = [
] ]
const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN) const hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN)
const goPro = () => history.push(hasToken() ? '/home' : '/login')
const scrollTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }) const scrollTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }) const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
export default function Landing() { export default function Landing() {
const { initialState } = useModel('@@initialState')
const isAdmin = !!initialState?.cert?.is_admin
// --- 科普轮播 ---
const [newsTitle, setNewsTitle] = useState(DEFAULT_NEWS_TITLE)
const [newsItems, setNewsItems] = useState<LandingNewsItem[]>(defaultNewsItems)
const [cur, setCur] = useState(0) const [cur, setCur] = useState(0)
const timer = useRef<ReturnType<typeof setInterval> | null>(null) const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const go = (n: number) => setCur((((n % news.length) + news.length) % news.length)) const len = newsItems.length || 1
const go = (n: number) => setCur((((n % len) + len) % len))
const stop = () => { if (timer.current) { clearInterval(timer.current); timer.current = null } } const stop = () => { if (timer.current) { clearInterval(timer.current); timer.current = null } }
const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % news.length), 5500) } const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % len), 5500) }
const next = () => { setCur(c => (c + 1) % news.length); start() } const next = () => { setCur(c => (c + 1) % len); start() }
const prev = () => { setCur(c => (c - 1 + news.length) % news.length); start() } const prev = () => { setCur(c => (c - 1 + len) % len); start() }
useEffect(() => { start(); return stop }, []) useEffect(() => { start(); return stop }, [len])
const [authOpen, setAuthOpen] = useState(false) const [authOpen, setAuthOpen] = useState(false)
const [ecoList, setEcoList] = useState<EcoMaterial[]>([]) const [ecoList, setEcoList] = useState<EcoMaterial[]>([])
const [caseImages, setCaseImages] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([])) api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([]))
api.config.getCaseImages({ skipErrorHandler: true }).then(setCaseImages).catch(() => {})
}, []) }, [])
const uploadCaseImage = async (index: number, file: File) => {
try {
const { url } = await api.config.uploadLandingImage(file)
const updated = await api.config.setCaseImage(index, url)
setCaseImages(updated)
message.success('案例图片已更新')
} catch { message.error('上传失败') }
return false
}
// --- 环保建材编辑 ---
const [ecoEditOpen, setEcoEditOpen] = useState(false)
const [ecoCandidates, setEcoCandidates] = useState<EcoMaterial[]>([])
const [ecoSelected, setEcoSelected] = useState<string[]>([])
const [ecoKeyword, setEcoKeyword] = useState('')
const openEcoEdit = async () => {
setEcoEditOpen(true)
setEcoSelected(ecoList.map(m => m.material_id))
try { setEcoCandidates(await api.material.getEcoCandidates('')) } catch {}
}
const searchCandidates = async (kw: string) => {
setEcoKeyword(kw)
try { setEcoCandidates(await api.material.getEcoCandidates(kw)) } catch {}
}
const toggleEco = (id: string) => {
setEcoSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
const saveEcoPicks = async () => {
try {
await api.material.setEcoPicks(ecoSelected)
const updated = await api.material.getEcoMaterials()
setEcoList(updated)
setEcoEditOpen(false)
message.success('环保建材已更新')
} catch { message.error('保存失败') }
}
const uploadEcoImage = async (materialId: string, file: File) => {
try {
const { url } = await api.config.uploadLandingImage(file)
await api.material.setEcoImage(materialId, url)
setEcoList(prev => prev.map(m => m.material_id === materialId ? { ...m, image: url } : m))
message.success('材料图片已更新')
} catch { message.error('上传失败') }
}
const openPredict = () => { const openPredict = () => {
if (hasToken()) history.push('/source') if (hasToken()) history.push('/home')
else setAuthOpen(true) else setAuthOpen(true)
} }
const onAuthed = () => { const onAuthed = () => {
setAuthOpen(false) setAuthOpen(false)
history.push('/source') history.push('/home')
}
// --- Hero 编辑 ---
const [hero, setHero] = useState<LandingHero>(DEFAULT_HERO)
const [editOpen, setEditOpen] = useState(false)
const [editForm, setEditForm] = useState<LandingHero>({})
const [saving, setSaving] = useState(false)
useEffect(() => {
api.config.getLandingConfig({ skipErrorHandler: true })
.then(data => { if (data) setHero({ ...DEFAULT_HERO, ...data }) })
.catch(() => {})
api.config.getLandingNews({ skipErrorHandler: true })
.then(data => {
if (data) {
if (data.sectionTitle) setNewsTitle(data.sectionTitle)
if (data.items?.length) setNewsItems(data.items)
}
})
.catch(() => {})
}, [])
const openEdit = () => {
setEditForm({ title: hero.title, description: hero.description })
setEditOpen(true)
}
const saveEdit = async () => {
setSaving(true)
try {
await api.config.updateLandingConfig(editForm)
setHero(prev => ({ ...prev, ...editForm }))
setEditOpen(false)
message.success('已保存')
} catch {
message.error('保存失败')
} finally {
setSaving(false)
}
}
const handleImageUpload = async (file: File) => {
try {
const res = await api.config.uploadLandingImage(file)
await api.config.updateLandingConfig({ image: res.url })
setHero(prev => ({ ...prev, image: res.url }))
message.success('图片已更新')
} catch {
message.error('上传失败')
}
return false
}
const renderTitle = () => {
const parts = (hero.title || '').split('\n')
return parts.map((line, i) => {
const segs = line.split(/(?=污染源头)|(?<=污染源头)/)
return (
<span key={i}>
{i > 0 && <br />}
{segs.map((s, j) => s === '污染源头' ? <em key={j}>{s}</em> : <span key={j}>{s}</span>)}
</span>
)
})
}
// --- 科普轮播编辑 ---
const [newsEditOpen, setNewsEditOpen] = useState(false)
const [newsEditTitle, setNewsEditTitle] = useState('')
const [newsEditItems, setNewsEditItems] = useState<LandingNewsItem[]>([])
const [newsSaving, setNewsSaving] = useState(false)
const openNewsEdit = () => {
setNewsEditTitle(newsTitle)
setNewsEditItems(newsItems.map(it => ({ ...it })))
setNewsEditOpen(true)
}
const updateNewsItem = (idx: number, field: keyof LandingNewsItem, value: string) => {
setNewsEditItems(items => items.map((it, i) => i === idx ? { ...it, [field]: value } : it))
}
const addNewsItem = () => {
setNewsEditItems(items => [...items, { id: `article-${Date.now()}`, title: '', tag: '', date: '', summary: '', image: '' }])
}
const removeNewsItem = (idx: number) => {
setNewsEditItems(items => items.filter((_, i) => i !== idx))
}
const handleNewsImageUpload = async (idx: number, file: File) => {
try {
const res = await api.config.uploadLandingImage(file)
updateNewsItem(idx, 'image', res.url)
message.success('图片已上传')
} catch {
message.error('上传失败')
}
return false
}
const saveNews = async () => {
const valid = newsEditItems.every(it => it.title.trim())
if (!valid) { message.warning('每篇文章都需要填写标题'); return }
setNewsSaving(true)
try {
await api.config.updateLandingNews({ sectionTitle: newsEditTitle, items: newsEditItems })
setNewsTitle(newsEditTitle)
setNewsItems(newsEditItems)
setCur(0)
setNewsEditOpen(false)
message.success('已保存')
} catch {
message.error('保存失败')
} finally {
setNewsSaving(false)
}
} }
return ( return (
@ -80,9 +261,11 @@ export default function Landing() {
<a onClick={() => scrollToId('cases')}></a> <a onClick={() => scrollToId('cases')}></a>
<a onClick={() => scrollToId('eco')}></a> <a onClick={() => scrollToId('eco')}></a>
<a onClick={() => history.push('/forum')}></a> <a onClick={() => history.push('/forum')}></a>
<a onClick={() => Modal.info({ title: '联系我们', content: '邮箱zty18395607196@gmail.com', okText: '知道了' })}></a>
</nav> </nav>
<span className="nav-sp" /> <span className="nav-sp" />
<a className="btn btn-primary" onClick={openPredict}><ArrowIcon /></a> {!hasToken() && <a className="btn" onClick={() => history.push('/login')}></a>}
<a className="btn btn-primary" onClick={openPredict}><ArrowIcon /></a>
</div> </div>
</header> </header>
@ -90,10 +273,12 @@ export default function Landing() {
<div className="wrap hero-grid"> <div className="wrap hero-grid">
<div> <div>
<span className="eyebrow"><span className="pip" /> GB/T 18883-2022 GB 50325-2020 </span> <span className="eyebrow"><span className="pip" /> GB/T 18883-2022 GB 50325-2020 </span>
<h1>,<br /><em></em></h1> <h1 className="hero-title-wrap">
<p className="hero-lead">,TVOC 6 ,,,</p> {renderTitle()}
{isAdmin && <button className="edit-icon" title="编辑标题与内容" onClick={openEdit}><EditOutlined /></button>}
</h1>
<p className="hero-lead">{hero.description}</p>
<div className="hero-cta"> <div className="hero-cta">
<a className="btn btn-primary btn-lg" onClick={openPredict}><ArrowIcon /></a>
<a className="btn btn-lg" onClick={() => scrollToId('cases')}></a> <a className="btn btn-lg" onClick={() => scrollToId('cases')}></a>
</div> </div>
<div className="hero-tags"> <div className="hero-tags">
@ -103,7 +288,12 @@ export default function Landing() {
</div> </div>
</div> </div>
<div className="hero-visual"> <div className="hero-visual">
<img className="hero-img" src="/iapip-web/images/hero-interior.jpg" alt="室内装修实景" style={{ objectFit: 'cover', display: 'block' }} /> <img className="hero-img" src={hero.image} alt="室内装修实景" style={{ objectFit: 'cover', display: 'block' }} />
{isAdmin && (
<Upload accept="image/jpeg,image/png,image/webp,image/gif" showUploadList={false} beforeUpload={handleImageUpload}>
<button className="edit-icon hero-img-edit" title="更换图片"><UploadOutlined /></button>
</Upload>
)}
<div className="hero-badge"><b>6 </b><span></span></div> <div className="hero-badge"><b>6 </b><span></span></div>
</div> </div>
</div> </div>
@ -118,15 +308,24 @@ export default function Landing() {
<section className="sec" id="news"> <section className="sec" id="news">
<div className="wrap"> <div className="wrap">
<div className="sec-head"> <div className="sec-head">
<div><div className="sec-tag"></div><h2 className="sec-h">,</h2></div> <div>
<div className="sec-tag"></div>
<h2 className="sec-h">
{newsTitle}
{isAdmin && <button className="edit-icon" title="编辑科普栏目" onClick={openNewsEdit} style={{ marginLeft: 12 }}><EditOutlined /></button>}
</h2>
</div>
<a className="btn" onClick={() => history.push('/articles')}></a> <a className="btn" onClick={() => history.push('/articles')}></a>
</div> </div>
<div className="carousel"> <div className="carousel">
<div className="cviewport"> <div className="cviewport">
<div className="ctrack" style={{ transform: `translateX(${-cur * 100}%)` }}> <div className="ctrack" style={{ transform: `translateX(${-cur * 100}%)` }}>
{news.map((n, i) => ( {newsItems.map((n, i) => (
<article className="cslide" key={i} style={{ cursor: 'pointer' }} onClick={() => history.push('/article/' + n.id)}> <article className="cslide" key={n.id || i} style={{ cursor: 'pointer' }} onClick={() => history.push('/article/' + n.id)}>
<div className="cslide-img"><span className="cslide-tag">{n.tag}</span><img src={n.image} alt={n.title} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} /></div> <div className="cslide-img">
<span className="cslide-tag">{n.tag}</span>
{n.image && <img src={n.image} alt={n.title} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div className="cslide-body"> <div className="cslide-body">
<div className="cslide-date">{n.date}</div> <div className="cslide-date">{n.date}</div>
<h3>{n.title}</h3> <h3>{n.title}</h3>
@ -141,7 +340,7 @@ export default function Landing() {
<button className="cnav next" onClick={next}><ChevronIcon dir="right" /></button> <button className="cnav next" onClick={next}><ChevronIcon dir="right" /></button>
</div> </div>
<div className="cdots"> <div className="cdots">
{news.map((_, i) => <button key={i} className={'cdot' + (i === cur ? ' on' : '')} onClick={() => go(i)} />)} {newsItems.map((_, i) => <button key={i} className={'cdot' + (i === cur ? ' on' : '')} onClick={() => go(i)} />)}
</div> </div>
</div> </div>
</section> </section>
@ -154,7 +353,15 @@ export default function Landing() {
<div className="cases-grid"> <div className="cases-grid">
{cases.map((c, i) => ( {cases.map((c, i) => (
<article className="case" key={i}> <article className="case" key={i}>
<div className="img-ph"></div> {caseImages[i] ? (
<div className="img-ph" style={{ backgroundImage: `url(${caseImages[i]})`, backgroundSize: 'cover', backgroundPosition: 'center' }}>
{isAdmin && <Upload accept="image/*" showUploadList={false} beforeUpload={f => uploadCaseImage(i, f)}><button className="img-ph-edit"><UploadOutlined /></button></Upload>}
</div>
) : (
<div className="img-ph">
{isAdmin ? <Upload accept="image/*" showUploadList={false} beforeUpload={f => uploadCaseImage(i, f)}><button className="img-ph-edit"><UploadOutlined /> </button></Upload> : '案例实景图'}
</div>
)}
<div className="case-b"> <div className="case-b">
<div className="case-type">{c.type}</div> <div className="case-type">{c.type}</div>
<h4>{c.name}</h4> <h4>{c.name}</h4>
@ -163,7 +370,6 @@ export default function Landing() {
<div className="ba-row"><span className="k"></span><div className="ba-track"><div className="ba-fill" style={{ width: c.w1 + '%', background: 'var(--bad)' }} /></div><span className="v" style={{ color: 'var(--bad)' }}>{c.v1}</span></div> <div className="ba-row"><span className="k"></span><div className="ba-track"><div className="ba-fill" style={{ width: c.w1 + '%', background: 'var(--bad)' }} /></div><span className="v" style={{ color: 'var(--bad)' }}>{c.v1}</span></div>
<div className="ba-row"><span className="k"></span><div className="ba-track"><div className="ba-fill" style={{ width: c.w2 + '%', background: 'var(--good)' }} /></div><span className="v" style={{ color: 'var(--good)' }}>{c.v2}</span></div> <div className="ba-row"><span className="k"></span><div className="ba-track"><div className="ba-fill" style={{ width: c.w2 + '%', background: 'var(--good)' }} /></div><span className="v" style={{ color: 'var(--good)' }}>{c.v2}</span></div>
</div> </div>
<div className="case-foot"><span className="chip chip-good">{c.chip}</span><span className="lk"><ArrowIcon small /></span></div>
</div> </div>
</article> </article>
))} ))}
@ -171,14 +377,13 @@ export default function Landing() {
</div> </div>
</section> </section>
{ecoList.length > 0 && ( {(ecoList.length > 0 || isAdmin) && (
<section className="sec" id="eco"> <section className="sec" id="eco">
<div className="wrap"> <div className="wrap">
<div className="sec-head"> <div className="sec-head">
<div> <div>
<div className="sec-tag"></div> <div className="sec-tag"></div>
<h2 className="sec-h">,</h2> <h2 className="sec-h">,{isAdmin && <button className="edit-icon" title="编辑展示材料" onClick={openEcoEdit}><EditOutlined /></button>}</h2>
<p className="sec-sub"> E0/E1 , TVOC </p>
</div> </div>
</div> </div>
<div className="cases-grid"> <div className="cases-grid">
@ -188,10 +393,26 @@ export default function Landing() {
<div className="case-type">{m.category} · {m.brand}</div> <div className="case-type">{m.category} · {m.brand}</div>
<h4>{m.name || m.brand || '环保建材'}</h4> <h4>{m.name || m.brand || '环保建材'}</h4>
<div className="case-meta">{m.factory || '—'}</div> <div className="case-meta">{m.factory || '—'}</div>
<div className="case-foot"> </div>
<span className="chip chip-good"> {m.eco_level}</span> <div className="eco-img-wrap">
{typeof m.methanal === 'number' && <span className="lk"> {m.methanal}</span>} {m.image ? (
</div> <div style={{ position: 'relative' }}>
<img src={m.image} alt={m.name} className="eco-img" />
{isAdmin && (
<Upload accept="image/*" showUploadList={false} beforeUpload={f => { uploadEcoImage(m.material_id, f); return false }}>
<button className="img-ph-edit" style={{ position: 'absolute', bottom: 6, right: 6 }}><UploadOutlined /></button>
</Upload>
)}
</div>
) : (
isAdmin ? (
<Upload accept="image/*" showUploadList={false} beforeUpload={f => { uploadEcoImage(m.material_id, f); return false }}>
<div className="eco-img-ph"><UploadOutlined style={{ fontSize: 20, marginBottom: 4 }} /><span></span></div>
</Upload>
) : (
<div className="eco-img-ph"><span style={{ color: '#ccc' }}></span></div>
)
)}
</div> </div>
</article> </article>
))} ))}
@ -200,19 +421,6 @@ export default function Landing() {
</section> </section>
)} )}
<section className="sec" id="how">
<div className="wrap">
<div className="sec-head"><div><div className="sec-tag">使</div><h2 className="sec-h">,</h2></div></div>
<div className="steps">
{steps.map((s, i) => (
<div className="step" key={i}>
<div className="step-n">{i + 1}</div><h4>{s.h}</h4><p>{s.p}</p>
{i < steps.length - 1 && <div className="step-ar"><ArrowIcon /></div>}
</div>
))}
</div>
</div>
</section>
<section className="sec" style={{ paddingTop: 0 }}> <section className="sec" style={{ paddingTop: 0 }}>
<div className="wrap"> <div className="wrap">
@ -222,8 +430,7 @@ export default function Landing() {
<p>,,</p> <p>,,</p>
</div> </div>
<div style={{ display: 'flex', gap: 14, position: 'relative', zIndex: 2 }}> <div style={{ display: 'flex', gap: 14, position: 'relative', zIndex: 2 }}>
<a className="btn btn-primary btn-lg" onClick={openPredict}></a> <a className="btn btn-primary btn-lg" onClick={openPredict}></a>
<a className="btn btn-lg" onClick={goPro}></a>
</div> </div>
<div className="deco" /> <div className="deco" />
</div> </div>
@ -241,6 +448,150 @@ export default function Landing() {
</footer> </footer>
<PhoneAuthModal open={authOpen} onSuccess={onAuthed} onCancel={() => setAuthOpen(false)} /> <PhoneAuthModal open={authOpen} onSuccess={onAuthed} onCancel={() => setAuthOpen(false)} />
{/* Hero 编辑弹窗 */}
<Modal
title="编辑首屏内容"
open={editOpen}
onOk={saveEdit}
onCancel={() => setEditOpen(false)}
confirmLoading={saving}
okText="保存"
cancelText="取消"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<Input.TextArea
rows={3}
value={editForm.title}
onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))}
placeholder="支持换行"
/>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<Input.TextArea
rows={4}
value={editForm.description}
onChange={e => setEditForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
</div>
</Modal>
{/* 科普栏目编辑弹窗 */}
<Modal
title="编辑科普栏目"
open={newsEditOpen}
onOk={saveNews}
onCancel={() => setNewsEditOpen(false)}
confirmLoading={newsSaving}
okText="保存"
cancelText="取消"
width={720}
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}></div>
<Input value={newsEditTitle} onChange={e => setNewsEditTitle(e.target.value)} />
</div>
{newsEditItems.map((item, idx) => (
<div key={idx} className="news-edit-card">
<div className="news-edit-card-head">
<span style={{ fontWeight: 600 }}> {idx + 1}</span>
{newsEditItems.length > 1 && (
<button className="edit-icon" style={{ width: 28, height: 28 }} title="删除" onClick={() => removeNewsItem(idx)}>
<DeleteOutlined style={{ color: '#bf4a30' }} />
</button>
)}
</div>
<div className="news-edit-row">
<div style={{ flex: 1 }}>
<div className="news-edit-label"> *</div>
<Input value={item.title} onChange={e => updateNewsItem(idx, 'title', e.target.value)} />
</div>
</div>
<div className="news-edit-row">
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
<Input value={item.tag} onChange={e => updateNewsItem(idx, 'tag', e.target.value)} placeholder="如: 科普、政策解读" />
</div>
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
<Input value={item.date} onChange={e => updateNewsItem(idx, 'date', e.target.value)} placeholder="如: 专栏 · 2026.04" />
</div>
</div>
<div>
<div className="news-edit-label"></div>
<Input.TextArea rows={2} value={item.summary} onChange={e => updateNewsItem(idx, 'summary', e.target.value)} />
</div>
<div className="news-edit-row" style={{ alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div className="news-edit-label"></div>
{item.image && <img src={item.image} alt="" style={{ height: 60, borderRadius: 6, objectFit: 'cover', marginRight: 8 }} />}
<Upload
accept="image/jpeg,image/png,image/webp,image/gif"
showUploadList={false}
beforeUpload={file => handleNewsImageUpload(idx, file)}
>
<button className="edit-icon" style={{ width: 28, height: 28 }} title="上传图片"><UploadOutlined /></button>
</Upload>
</div>
</div>
</div>
))}
<button
className="news-add-btn"
onClick={addNewsItem}
>
<PlusOutlined />
</button>
</div>
</Modal>
{/* 环保建材编辑弹窗 */}
<Modal
title="选择展示的环保建材"
open={ecoEditOpen}
onOk={saveEcoPicks}
onCancel={() => setEcoEditOpen(false)}
okText="保存"
cancelText="取消"
width={680}
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
>
<div style={{ marginBottom: 12 }}>
<span style={{ color: '#888', fontSize: 13 }}> {ecoSelected.length} </span>
</div>
<Input
placeholder="搜索材料名称/品牌/分类..."
prefix={<SearchOutlined />}
value={ecoKeyword}
onChange={e => searchCandidates(e.target.value)}
allowClear
style={{ marginBottom: 12 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ecoCandidates.map(m => (
<label key={m.material_id} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
background: ecoSelected.includes(m.material_id) ? '#f0f9f4' : '#fafafa',
borderRadius: 8, cursor: 'pointer', border: '1px solid',
borderColor: ecoSelected.includes(m.material_id) ? '#1f7a5a' : '#eee'
}}>
<Checkbox checked={ecoSelected.includes(m.material_id)} onChange={() => toggleEco(m.material_id)} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{m.name || m.brand}</div>
<div style={{ fontSize: 12, color: '#888' }}>{m.category} · {m.brand} · {m.factory || '—'}</div>
</div>
</label>
))}
{ecoCandidates.length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}></div>}
</div>
</Modal>
</div> </div>
) )
} }

View File

@ -35,6 +35,7 @@
.lp .btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 6px 18px rgba(31,122,90,.22);} .lp .btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 6px 18px rgba(31,122,90,.22);}
.lp .btn-primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);color:#fff;} .lp .btn-primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);color:#fff;}
.lp .btn-lg{padding:14px 26px;font-size:15px;border-radius:13px;} .lp .btn-lg{padding:14px 26px;font-size:15px;border-radius:13px;}
.lp .hero-title-wrap .edit-icon{vertical-align:middle;margin-left:8px;}
/* hero */ /* hero */
.lp .hero{padding:64px 0 52px;} .lp .hero{padding:64px 0 52px;}
@ -48,7 +49,6 @@
.lp .hero-tags{display:flex;gap:22px;margin-top:26px;flex-wrap:wrap;} .lp .hero-tags{display:flex;gap:22px;margin-top:26px;flex-wrap:wrap;}
.lp .hero-tag{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--sub);} .lp .hero-tag{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--sub);}
.lp .hero-tag svg{width:17px;height:17px;color:var(--accent);} .lp .hero-tag svg{width:17px;height:17px;color:var(--accent);}
.lp .hero-visual{position:relative;}
.lp .hero-img{width:100%;height:56vh;min-height:400px;max-height:640px;border-radius:22px;border:1px solid var(--border);} .lp .hero-img{width:100%;height:56vh;min-height:400px;max-height:640px;border-radius:22px;border:1px solid var(--border);}
.lp .hero-badge{position:absolute;right:-14px;top:26px;background:var(--ink);color:#fff;border-radius:13px;padding:11px 15px;box-shadow:0 14px 34px rgba(0,0,0,.22);} .lp .hero-badge{position:absolute;right:-14px;top:26px;background:var(--ink);color:#fff;border-radius:13px;padding:11px 15px;box-shadow:0 14px 34px rgba(0,0,0,.22);}
.lp .hero-badge b{font-family:var(--serif);font-size:20px;display:block;} .lp .hero-badge b{font-family:var(--serif);font-size:20px;display:block;}
@ -94,7 +94,13 @@
.lp .cases-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;} .lp .cases-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;}
.lp .case{background:var(--panel);border:1px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:.18s;} .lp .case{background:var(--panel);border:1px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:.18s;}
.lp .case:hover{transform:translateY(-3px);box-shadow:0 16px 36px rgba(60,45,20,.12);} .lp .case:hover{transform:translateY(-3px);box-shadow:0 16px 36px rgba(60,45,20,.12);}
.lp .case .img-ph{width:100%;height:172px;border-radius:0;} .lp .case .img-ph{width:100%;height:172px;border-radius:0;position:relative;}
.lp .img-ph-edit{position:absolute;bottom:8px;right:8px;background:rgba(255,255,255,.85);border:1px solid #d9d9d9;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:12px;color:var(--accent);display:inline-flex;align-items:center;gap:4px;}
.lp .img-ph-edit:hover{background:#fff;border-color:var(--accent);}
.lp .eco-img-wrap{padding:0 19px 16px;}
.lp .eco-img{max-width:100%;max-height:120px;object-fit:contain;border-radius:10px;display:block;}
.lp .eco-img-ph{width:100%;height:120px;border-radius:10px;background:var(--bg2);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--faint);font-size:13px;cursor:pointer;border:1.5px dashed var(--border2);transition:.18s;}
.lp .eco-img-ph:hover{border-color:var(--accent);color:var(--accent);}
.lp .case-b{padding:18px 19px 20px;} .lp .case-b{padding:18px 19px 20px;}
.lp .case-type{font-size:11.5px;font-weight:700;color:var(--accent);letter-spacing:.5px;} .lp .case-type{font-size:11.5px;font-weight:700;color:var(--accent);letter-spacing:.5px;}
.lp .case h4{font-family:var(--serif);font-size:18px;font-weight:700;margin:7px 0 0;} .lp .case h4{font-family:var(--serif);font-size:18px;font-weight:700;margin:7px 0 0;}
@ -134,6 +140,21 @@
.lp .footer-in{max-width:var(--maxw);margin:0 auto;padding:34px 28px;display:flex;align-items:center;justify-content:space-between;gap:20px;color:var(--faint);font-size:13px;} .lp .footer-in{max-width:var(--maxw);margin:0 auto;padding:34px 28px;display:flex;align-items:center;justify-content:space-between;gap:20px;color:var(--faint);font-size:13px;}
.lp .footer .brand-tt{font-size:15px;} .lp .footer .brand-tt{font-size:15px;}
/* admin edit icons */
.lp .edit-icon{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;border:1px solid var(--border2);background:var(--panel);color:var(--accent);cursor:pointer;font-size:14px;transition:.15s;box-shadow:0 2px 8px rgba(0,0,0,.08);vertical-align:middle;margin-left:12px;flex-shrink:0;}
.lp .edit-icon:hover{background:var(--accent);color:#fff;border-color:var(--accent);}
.lp .hero-title-wrap{display:block;}
.lp .hero-img-edit{position:absolute;bottom:16px;right:16px;z-index:3;}
.lp .hero-visual{position:relative;}
/* news edit modal */
.news-edit-card{border:1px solid #e9e1d2;border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px;background:#fafaf6;}
.news-edit-card-head{display:flex;align-items:center;justify-content:space-between;}
.news-edit-row{display:flex;gap:12px;}
.news-edit-label{font-size:13px;color:#6c6353;margin-bottom:4px;}
.news-add-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px;border:1px dashed #ded3bf;border-radius:10px;background:transparent;color:#1f7a5a;font-size:14px;font-weight:600;cursor:pointer;transition:.15s;}
.news-add-btn:hover{border-color:#1f7a5a;background:rgba(31,122,90,.06);}
@media(max-width:900px){ @media(max-width:900px){
.lp .hero-grid,.lp .cslide{grid-template-columns:1fr;} .lp .hero-grid,.lp .cslide{grid-template-columns:1fr;}
.lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;} .lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;}

View File

@ -31,10 +31,11 @@ export default function SourceTracing() {
const [standards, setStandards] = useState<ApcLimitStandard[]>([]) const [standards, setStandards] = useState<ApcLimitStandard[]>([])
const [standardId, setStandardId] = useState('') const [standardId, setStandardId] = useState('')
const [roomId, setRoomId] = useState('demo') const [roomId, setRoomId] = useState('demo')
const [customName, setCustomName] = useState('')
const [area, setArea] = useState(19.8) const [area, setArea] = useState(19.8)
const [height, setHeight] = useState(3) const [height, setHeight] = useState(3)
const [temperature, setTemperature] = useState(22) const [temperature, setTemperature] = useState(23)
const [humidity, setHumidity] = useState(45) const [humidity, setHumidity] = useState(50)
const [ventilationRate, setVentilationRate] = useState(0.5) const [ventilationRate, setVentilationRate] = useState(0.5)
const [mats, setMats] = useState<MatRow[]>([]) const [mats, setMats] = useState<MatRow[]>([])
const [pol, setPol] = useState<Pollutant>('methanal') const [pol, setPol] = useState<Pollutant>('methanal')
@ -104,7 +105,7 @@ export default function SourceTracing() {
function buildPayload(ventRate = ventilationRate) { function buildPayload(ventRate = ventilationRate) {
const selected = mats.filter(m => m.on && matMap[m.id]) const selected = mats.filter(m => m.on && matMap[m.id])
return { return {
id: 'src', project_id: 'src', name: roomId, id: 'src', project_id: 'src', name: roomId === '_custom' ? (customName || '自定义空间') : roomId,
height, area, height, area,
env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate, env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate,
methanal_c_lmt: standard?.methanal_c_lmt, methanal_c_lmt: standard?.methanal_c_lmt,
@ -214,12 +215,36 @@ export default function SourceTracing() {
<div className="fld"> <div className="fld">
<div className="fld-lab"></div> <div className="fld-lab"></div>
<div className="rooms"> <div className="num">
{PRESET_ROOMS.map(r => ( <select
<div key={r.id} className={'room-b' + (roomId === r.id ? ' on' : '')} onClick={() => loadRoom(r.id)}>{r.name}</div> style={{ flex: 1, border: 'none', background: 'transparent', padding: '8px 10px', fontSize: 14, fontWeight: 600, outline: 'none', color: 'var(--ink)' }}
))} value={roomId}
onChange={e => {
const v = e.target.value
if (v === '_custom') {
setRoomId('_custom')
setCustomName('')
setArea(0); setHeight(2.7); setTemperature(23); setHumidity(50)
setVentilationRate(0.5); setMats([])
} else {
loadRoom(v)
}
}}
>
{PRESET_ROOMS.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
<option value="_custom"></option>
</select>
</div> </div>
</div> </div>
{roomId === '_custom' && (
<div className="fld">
<div className="fld-lab"></div>
<div className="num">
<input type="text" placeholder="请输入空间名称" value={customName} onChange={e => setCustomName(e.target.value)}
style={{ flex: 1, border: 'none', background: 'transparent', padding: '8px 10px', fontSize: 14, fontWeight: 600, outline: 'none', color: 'var(--ink)' }} />
</div>
</div>
)}
<div className="fld"> <div className="fld">
<div className="row2"> <div className="row2">

View File

@ -65,17 +65,6 @@
.fld-lab .v { font-weight: 700; color: var(--ink); font-size: 13px; } .fld-lab .v { font-weight: 700; color: var(--ink); font-size: 13px; }
/* Room selector tabs */ /* Room selector tabs */
.rooms { display: flex; flex-wrap: wrap; gap: 6px; }
.room-b {
font-size: 12.5px; font-weight: 600; padding: 7px 14px;
border-radius: 6px; border: 1px solid var(--border2);
background: var(--panel); color: var(--sub);
cursor: pointer; transition: border-color .12s, color .12s, background .12s;
white-space: nowrap;
}
.room-b:hover { border-color: var(--accent); color: var(--accent); }
.room-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Number input group */ /* Number input group */
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.row2 > div { min-width: 0; } .row2 > div { min-width: 0; }

View File

@ -291,7 +291,29 @@ export interface EcoMaterial {
factory: string factory: string
eco_level: string eco_level: string
methanal: number methanal: number
image?: string | null
} }
export async function getEcoMaterials(options?: AnyObj) { export async function getEcoMaterials(options?: AnyObj) {
return request<EcoMaterial[]>('/api/mtrl/eco', { method: 'GET', ...(options || {}) }) return request<EcoMaterial[]>('/api/mtrl/eco', { method: 'GET', ...(options || {}) })
} }
export async function getEcoCandidates(keyword?: string) {
return request<EcoMaterial[]>('/api/mtrl/eco/candidates', {
method: 'GET',
params: { keyword },
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() }
})
}
export async function setEcoPicks(ids: string[]) {
return request<string[]>('/api/mtrl/eco/picks', {
method: 'PUT',
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() },
data: { ids }
})
}
export async function setEcoImage(materialId: string, url: string) {
return request<Record<string, string>>(`/api/mtrl/eco/image/${materialId}`, {
method: 'PUT',
headers: { [HTTP_HEADER.AUTHORIZATION]: getLocalToken() },
data: { url }
})
}

View File

@ -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
}
}

View File

@ -142,6 +142,22 @@ export type Material = {
display?: number display?: number
ypt?: number | null ypt?: number | null
health_level?: 'A' | 'B' | 'C' | null health_level?: 'A' | 'B' | 'C' | null
model_name?: string
sample_image?: string
reference_price?: string
application_case?: string
effect_image?: string
supplier?: string
contact_person?: string
contact_phone?: string
production_date?: string
batch_no?: string
sample_source?: string
client_unit?: string
test_date?: string
test_standard?: string
report_no?: string
test_org?: string
collectors?: { user_id: string; project_id: string }[] collectors?: { user_id: string; project_id: string }[]
methanal?: number methanal?: number
methanal_min_be?: number methanal_min_be?: number

View File

@ -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

View File

@ -1,5 +1,5 @@
import { DATA_TYPE } from '@/common/constants' import { DATA_TYPE } from '@/common/constants'
import { AnyObj, ECO_LEVEL, Material } from '@/common/types' import { AnyObj, Material } from '@/common/types'
import { searchParams } from '@/common/utils' import { searchParams } from '@/common/utils'
import api from '@/services/api' import api from '@/services/api'
import { import {
@ -119,12 +119,6 @@ const MaterialCard: React.FC<{
<ProFormText name="brand" label="材料品牌" /> <ProFormText name="brand" label="材料品牌" />
<ProFormText name="factory" label="材料厂家" /> <ProFormText name="factory" label="材料厂家" />
<ProFormText name="spec" label="材料规格" /> <ProFormText name="spec" label="材料规格" />
<ProFormSelect
name="eco_level"
label="环保级别"
options={[{ value: ECO_LEVEL.E0 }, { value: ECO_LEVEL.E1 }, { value: ECO_LEVEL.E2 }]}
mode="multiple"
/>
{tab === DATA_TYPE.PRIVATE ? ( {tab === DATA_TYPE.PRIVATE ? (
<ProFormText name="creator_name" label="创建用户" /> <ProFormText name="creator_name" label="创建用户" />
) : ( ) : (
@ -229,7 +223,7 @@ const MaterialCard: React.FC<{
search={false} search={false}
options={false} options={false}
actionRef={tableRef} actionRef={tableRef}
scroll={modal ? { y: 'calc(100vh - 400px)' } : { y: 'calc(100vh - 360px)' }} scroll={modal ? { x: 3200, y: 'calc(100vh - 400px)' } : { x: 3200, y: 'calc(100vh - 360px)' }}
rowSelection={ rowSelection={
modal modal
? { ? {
@ -280,7 +274,8 @@ const MaterialCard: React.FC<{
title: '材料ID', title: '材料ID',
dataIndex: 'material_id', dataIndex: 'material_id',
width: 120, width: 120,
align: 'center' align: 'center',
fixed: 'left'
}, },
{ {
title: '材料名称', title: '材料名称',
@ -311,7 +306,7 @@ const MaterialCard: React.FC<{
align: 'center' align: 'center'
}, },
{ {
title: '材料厂家', title: '生产商',
dataIndex: 'factory', dataIndex: 'factory',
align: 'center' align: 'center'
}, },
@ -320,13 +315,6 @@ const MaterialCard: React.FC<{
dataIndex: 'spec', dataIndex: 'spec',
align: 'center' align: 'center'
}, },
{
title: '环保等级',
dataIndex: 'eco_level',
align: 'center',
width: 90,
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
},
{ {
title: '健康等级', title: '健康等级',
dataIndex: 'health_level', dataIndex: 'health_level',
@ -344,6 +332,104 @@ const MaterialCard: React.FC<{
return m.health_level ? <Tag color={color}>{m.health_level}</Tag> : '—' return m.health_level ? <Tag color={color}>{m.health_level}</Tag> : '—'
} }
}, },
{
title: '型号',
dataIndex: 'model_name',
align: 'center',
render: (v) => v || '—'
},
{
title: '参考价格',
dataIndex: 'reference_price',
align: 'center',
render: (v) => v || '—'
},
{
title: '应用案例',
dataIndex: 'application_case',
align: 'center',
render: (v) => v || '—'
},
{
title: '样品图片',
dataIndex: 'sample_image',
align: 'center',
width: 100,
render: (v) => v ? <img src={v as string} style={{ maxWidth: 60, maxHeight: 40, objectFit: 'contain' }} /> : '—'
},
{
title: '应用效果图',
dataIndex: 'effect_image',
align: 'center',
width: 100,
render: (v) => v ? <img src={v as string} style={{ maxWidth: 60, maxHeight: 40, objectFit: 'contain' }} /> : '—'
},
{
title: '供应商',
dataIndex: 'supplier',
align: 'center',
render: (v) => v || '—'
},
{
title: '联系人',
dataIndex: 'contact_person',
align: 'center',
render: (v) => v || '—'
},
{
title: '联系电话',
dataIndex: 'contact_phone',
align: 'center',
render: (v) => v || '—'
},
{
title: '生产日期',
dataIndex: 'production_date',
align: 'center',
render: (v) => v || '—'
},
{
title: '生产批号',
dataIndex: 'batch_no',
align: 'center',
render: (v) => v || '—'
},
{
title: '样品来源',
dataIndex: 'sample_source',
align: 'center',
render: (v) => v || '—'
},
{
title: '委托单位',
dataIndex: 'client_unit',
align: 'center',
render: (v) => v || '—'
},
{
title: '检测日期',
dataIndex: 'test_date',
align: 'center',
render: (v) => v || '—'
},
{
title: '执行标准',
dataIndex: 'test_standard',
align: 'center',
render: (v) => v || '—'
},
{
title: '报告编号',
dataIndex: 'report_no',
align: 'center',
render: (v) => v || '—'
},
{
title: '检测机构',
dataIndex: 'test_org',
align: 'center',
render: (v) => v || '—'
},
{ {
title: '状态', title: '状态',
dataIndex: 'display', dataIndex: 'display',
@ -376,6 +462,7 @@ const MaterialCard: React.FC<{
valueType: 'option', valueType: 'option',
align: 'center', align: 'center',
width: 100, width: 100,
fixed: 'right',
render: (_, item) => ( render: (_, item) => (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
<Button <Button

View File

@ -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="甲醛: &nbsp;&nbsp;最低平衡释放量(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 &nbsp;&nbsp;最低平衡释放量(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="苯: &nbsp;&nbsp;最低平衡释放量(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="甲苯: &nbsp;&nbsp;最低平衡释放量(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="二甲苯: &nbsp;&nbsp;最低平衡释放量(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

View File

@ -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
})
}