feat(ms): 管理端材料库排序依据/分组 + 健康档标签

- Material 类型新增 ypt/health_tier 字段
- material.ts search() 查询参数补 group_by_tier
- material-card 新增排序依据 Select(手动排序/综合 Ypt/五种污染物释放量)与按健康档分组 Switch,
  健康档 Tag 列,分组时按档打分区标题
- 顶部排序依据 Select 为唯一排序权威来源:移除旧筛选区排序下拉对 searchParams 第三参数的驱动,
  避免其静默覆盖新排序请求(对齐用户端 iapip-web 同一修复)
- 经核查 ms 端不存在手动拖动/位置输入重排 sort_order 的既有功能,故无需像 web 端那样额外加
  sortKey==='sort_order' && !groupByTier 门控

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCShKevM5prWZhp1kk1iGh
This commit is contained in:
zty 2026-07-10 04:32:35 -04:00
parent c7134f4332
commit fb033ada28
3 changed files with 1000 additions and 0 deletions

View File

@ -0,0 +1,298 @@
export type AnyObj = {
[key: string]: any
}
export type Arrayable<T> = T | T[]
/**
* Server Types
*/
export type Admin = {
id: string
name: string
password: string
username: string
status: number
type: ADMIN_TYPE
authz_hist_mgmt: number
authz_tmpl_mgmt: number
authz_mtrl_mgmt: number
authz_audit_mgmt: number
authz_acct_mgmt: number
authz_sys_cfg: number
creator_id: string
creator_name: string
created_at: string
updated_at: string
}
export enum ADMIN_TYPE {
SUPER_ADMIN = 1,
ADMIN = 2
}
export type User = {
id: string
name: string
password: string
username: string
status: number
creator_id: string
creator_name: string
created_at: string
updated_at: string
}
export type AdminCert = {
id: string
name: string
username: string
status: number
type: ADMIN_TYPE
authz_hist_mgmt: number
authz_tmpl_mgmt: number
authz_mtrl_mgmt: number
authz_audit_mgmt: number
authz_acct_mgmt: number
authz_sys_cfg: number
ip: string
login_at: Date
expire_at: Date
iat?: number
exp?: number
}
export type Creator = {
id: string
name: string
username: string
}
export type Project = {
id: string
name: string
category?: string
province?: string
city?: string
climatic_zone?: string
constr_area?: number
usage_area?: number
spaces?: Space[]
status?: number
display?: number
predicted_at?: string
score?: number
grade?: number
collectors?: { user_id: string; project_id: string }[]
from?: FROM_TYPE
creator_id?: string
creator_name: string
creator?: Creator
related_user?: Creator
related_admin?: Creator
created_at: string
updated_at: string
}
export type Space = {
id: string
project_id: string
name: string
category?: string
layout: number
height?: number
area?: number
volume?: number
env_temp?: number
env_hum?: number
env_vent_rate?: number
apc_lmt_std_id?: string
apc_lmt_std?: ApcLimitStandard
materials?: Material[]
methanal_c?: number
tvoc_c?: number
benzene_c?: number
toluene_c?: number
p_xylene_c?: number
methanal_c_exceed?: number
tvoc_c_exceed?: number
benzene_c_exceed?: number
toluene_c_exceed?: number
p_xylene_c_exceed?: number
creator_id: string
creator_name: string
created_at: string
updated_at: string
}
export type Material = {
id?: number
material_id: string
project_id?: string
space_id?: string
name?: string
category?: string
brand?: string
factory?: string
spec?: string
eco_level?: ECO_LEVEL
unit?: MTRL_UNIT
qty?: number
display?: number
ypt?: number | null
health_tier?: 'A' | 'B' | 'C' | null
collectors?: { user_id: string; project_id: string }[]
methanal?: number
methanal_min_be?: number
methanal_be_area?: number
methanal_be_roc?: number
tvoc?: number
tvoc_min_be?: number
tvoc_be_area?: number
tvoc_be_roc?: number
benzene?: number
benzene_min_be?: number
benzene_be_area?: number
benzene_be_roc?: number
toluene?: number
toluene_min_be?: number
toluene_be_area?: number
toluene_be_roc?: number
p_xylene?: number
p_xylene_min_be?: number
p_xylene_be_area?: number
p_xylene_be_roc?: number
methanal_cr?: number
tvoc_cr?: number
benzene_cr?: number
toluene_cr?: number
p_xylene_cr?: number
methanal_cr_exceed?: number
tvoc_cr_exceed?: number
benzene_cr_exceed?: number
toluene_cr_exceed?: number
p_xylene_cr_exceed?: number
is_methanal_ps?: number
is_tvoc_ps?: number
is_benzene_ps?: number
is_toluene_ps?: number
is_p_xylene_ps?: number
from?: FROM_TYPE
user_id: string
user_name: string
creator_id?: string
creator_name: string
creator?: Creator
related_user?: Creator
related_admin?: Creator
created_at: string
updated_at: string
}
export type AuditRequest = {
id: string
status: AUDIT_REQ_STATUS
source_id?: string
source_creator_id?: string
source: AUDIT_REQ_SRC
type: AUDIT_REQ_TYPE
desc?: string
remark?: string
details: AuditRequestDetails[]
auditor_id?: string
auditor_name?: string
auditor?: Creator
audited_at?: string
creator_id?: string
creator_name: string
creator?: Creator
created_at: string
updated_at: string
}
export type AuditRequestDetails = {
id: number
audit_request_id: number
content: AnyObj
}
export enum ECO_LEVEL {
E0 = 'E0',
E1 = 'E1',
E2 = 'E2'
}
export enum MTRL_UNIT {
AREA = 'AREA',
LEN = 'LEN'
}
export enum FROM_TYPE {
CREATE = 'CREATE',
IMPORT = 'IMPORT',
COLLECT = 'COLLECT'
}
export enum AUDIT_REQ_SRC {
PUB_TEMP = 'PUB_TEMP',
SELF_TEMP = 'SELF_TEMP',
PUB_MTRL = 'PUB_MTRL',
SELF_MTRL = 'SELF_MTRL',
HIS_PROJ = 'HIS_PROJ'
}
export enum AUDIT_REQ_TYPE {
CREATE = 'CREATE',
IMPORT = 'IMPORT',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
COLLECT = 'COLLECT',
EXPORT = 'EXPORT'
}
export enum AUDIT_REQ_STATUS {
REJECT = -1,
HOLD = 0,
PASS = 1
}
export type ProjectSpaceCategory = {
pry_no_1: number
project_category: string
space_categories: {
pry_no_2: number
space_category: string
}[]
}
export type MaterialCategory = {
pry_no_1: number
first_category: string
second_categories: {
pry_no_2: number
second_category: string
unit?: MTRL_UNIT
}[]
}
export type CityInformation = {
pry_no_1: number
province: string
cities: {
pry_no_2: number
city: string
climatic_zone?: string
env_temp: number
env_hum: number
}[]
}
export type ApcLimitStandard = {
id: string
methanal_c_lmt: number
tvoc_c_lmt: number
benzene_c_lmt: number
toluene_c_lmt: number
p_xylene_c_lmt: number
}

View File

@ -0,0 +1,546 @@
import { DATA_TYPE } from '@/common/constants'
import { AnyObj, ECO_LEVEL, Material } from '@/common/types'
import { searchParams } from '@/common/utils'
import api from '@/services/api'
import {
ActionType,
LightFilter,
ProFormCascader,
ProFormSelect,
ProFormText,
ProTable
} from '@ant-design/pro-components'
import { Badge, Button, Form, Modal, Select, Switch, Tag } from 'antd'
import { useRef, useState } from 'react'
import OperateMaterial from '../apply-request-modal/operate-material'
import ImportMaterialsModal from '../import-file-modal/materials'
import MaterialModalForm from '../material-modal-form'
import PageCard from '../page-card'
const MaterialCard: React.FC<{
title?: string
modal?: boolean
modalProps?: {
open?: boolean
onCancel?: () => void
}
defaultSelected?: Material[]
onAdd?: (addedMtrls?: Material[]) => void
}> = ({ title, modal, modalProps, defaultSelected, onAdd }) => {
const [tab, setTab] = useState(DATA_TYPE.PUBLIC)
const tableRef = useRef<ActionType>()
const [filterForm] = Form.useForm()
const [filterConditions, setFilterConditions] = useState<AnyObj>()
const [openCreateModal, setOpenCreateModal] = useState(false)
const [details, setDetails] = useState<Material>()
const [selected, setSelected] = useState<Material[] | undefined>()
const [content, setContent] = useState<AnyObj>()
// 材料排序依据(默认综合 Ypt 升序)与是否按健康档分组显示
const [sortKey, setSortKey] = useState<string>('ypt')
const [groupByTier, setGroupByTier] = useState<boolean>(false)
const page = (
<PageCard
title={title}
defActTabKey={!modal ? DATA_TYPE.PUBLIC : undefined}
tabItems={
!modal
? [
{
key: DATA_TYPE.PUBLIC,
label: '公共库'
},
{
key: DATA_TYPE.PRIVATE,
label: '自建库'
}
]
: undefined
}
onTabChange={!modal ? (key) => setTab(key as DATA_TYPE) : undefined}
extra={
!modal && tab === DATA_TYPE.PUBLIC
? [
<ImportMaterialsModal
key="import-modal"
title="导入公共材料"
trigger={<Button></Button>}
/>,
<>
<Button type="primary" onClick={() => setOpenCreateModal(true)}>
</Button>
<MaterialModalForm
key="create-modal-form"
title="新建公共材料"
displayStatus
displayDetails
open={openCreateModal}
onCancel={() => setOpenCreateModal(false)}
onFinish={async (data) => setContent(data)}
/>
</>
]
: []
}
cardStyle={
modal
? {
borderBottomWidth: 0
}
: undefined
}
>
<div className="flex mb-4">
<div style={{ width: 'calc(100% - 80px)' }}>
<LightFilter form={filterForm} onFinish={async (data) => setFilterConditions(data)}>
<ProFormText name="material_id" label="材料ID" />
<ProFormText name="name" label="材料名称" />
<ProFormCascader
name="category"
label="材料类别"
fieldProps={{
placeholder: '',
multiple: true,
showSearch: true
}}
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.second_category,
label: c.second_category
}))
}))
}}
transform={(val) => ({ category: val.map((v: string[]) => v.join('/')) })}
/>
<ProFormText name="brand" label="材料品牌" />
<ProFormText name="factory" label="材料厂家" />
<ProFormText name="spec" label="材料规格" />
<ProFormSelect
name="eco_level"
label="环保级别"
options={[{ value: ECO_LEVEL.E0 }, { value: ECO_LEVEL.E1 }, { value: ECO_LEVEL.E2 }]}
mode="multiple"
/>
{tab === DATA_TYPE.PRIVATE ? (
<ProFormText name="creator_name" label="创建用户" />
) : (
<ProFormSelect
name="display"
label="状态"
options={[
{ label: '展示', value: 1 },
{ label: '隐藏', value: 0 }
]}
/>
)}
<ProFormSelect
name="sort"
label="排序"
options={[
{
label: '材料类别A-Z',
value: 'category'
},
{
label: '最近更新时间由近到远',
value: 'updated_at'
},
{
label: '甲醛释放量由低到高',
value: 'methanal_be_area'
},
{
label: 'TVOC释放量由低到高',
value: 'tvoc_be_area'
},
{
label: '苯释放量由低到高',
value: 'benzene_be_area'
},
{
label: '甲苯释放量由低到高',
value: 'toluene_be_area'
},
{
label: '二甲苯释放量由低到高',
value: 'p_xylene_be_area'
}
]}
/>
</LightFilter>
</div>
<div style={{ width: 80 }} className="flex justify-center">
<Button
key="reset"
onClick={() => {
setFilterConditions(undefined)
filterForm.resetFields()
}}
>
</Button>
</div>
</div>
<div className="flex items-center gap-4 mb-4">
<span>
{' '}
<Select
size="small"
style={{ width: 160 }}
value={sortKey}
onChange={(v) => {
setSortKey(v)
tableRef.current?.reload()
}}
options={[
{ value: 'sort_order', label: '手动排序' },
{ value: 'ypt', label: '综合 Ypt' },
{ value: 'methanal_be_area', label: '甲醛 Yp' },
{ value: 'tvoc_be_area', label: 'TVOC Yp' },
{ value: 'benzene_be_area', label: '苯 Yp' },
{ value: 'toluene_be_area', label: '甲苯 Yp' },
{ value: 'p_xylene_be_area', label: '二甲苯 Yp' }
]}
/>
</span>
<span>
{' '}
<Switch
size="small"
checked={groupByTier}
onChange={(c) => {
setGroupByTier(c)
tableRef.current?.reload()
}}
/>
</span>
</div>
<ProTable<Material>
cardProps={{
bodyStyle: {
padding: 0
}
}}
size="small"
search={false}
options={false}
actionRef={tableRef}
scroll={modal ? { y: 'calc(100vh - 400px)' } : { y: 'calc(100vh - 360px)' }}
rowSelection={
modal
? {
type: 'checkbox',
selectedRowKeys: selected?.map((m) => m.material_id),
// onSelect: (mtrl, slct) => {
// setSelected((pre) => {
// if (slct) {
// const nxt = pre ? [...pre] : []
// nxt.push(mtrl)
// return nxt
// } else {
// return pre?.filter((m) => m.material_id !== mtrl.material_id)
// }
// })
// },
onChange: (_, mtrls) => setSelected(mtrls),
getCheckboxProps: (mtrl) => ({
disabled:
defaultSelected &&
defaultSelected.findIndex((m) => m.material_id === mtrl.material_id) !== -1
})
}
: false
}
onRow={(mtrl) => ({
onClick: () => {
if (!defaultSelected?.find((m) => m.material_id === mtrl.material_id)) {
setSelected((pre) => {
if (pre) {
if (pre.find((m) => m.material_id === mtrl.material_id)) {
return pre.filter((m) => m.material_id !== mtrl.material_id)
} else {
const nxt = [...pre]
nxt.push(mtrl)
return nxt
}
} else {
return [mtrl]
}
})
}
}
})}
rowKey={(data) => data.material_id}
columns={[
{
title: '材料ID',
dataIndex: 'material_id',
width: 120,
align: 'center'
},
{
title: '材料名称',
dataIndex: 'name',
align: 'center',
render: (_, m) => {
const tierHead = (m as Material & { __tierHead?: string }).__tierHead
return (
<>
{tierHead && (
<div style={{ fontWeight: 600, color: '#888', margin: '4px 0' }}>
{'档位 ' + tierHead}
</div>
)}
<span>{m.name}</span>
</>
)
}
},
{
title: '材料类别',
dataIndex: 'category',
align: 'center'
},
{
title: '材料品牌',
dataIndex: 'brand',
align: 'center'
},
{
title: '材料厂家',
dataIndex: 'factory',
align: 'center'
},
{
title: '材料规格',
dataIndex: 'spec',
align: 'center'
},
{
title: '环保等级',
dataIndex: 'eco_level',
align: 'center',
width: 90,
render: (_, { eco_level }) => (eco_level ? <Tag>{eco_level}</Tag> : '-')
},
{
title: '健康档',
dataIndex: 'health_tier',
align: 'center',
width: 70,
render: (_, m) => {
const color =
m.health_tier === 'A'
? 'green'
: m.health_tier === 'B'
? 'gold'
: m.health_tier === 'C'
? 'red'
: 'default'
return <Tag color={color}>{m.health_tier ?? '—'}</Tag>
}
},
{
title: '状态',
dataIndex: 'display',
align: 'center',
width: 80,
hideInTable: modal || tab === DATA_TYPE.PRIVATE,
render: (_, { display }) =>
display === 1 ? (
<Badge status="success" text="展示" />
) : (
<Badge status="error" text="隐藏" />
)
},
{
title: '创建用户',
dataIndex: 'creator_name',
align: 'center',
hideInTable: modal || tab === DATA_TYPE.PUBLIC,
renderText: (_, { creator }) => creator?.username
},
{
title: '最近更新时间',
dataIndex: 'updated_at',
align: 'center',
width: 160,
renderText: (dt) => new Date(dt).toLocaleString('zh-CN')
},
{
title: '操作',
valueType: 'option',
align: 'center',
width: 100,
render: (_, item) => (
<div className="flex justify-center space-x-2">
<Button
type="link"
style={{ padding: 0 }}
onClick={(e) => {
setDetails(item)
e.stopPropagation()
}}
>
</Button>
{!modal && tab === DATA_TYPE.PUBLIC ? (
<Button
type="link"
style={{ padding: 0 }}
onClick={(e) => {
setContent({ delete_id: item.material_id })
e.stopPropagation()
}}
>
</Button>
) : !modal && tab === DATA_TYPE.PRIVATE ? (
<Button
type="link"
style={{ padding: 0 }}
onClick={(e) => {
setContent({ collect_id: item.material_id, creator_id: item.creator_id })
e.stopPropagation()
}}
>
</Button>
) : (
''
)}
</div>
)
}
]}
params={{ type: tab, conditions: filterConditions, sortKey, groupByTier }}
request={async ({ type, conditions, sortKey: skParam, groupByTier: gbtParam, ...other }) => {
try {
// 旧筛选区「排序」下拉已被顶部「排序依据」Select 取代:
// 从 conditions 里剔除 sort不让它随 otherConditions 传给后端,
// 也不再把它转成 searchParams() 的第三个参数(那会覆盖 query.sort_key/sort_val
// 导致新 Select 选的排序字段被静默忽略)。'category' 仍保留为纯前端排序。
const { sort, ...otherConditions } = conditions ?? {}
const sortByCategory = sort === 'category'
const data = await api.material.search(
type,
searchParams(
{
...otherConditions,
...other,
display: modal ? 1 : undefined,
with_cltrs: 1,
// 顶部「排序依据」Select 始终是排序字段的唯一来源,不会被旧筛选区排序覆盖
sort_key: skParam,
sort_val: 'asc',
group_by_tier: gbtParam ? 1 : 0
},
false
)
)
if (sortByCategory) {
data.sort((a, b) => (a.category ?? '').localeCompare(b.category ?? ''))
}
if (gbtParam) {
// 分组开时后端已按档排序;为每档第一条打标,供「材料名称」列渲染分区小标题
let last: string | null | undefined = undefined
data.forEach((m) => {
if (m.health_tier !== last) {
;(m as Material & { __tierHead?: string }).__tierHead = m.health_tier ?? '数据不全'
last = m.health_tier
}
})
}
return {
success: true,
data
}
} catch (err) {
return {
success: false
}
}
}}
pagination={
!modal
? {
position: ['bottomRight'],
defaultPageSize: 10,
showSizeChanger: true
}
: false
}
/>
<MaterialModalForm
title={`${tab === DATA_TYPE.PUBLIC ? '公共' : '自建'}材料详情`}
initialValues={details}
displayId
displayCreator={!modal && tab === DATA_TYPE.PRIVATE}
displayStatus={!modal && tab === DATA_TYPE.PUBLIC}
displayDetails
readonly={modal || tab === DATA_TYPE.PRIVATE}
open={details !== undefined}
submitText="保存"
onFinish={tab === DATA_TYPE.PUBLIC ? async (data) => setContent(data) : undefined}
onCancel={() => setDetails(undefined)}
/>
</PageCard>
)
return modal ? (
<Modal
style={{
top: 65
}}
styles={{
content: {
padding: 0
},
footer: {
margin: 0
}
}}
width="calc(100% - 100px)"
footer={
<div className="space-x-4 pb-4 pr-[20px]">
<Button style={{ width: 64 }} onClick={modalProps?.onCancel}>
</Button>
<Button type="primary" style={{ width: 64 }} onClick={() => onAdd?.(selected)}>
</Button>
</div>
}
maskClosable={false}
destroyOnClose
open={modalProps?.open}
afterClose={() => {
setFilterConditions(undefined)
filterForm.resetFields()
setSelected(undefined)
}}
onCancel={modalProps?.onCancel}
>
{page}
</Modal>
) : (
<>
{page}
<OperateMaterial
type={tab}
content={content}
open={content !== undefined}
onCancel={() => setContent(undefined)}
onFinish={() => {
setOpenCreateModal(false)
setDetails(undefined)
}}
/>
</>
)
}
export default MaterialCard

View File

@ -0,0 +1,156 @@
import { DATA_TYPE, HTTP_HEADER } from '@/common/constants'
import { AnyObj, Arrayable, ECO_LEVEL, FROM_TYPE, MTRL_UNIT, Material } from '@/common/types'
import { getLocalToken } from '@/common/utils'
import { request } from '@umijs/max'
export async function createPub(
body: {
name?: string
category?: string
brand?: string
factory?: string
spec?: string
eco_level?: ECO_LEVEL
display?: number
methanal_min_be?: number
methanal_be_area?: number
methanal_be_roc?: number
tvoc_min_be?: number
tvoc_be_area?: number
tvoc_be_roc?: number
benzene_min_be?: number
benzene_be_area?: number
benzene_be_roc?: number
toluene_min_be?: number
toluene_be_area?: number
toluene_be_roc?: number
p_xylene_min_be?: number
p_xylene_be_area?: number
p_xylene_be_roc?: number
from?: FROM_TYPE
},
options?: AnyObj
) {
return request<Material>(`/api/mtrl/pub`, {
method: 'POST',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export async function collectSelf(
body: {
material_id: string
creator_id: string
display?: number
},
options?: AnyObj
) {
return request<Material>(`/api/mtrl/pub/self`, {
method: 'POST',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
data: body,
...(options || {})
})
}
export async function patchPub(
query: {
material_id: string
},
body: {
name?: string
category?: string
brand?: string
factory?: string
spec?: string
eco_level?: ECO_LEVEL
display?: number
methanal_min_be?: number
methanal_be_area?: number
methanal_be_roc?: number
tvoc_min_be?: number
tvoc_be_area?: number
tvoc_be_roc?: number
benzene_min_be?: number
benzene_be_area?: number
benzene_be_roc?: number
toluene_min_be?: number
toluene_be_area?: number
toluene_be_roc?: number
p_xylene_min_be?: number
p_xylene_be_area?: number
p_xylene_be_roc?: number
},
options?: AnyObj
) {
return request<Material>(`/api/mtrl/pub`, {
method: 'PATCH',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: query,
data: body,
...(options || {})
})
}
export async function removePub(
query: {
material_id: Arrayable<string>
},
options?: AnyObj
) {
return request<{ count: number }>('/api/mtrl/pub', {
method: 'DELETE',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: query,
...(options || {})
})
}
export async function search(
type: DATA_TYPE,
query?: {
material_id?: Arrayable<string>
name?: Arrayable<string>
category?: Arrayable<string>
brand?: Arrayable<string>
factory?: Arrayable<string>
spec?: Arrayable<string>
eco_level?: Arrayable<ECO_LEVEL>
unit?: Arrayable<MTRL_UNIT>
display?: number
collected?: number
creator_id?: Arrayable<string>
creator_name?: Arrayable<string>
with_cltrs?: number
sort_key?: Arrayable<string>
sort_val?: Arrayable<string>
group_by_tier?: 0 | 1
page?: number
size?: number
},
options?: AnyObj
) {
if (type === DATA_TYPE.PRIVATE) {
delete query?.display
delete query?.collected
delete query?.with_cltrs
}
return request<Material[]>(`/api/mtrl/${type}`, {
method: 'GET',
headers: {
[HTTP_HEADER.AUTHORIZATION]: getLocalToken()
},
params: query,
...(options || {})
})
}