feat(web): 材料库排序依据/分组开关 + 健康档标签与分区

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 03:30:36 -04:00
parent 929a5dc809
commit 8c82d18117
2 changed files with 699 additions and 0 deletions

View File

@ -0,0 +1,698 @@
import { DATA_TYPE } from '@/common/constants'
import { ADMIN_TYPE, AnyObj, ECO_LEVEL, Material } from '@/common/types'
import { searchParams } from '@/common/utils'
import MaterialModalForm from '@/components/material-modal-form'
import PageCard from '@/components/page-card'
import api from '@/services/api'
import {
ActionType,
LightFilter,
ProFormCascader,
ProFormSelect,
ProFormText,
ProTable,
QueryFilter
} from '@ant-design/pro-components'
import { Button, Form, InputNumber, Modal, Popconfirm, Select, Switch, Tag, message } from 'antd'
import { useRef, useState } from 'react'
import { useModel } from '@umijs/max'
import ImportMaterialsModal from '../import-file-modal/materials'
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 [details, setDetails] = useState<Partial<Material>>()
const [selected, setSelected] = useState<Material[] | undefined>()
// 材料排序依据(默认综合 Ypt 升序)与是否按健康档分组显示
const [sortKey, setSortKey] = useState<string>('ypt')
const [groupByTier, setGroupByTier] = useState<boolean>(false)
const { initialState } = useModel('@@initialState')
const cert = initialState?.cert
const isAdmin = !!cert?.is_admin
// 公共材料编辑权限:超管,或具备材料管理权限的管理员(与后端 authz 规则一致)
const canPubMtrlMgmt =
isAdmin &&
(cert?.type === ADMIN_TYPE.SUPER_ADMIN ||
(cert?.type === ADMIN_TYPE.ADMIN && cert?.authz_mtrl_mgmt === 1))
const pageDataRef = useRef<Material[]>([])
// 仅在「手动排序」视图下启用排序控件(避免在按其他字段排序时破坏 sort_order 语义)
const canSort = !modal && tab === DATA_TYPE.PUBLIC && isAdmin && (!filterConditions?.sort || filterConditions?.sort === 'sort_order')
// pageDataRef.current 为全部材料(按 sort_order 升序)。输入目标全局序号(1-based)直接定位:
// 把材料移到目标位置后,对全部材料按新顺序连续重编号(0,1,2,...),保证全局顺序一致、不跨页错乱
const moveSortTo = async (material_id: string, targetPos: number) => {
const list = pageDataRef.current ?? []
const fromIdx = list.findIndex((m) => m.material_id === material_id)
if (fromIdx < 0) return
let toIdx = Math.floor(targetPos) - 1
if (Number.isNaN(toIdx)) return
if (toIdx < 0) toIdx = 0
if (toIdx >= list.length) toIdx = list.length - 1
if (toIdx === fromIdx) return
const arr = [...list]
const [moved] = arr.splice(fromIdx, 1)
arr.splice(toIdx, 0, moved)
const updates: { material_id: string; sort_order: number }[] = []
arr.forEach((m, i) => {
if ((m.sort_order ?? 0) !== i) {
updates.push({ material_id: m.material_id, sort_order: i })
}
})
if (!updates.length) return
try {
await Promise.all(updates.map((u) => api.material.updatePubSort(u)))
message.success('排序已更新')
await tableRef?.current?.reload?.()
} catch (err) {}
}
// 上下箭头:复用 moveSortTo按全局位置上移/下移一格
const moveSort = async (material_id: string, dir: -1 | 1) => {
const list = pageDataRef.current ?? []
const idx = list.findIndex((m) => m.material_id === material_id)
if (idx < 0) return
const ni = idx + dir
if (ni < 0 || ni >= list.length) return
await moveSortTo(material_id, ni + 1)
}
// 取材料在全部数据中的全局序号(0-based),用于排序输入框显示
const globalIndexOf = (material_id: string) => (pageDataRef.current ?? []).findIndex((m) => m.material_id === material_id)
const content = (
<PageCard
title={title}
defActTabKey={DATA_TYPE.PUBLIC}
tabItems={[
{
key: DATA_TYPE.PUBLIC,
label: '公共库'
},
{
key: DATA_TYPE.PRIVATE,
label: '自建库'
}
]}
onTabChange={(key) => setTab(key as DATA_TYPE)}
extra={
!modal && tab === DATA_TYPE.PRIVATE
? [
<ImportMaterialsModal
key="import-modal"
title="导入自建材料"
trigger={<Button></Button>}
onSuccess={async () => {
await tableRef?.current?.reload?.()
}}
/>,
<MaterialModalForm
key="create-modal-form"
title="新建自建材料"
displayDetails
trigger={<Button type="primary"></Button>}
onFinish={async (data) => {
try {
await api.material.createSelf(data)
message.success('新建成功')
await tableRef?.current?.reload?.()
return true
} catch (err) {
return false
}
}}
/>
]
: []
}
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="材料名称" />
{/* 公共库/自建库改由顶部 tab 切换,此处不再需要「类型」筛选 */}
<ProFormCascader
name="category"
label="材料类别"
fieldProps={{
placeholder: '请选择大类/子类(可多选跨类)',
multiple: true,
showSearch: true,
changeOnSelect: true,
maxTagCount: 'responsive'
}}
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 as string[][])?.map((v: string[]) => v.join('/')) ?? undefined })}
/>
<ProFormText name="brand" label="材料品牌" />
<ProFormText name="factory" label="材料厂家" />
<ProFormText name="spec" label="材料规格" />
<ProFormSelect
name="eco_level"
label="环保级别"
options={[{ value: ECO_LEVEL.E0 }, { value: ECO_LEVEL.E1 }, { value: ECO_LEVEL.E2 }]}
mode="multiple"
/>
<ProFormSelect
name="health_level"
label="健康等级"
options={[{ value: 'A' }, { value: 'B' }, { value: 'C' }]}
mode="multiple"
/>
{modal || tab === DATA_TYPE.PUBLIC ? (
<ProFormSelect
name="collected"
label="收藏"
options={[
{ label: '已收藏', value: 1 },
{ label: '未收藏', value: 0 }
]}
/>
) : (
<></>
)}
{isAdmin && (
<ProFormSelect
name="sort"
label="排序"
options={[
{
label: '手动排序',
value: 'sort_order'
},
{
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: '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}
rowSelection={
modal
? {
type: 'checkbox',
hideSelectAll: true,
selectedRowKeys: selected?.map((m) => m.material_id),
onSelect: (mtrl, isSelected) => {
setSelected((pre) => {
if (isSelected) {
if ((pre ?? []).find((m) => m.material_id === mtrl.material_id)) return pre
return [...(pre ?? []), mtrl]
} else {
return (pre ?? []).filter((m) => m.material_id !== mtrl.material_id)
}
})
},
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]
}
})
}
}
})}
scroll={modal ? { y: 'calc(100vh - 400px)' } : undefined}
params={{ type: tab, conditions: filterConditions, sortKey, groupByTier }}
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: 'type',
align: 'center',
width: 80,
render: (_, { material_id }) => <Tag>{material_id.startsWith('P') ? '公共' : '自建'}</Tag>,
hideInTable: !modal
},
{
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_level',
align: 'center',
width: 90,
render: (_, { health_level }) => (health_level ? <Tag color={health_level === 'A' ? 'green' : health_level === 'B' ? 'orange' : 'red'}>{health_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: 'updated_at',
align: 'center',
width: 160,
renderText: (dt) => new Date(dt).toLocaleString('zh-CN'),
search: false
},
{
title: '操作',
valueType: 'option',
align: 'center',
width: !modal && tab === DATA_TYPE.PRIVATE ? 100 : canSort ? 180 : 70,
render: (_, item, idx) => (
<div className="flex justify-center items-center space-x-2">
<Button
type="link"
style={{ padding: 0 }}
onClick={(e) => {
setDetails(item)
e.stopPropagation()
}}
>
</Button>
{canSort ? (() => {
const gIdx = globalIndexOf(item.material_id)
const total = pageDataRef.current?.length ?? 0
return (
<>
<InputNumber
key={`so-${item.material_id}-${gIdx}`}
size="small"
min={1}
max={total || 1}
defaultValue={gIdx + 1}
style={{ width: 56 }}
onClick={(e) => e.stopPropagation()}
onPressEnter={(e) => {
const v = Number((e.target as HTMLInputElement).value)
if (!Number.isNaN(v)) moveSortTo(item.material_id, v)
e.stopPropagation()
}}
onBlur={(e) => {
const v = Number((e.target as HTMLInputElement).value)
if (!Number.isNaN(v)) moveSortTo(item.material_id, v)
}}
/>
<Button
type="link"
style={{ padding: 0 }}
disabled={gIdx <= 0}
onClick={(e) => {
moveSort(item.material_id, -1)
e.stopPropagation()
}}
>
</Button>
<Button
type="link"
style={{ padding: 0 }}
disabled={gIdx >= total - 1}
onClick={(e) => {
moveSort(item.material_id, 1)
e.stopPropagation()
}}
>
</Button>
</>
)
})() : (
''
)}
{!modal && tab === DATA_TYPE.PRIVATE ? (
<Popconfirm
title="确定删除该材料吗?"
okText="确定"
cancelText="取消"
onConfirm={async (e) => {
try {
await api.material.removeSelf({
material_id: item.material_id
})
message.success('删除成功')
await tableRef?.current?.reload?.()
e?.stopPropagation()
} catch (err) {}
}}
>
<Button type="link" style={{ padding: 0 }}>
</Button>
</Popconfirm>
) : (
''
)}
</div>
)
},
{
title: '收藏',
valueType: 'option',
align: 'center',
width: 70,
render: (_, { material_id, collected }) => (
<Button
type="link"
style={{ padding: 0 }}
onClick={async (e) => {
try {
const c = collected ?? 0 ? 0 : 1
await api.material.collectPub({ material_id, c })
message.success(c ? '收藏成功' : '取消成功')
await tableRef.current?.reload?.()
e.stopPropagation()
} catch (err) {}
}}
>
{collected ? '取消' : '收藏'}
</Button>
),
hideInTable: modal || tab === DATA_TYPE.PRIVATE
}
]}
request={async ({ type, conditions, sortKey: skParam, groupByTier: gbtParam, ...other }) => {
// 弹窗与独立页面统一:都按当前 tab(type) 分别查公共库或自建库
try {
const { sort, ...otherConditions } = conditions ?? {}
const sortByCategory = sort === 'category'
const data = await api.material.search(
type,
searchParams(
{
...otherConditions,
...other,
display: 1,
with_cltrs: 1,
// 顶部「排序依据」控件的默认值(如 ypt 升序);若下方筛选区的「排序」被管理员显式选中,
// 下面的第三个参数会覆盖此处的 sort_key/sort_val
sort_key: skParam,
sort_val: 'asc',
group_by_tier: gbtParam ? 1 : 0
},
false,
!sortByCategory && sort
? {
[sort]: sort !== 'updated_at' ? 'ascend' : 'descend'
}
: undefined
)
)
pageDataRef.current = data
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
}
}
}}
rowKey={(data) => data.material_id}
pagination={
!modal
? {
position: ['bottomRight'],
defaultPageSize: 10,
showSizeChanger: true
}
: false
}
/>
<MaterialModalForm
title={
!modal && tab === DATA_TYPE.PUBLIC && canPubMtrlMgmt
? '编辑公共材料'
: `${
!modal
? tab === DATA_TYPE.PUBLIC
? '公共'
: '自建'
: details?.material_id?.startsWith('P')
? '公共'
: '自建'
}`
}
initialValues={details}
displayId
displayDetails={
!modal
? tab === DATA_TYPE.PRIVATE || (tab === DATA_TYPE.PUBLIC && canPubMtrlMgmt)
: !details?.material_id?.startsWith('P')
}
readonly={modal || (tab === DATA_TYPE.PUBLIC && !canPubMtrlMgmt)}
open={details !== undefined}
submitText="保存"
onFinish={
!modal && (tab === DATA_TYPE.PRIVATE || (tab === DATA_TYPE.PUBLIC && canPubMtrlMgmt))
? async (values) => {
const { material_id, ...data } = values
// 公共材料无 unit 字段,剔除以避免后端校验失败
delete (data as AnyObj).unit
try {
if (tab === DATA_TYPE.PUBLIC) {
await api.material.patchPub({ material_id }, data)
} else {
await api.material.patchSelf({ material_id }, data)
}
message.success('修改成功')
await tableRef?.current?.reload?.()
setDetails(undefined)
return true
} catch (err) {
return false
}
}
: undefined
}
onCancel={() => setDetails(undefined)}
/>
</PageCard>
)
return !modal ? (
content
) : (
<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={() => {
setTab(DATA_TYPE.PUBLIC)
setFilterConditions(undefined)
filterForm.resetFields()
setSelected(undefined)
}}
onCancel={modalProps?.onCancel}
>
{content}
</Modal>
)
}
export default MaterialCard

View File

@ -211,6 +211,7 @@ export async function search(
with_cltrs?: number
sort_key?: Arrayable<string>
sort_val?: Arrayable<string>
group_by_tier?: 0 | 1
page?: number
size?: number
},