27 KiB
材料健康分级与 Yp 排序 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让材料库/选材界面能按综合 Ypt 或单污染物 Yp 升序排序,并按全库三分位把材料分成 A/B/C 健康档展示。
Architecture: 后端新增纯函数工具 material-ranking.ts(算 Ypt、按全库三分位定档),被 material.ts 的三个列表接口调用,给每条材料注解 ypt 与 health_tier;前端(用户端 + 管理端)各在材料列表组件加"排序依据下拉 + 分组开关",并显示健康档标签。
Tech Stack: Node 18 / Koa / Prisma 5 / TypeScript / UmiJS 4 + Antd ProTable / vitest(新引入,仅测纯函数)
参见设计文档:docs/superpowers/specs/2026-07-10-material-sorting-design.md
Global Constraints
- Yp =
*_be_area:Yp甲醛=methanal_be_area、YpTVOC=tvoc_be_area、Yp苯=benzene_be_area、Yp甲苯=toluene_be_area、Yp二甲苯=p_xylene_be_area - Ypt 权重固定:甲醛 1.8 / TVOC 0.25 / 苯 1 / 甲苯 0.45 / 二甲苯 0.38
- 缺值规则:5 个 be_area 任一为空 →
ypt=null、health_tier=null,排最后、不分档 - 三分位口径:全库所有"数据完整"材料(公共+自建合一)按 Ypt 升序,边界索引
ceil(n/3)、ceil(2n/3);相同 Ypt 落同档 - 排序恒升序(
sort_val=asc);sort_key取值ypt或某个*_be_area health_level手动字段停用于分组(不删字段、不迁数据)- 后端工作区:
源码/服务端/iapip-svr;用户端:源码/用户端/iapip-web;管理端:源码/管理端/iapip-ms - 本地 shell 为 Git Bash;后端跑在本地 6060、前端 8001(见部署记忆 iapip-run-setup)
Task 1: 后端排序工具 + 单元测试
新增纯函数工具与 vitest。纯函数无 DB/HTTP 依赖,buildTierClassifier 接收"全库材料数组"作参数(DB 查询在调用方做,保持本文件可单测)。
Files:
- Create:
源码/服务端/iapip-svr/src/common/material-ranking.ts - Create:
源码/服务端/iapip-svr/test/material-ranking.test.ts - Modify:
源码/服务端/iapip-svr/package.json(加 vitest devDep 与 test 脚本)
Interfaces:
-
Consumes: 无
-
Produces:
POLL_WEIGHTS: Record<string, number>computeYpt(m: BeAreaSource): number | nulltype Tier = 'A' | 'B' | 'C'buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) => Tier | nullannotate<T extends BeAreaSource>(materials: T[], classify: (ypt: number|null) => Tier|null): (T & { ypt: number|null; health_tier: Tier|null })[]type BeAreaSource = { methanal_be_area?: number|null; tvoc_be_area?: number|null; benzene_be_area?: number|null; toluene_be_area?: number|null; p_xylene_be_area?: number|null }
-
Step 1: 加 vitest 到 package.json
在 源码/服务端/iapip-svr/package.json 的 devDependencies 增加一行(放在字母序合适位置即可):
"vitest": "^1.6.0",
并在 scripts 增加:
"test": "vitest run",
然后安装:
cd 源码/服务端/iapip-svr && npm install
预期:added N packages。npmmirror 偶发 ECONNRESET,失败重跑。
- Step 2: 写失败测试
test/material-ranking.test.ts
import { describe, it, expect } from 'vitest'
import { computeYpt, buildTierClassifier, annotate } from '../src/common/material-ranking'
const M = (methanal: number|null, tvoc: number|null, benzene: number|null, toluene: number|null, pxylene: number|null) => ({
methanal_be_area: methanal, tvoc_be_area: tvoc, benzene_be_area: benzene, toluene_be_area: toluene, p_xylene_be_area: pxylene
})
describe('computeYpt', () => {
it('按权重求和', () => {
// 1*1.8 + 2*0.25 + 3*1 + 4*0.45 + 5*0.38 = 1.8+0.5+3+1.8+1.9 = 9
expect(computeYpt(M(1, 2, 3, 4, 5))).toBeCloseTo(9, 6)
})
it('任一缺值返回 null', () => {
expect(computeYpt(M(1, null, 3, 4, 5))).toBeNull()
expect(computeYpt(M(1, 2, 3, 4, undefined as never))).toBeNull()
})
})
describe('buildTierClassifier', () => {
it('全库三分位: 6 个材料 -> 2A 2B 2C', () => {
// ypt 分别为 1..6(每个只用 methanal 权重 1.8,但相对顺序即 methanal 顺序)
const all = [1, 2, 3, 4, 5, 6].map(v => M(v, 0, 0, 0, 0))
const classify = buildTierClassifier(all)
const ypts = all.map(computeYpt)
expect(ypts.map(classify)).toEqual(['A', 'A', 'B', 'B', 'C', 'C'])
})
it('缺值材料不参与分位、classify(null)=null', () => {
const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0), M(1,null,0,0,0)]
const classify = buildTierClassifier(all)
expect(classify(null)).toBeNull()
})
it('相同 Ypt 落同档(并列不强拆)', () => {
const all = [M(1,0,0,0,0), M(1,0,0,0,0), M(1,0,0,0,0)]
const classify = buildTierClassifier(all)
const tiers = all.map(m => classify(computeYpt(m)))
expect(new Set(tiers).size).toBe(1) // 全相等 -> 同一档
})
it('小 n=1 -> 该材料为 A', () => {
const all = [M(5,0,0,0,0)]
const classify = buildTierClassifier(all)
expect(classify(computeYpt(all[0]))).toBe('A')
})
})
describe('annotate', () => {
it('挂上 ypt 与 health_tier', () => {
const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0)]
const classify = buildTierClassifier(all)
const out = annotate(all, classify)
expect(out[0].health_tier).toBe('A')
expect(out[0].ypt).toBeCloseTo(1.8, 6)
expect(out[2].health_tier).toBe('C')
})
})
- Step 3: 运行测试确认失败
Run: cd 源码/服务端/iapip-svr && npx vitest run test/material-ranking.test.ts
Expected: FAIL(Cannot find module '../src/common/material-ranking')
- Step 4: 写实现
src/common/material-ranking.ts
export type BeAreaSource = {
methanal_be_area?: number | null
tvoc_be_area?: number | null
benzene_be_area?: number | null
toluene_be_area?: number | null
p_xylene_be_area?: number | null
}
export type Tier = 'A' | 'B' | 'C'
// Ypt 权重(固定,勿改)
export const POLL_WEIGHTS = {
methanal: 1.8,
tvoc: 0.25,
benzene: 1,
toluene: 0.45,
p_xylene: 0.38
} as const
// 综合 Ypt;任一 be_area 为 null/undefined 返回 null
export function computeYpt(m: BeAreaSource): number | null {
const vals = [
[m.methanal_be_area, POLL_WEIGHTS.methanal],
[m.tvoc_be_area, POLL_WEIGHTS.tvoc],
[m.benzene_be_area, POLL_WEIGHTS.benzene],
[m.toluene_be_area, POLL_WEIGHTS.toluene],
[m.p_xylene_be_area, POLL_WEIGHTS.p_xylene]
] as [number | null | undefined, number][]
let sum = 0
for (const [v, w] of vals) {
if (v === null || v === undefined) return null
sum += v * w
}
return sum
}
// 用全库材料建三分位分类器;缺值材料不参与
export function buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) => Tier | null {
const sorted = all
.map(computeYpt)
.filter((v): v is number => v !== null)
.sort((a, b) => a - b)
const n = sorted.length
if (n === 0) {
return () => null
}
// 边界索引:前 ceil(n/3) 为 A,接着到 ceil(2n/3) 为 B,其余 C
const aEndIdx = Math.ceil(n / 3) // [0, aEndIdx) => A
const bEndIdx = Math.ceil((2 * n) / 3) // [aEndIdx, bEndIdx) => B
// 用边界处的 Ypt 值作阈值,保证并列同档
const aMax = sorted[Math.min(aEndIdx, n) - 1]
const bMax = sorted[Math.min(bEndIdx, n) - 1]
return (ypt: number | null): Tier | null => {
if (ypt === null) return null
if (ypt <= aMax) return 'A'
if (ypt <= bMax) return 'B'
return 'C'
}
}
// 给材料数组挂上 ypt 与 health_tier
export function annotate<T extends BeAreaSource>(
materials: T[],
classify: (ypt: number | null) => Tier | null
): (T & { ypt: number | null; health_tier: Tier | null })[] {
return materials.map(m => {
const ypt = computeYpt(m)
return { ...m, ypt, health_tier: classify(ypt) }
})
}
- Step 5: 运行测试确认通过
Run: cd 源码/服务端/iapip-svr && npx vitest run test/material-ranking.test.ts
Expected: PASS(全部用例绿)
- Step 6: 提交
cd C:/code && git add "空气质量预测/源码/服务端/iapip-svr/src/common/material-ranking.ts" "空气质量预测/源码/服务端/iapip-svr/test/material-ranking.test.ts" "空气质量预测/源码/服务端/iapip-svr/package.json" "空气质量预测/源码/服务端/iapip-svr/package-lock.json"
git commit -m "feat(material): Ypt 计算与全库三分位健康档工具 + 单测"
Task 2: 接入排序注解到三个列表接口的共享逻辑
在 material.ts 里加一个私有辅助,封装"查全库 be_area → 建分类器 → 注解 → 按 sort_key/分组排序 → 分页",供三个 GET 处理器复用。
Files:
- Modify:
源码/服务端/iapip-svr/src/controllers/material.ts
Interfaces:
-
Consumes: Task 1 的
buildTierClassifier、annotate、computeYpt -
Produces: 文件内私有函数
loadTierClassifier(): Promise<(ypt:number|null)=>('A'|'B'|'C'|null)>—— 查全库公共+自建的 5 个 be_area 列,建分类器rankAndPaginate(materials, query, classify): AnyObj[]—— 注解 + 排序(支持ypt与*_be_area的 sort_key、group_by_tier)+ 分页切片
-
Step 1: 在 material.ts 顶部引入工具
在现有 import 区(material.ts:1-11)末尾加:
import { annotate, buildTierClassifier, computeYpt, Tier } from '../common/material-ranking'
- Step 2: 在
materialRouter定义后加两个私有辅助
放在 material.ts 的 const materialRouter = new Router()(约 21 行)之后:
const BE_AREA_SELECT = {
methanal_be_area: true, tvoc_be_area: true, benzene_be_area: true, toluene_be_area: true, p_xylene_be_area: true
} as const
// 查全库(公共+自建)be_area,建三分位分类器
async function loadTierClassifier(): Promise<(ypt: number | null) => Tier | null> {
const [pub, self] = await Promise.all([
db.publicMaterial.findMany({ select: BE_AREA_SELECT }),
db.selfMaterial.findMany({ select: BE_AREA_SELECT })
])
return buildTierClassifier([...pub, ...self])
}
// 注解 + 排序(ypt / *_be_area, 恒升序; group_by_tier 时先按档) + 分页
const TIER_ORDER: Record<string, number> = { A: 0, B: 1, C: 2 }
function rankAndPaginate(materials: AnyObj[], query: AnyObj, classify: (ypt: number | null) => Tier | null): AnyObj[] {
let list: AnyObj[] = annotate(materials as never, classify)
const sk = query.sort_key as string | undefined
const group = query.group_by_tier == 1 || query.group_by_tier === true
if (sk || group) {
const tierRank = (t: Tier | null) => (t === null ? 3 : TIER_ORDER[t])
list.sort((a, b) => {
if (group) {
const d = tierRank(a.health_tier) - tierRank(b.health_tier)
if (d !== 0) return d
}
const k = sk ?? 'ypt'
const av = a[k], bv = b[k]
// 升序;null 排最后
if (av === null || av === undefined) return bv === null || bv === undefined ? 0 : 1
if (bv === null || bv === undefined) return -1
return av === bv ? 0 : av > bv ? 1 : -1
})
}
if (query.size) {
const page = query.page ?? 1
list = list.slice((page - 1) * query.size, page * query.size)
}
return list
}
- Step 3: 扩展
/api/mtrl的 schema:sort_key 加ypt,加group_by_tier
在 material.ts 列表接口的 sort_key.enum(约 200-202 行)数组里加入 'ypt';在 properties 里 size 之后加:
group_by_tier: {
type: 'number',
enum: [0, 1]
},
- Step 4: 去掉 Prisma 预分页(关键)
dynSearch(utils.ts:153-158)在 query 带 size 时会给返回的 conditions 加 take/skip,使 findMany 只返回一页。ypt/分档排序需要全量过滤结果,否则排序错。因此两个 conditions 生成后(conditions = dynSearch(...) 于约 225 行、selfConditions = dynSearch(...) 于约 257 行),各紧接一行删除分页:
delete conditions.take; delete conditions.skip
delete selfConditions.take; delete selfConditions.skip
(query.page/query.size 仍保留在 query 上,供后面内存分页用。)
- Step 5: 改写
/api/mtrl处理器尾部为统一注解+排序+分页
把该处理器(material.ts:218-294)里"排序和分页"整段(if (pubMtrlQty && selfMtrlQty){...} 到 ctx.body = materials)替换为:
// 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页
const classify = await loadTierClassifier()
ctx.body = rankAndPaginate(materials, query, classify)
并把上方两处 if (!query.sort_key) { conditions.orderBy = [{ sort_order: 'asc' }, { updated_at: 'desc' }] } 保留(作为无排序时的库内默认序),不动。
- Step 6: 手动验证
/api/mtrl(后端已在本地 6060 跑)
先确保本地已登录拿到 token(用管理员或用户),或用一个已知有效 token。执行(PowerShell 里跑,避免 Git Bash 路径改写):
$h = @{ Authorization = "Bearer <token>" }
# 综合 Ypt 升序、分组
(Invoke-WebRequest "http://localhost:8001/api/mtrl?size=5&sort_key=ypt&group_by_tier=1" -Headers $h -UseBasicParsing).Content
预期:返回的每条材料含 ypt、health_tier 字段;group_by_tier=1 时靠前的是 A 档、Ypt 较小者。
- Step 7: 提交
cd C:/code && git add "空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts"
git commit -m "feat(material): /api/mtrl 注解 ypt/health_tier 并支持 ypt 排序与分组"
Task 3: 接入管理端两个接口 /api/mtrl/pub 与 /api/mtrl/self
管理端材料库分别走这两个 GET。复用 Task 2 的 loadTierClassifier 与 rankAndPaginate。
Files:
- Modify:
源码/服务端/iapip-svr/src/controllers/material.ts
Interfaces:
-
Consumes: Task 2 的
loadTierClassifier、rankAndPaginate -
Produces: 无新接口,仅行为增强
-
Step 1:
/api/mtrl/pub处理器接入
在公共材料列表处理器(material.ts 约 1059 起):
- schema 的
sort_key.enum加'ypt'、在size后加group_by_tier(同 Task 2 Step 3 的两段) conditions = {...dynSearch(...)}之后、findMany之前,加一行去掉 Prisma 预分页:delete conditions.take; delete conditions.skip- 把结尾
ctx.body = materials替换为:
const classify = await loadTierClassifier()
ctx.body = rankAndPaginate(materials, query, classify)
注意保留 with_cltrs 的 collected 注入逻辑不动(在 materials 生成后、rankAndPaginate 前已完成即可)。
- Step 2:
/api/mtrl/self处理器接入
在自建材料列表处理器(material.ts 约 1800 起,ctx.body = await db.selfMaterial.findMany(conditions as never)):
- schema 的
sort_key.enum加'ypt'、加group_by_tier(同上) conditions = dynSearch(...)之后加delete conditions.take; delete conditions.skip- 结尾改为:
const selfMaterials = await db.selfMaterial.findMany(conditions as never)
const classify = await loadTierClassifier()
ctx.body = rankAndPaginate(selfMaterials, query, classify)
- Step 3: 手动验证两个接口
$h = @{ Authorization = "Bearer <admin-token>" }
(Invoke-WebRequest "http://localhost:8001/api/mtrl/pub?size=5&sort_key=ypt&group_by_tier=1" -Headers $h -UseBasicParsing).Content
(Invoke-WebRequest "http://localhost:8001/api/mtrl/self?size=5&sort_key=ypt" -Headers $h -UseBasicParsing).Content
预期:两者返回材料均含 ypt、health_tier。
- Step 4: 提交
cd C:/code && git add "空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts"
git commit -m "feat(material): 管理端 /api/mtrl/pub|self 接入 ypt 排序与分组"
Task 4: 用户端类型与 API 层
Files:
- Modify:
源码/用户端/iapip-web/src/common/types.ts - Modify:
源码/用户端/iapip-web/src/services/api/material.ts
Interfaces:
-
Consumes: 后端响应新增
ypt、health_tier -
Produces:
Material类型含ypt?: number|null; health_tier?: 'A'|'B'|'C'|null;getMaterials参数支持sort_key、group_by_tier -
Step 1:
types.ts的Material加字段
在 源码/用户端/iapip-web/src/common/types.ts 的 Material 接口里(health_level?: string、sort_order?: number 附近,约 132-133 行)加:
ypt?: number | null
health_tier?: 'A' | 'B' | 'C' | null
- Step 2:
services/api/material.ts的getMaterials支持新参数
找到 getMaterials(约 240-262 行,request<Material[]>('/api/mtrl', {...}))。确认其 query 类型含 sort_key?、sort_val?、page?、size?(现有),补充:
group_by_tier?: 0 | 1
到 query 参数类型里,并确保 sort_key 的联合类型包含 'ypt'(若是字符串则无需改)。请求透传该参数即可(现有实现已把 query 展开进 request params,无需额外代码,只补类型)。
- Step 3: 类型检查
Run: cd 源码/用户端/iapip-web && npx tsc --noEmit
Expected: 无与本改动相关的新报错。
- Step 4: 提交
cd C:/code && git add "空气质量预测/源码/用户端/iapip-web/src/common/types.ts" "空气质量预测/源码/用户端/iapip-web/src/services/api/material.ts"
git commit -m "feat(web): Material 增 ypt/health_tier 类型, getMaterials 支持 group_by_tier"
Task 5: 用户端 material-card —— 控件 + 档标签 + 分组显示
material-card 同时被材料库页(pages/material)与选材弹窗(space-modal-form)复用,改一处覆盖两处。
Files:
- Modify:
源码/用户端/iapip-web/src/components/material-card/index.tsx
Interfaces:
-
Consumes: Task 4 的类型与 API
-
Produces: UI 行为(排序依据下拉、分组开关、健康档列/分区)
-
Step 1: 加状态
在组件内现有 useState 区(约 31-36 行)加:
const [sortKey, setSortKey] = useState<string>('ypt')
const [groupByTier, setGroupByTier] = useState<boolean>(false)
- Step 2: 顶部工具区加两个控件
在筛选区(约 150-247 行,"重置"按钮所在的容器)内合适位置加入(用已 import 的 antd Select、Switch;若未 import 则在文件顶部补 import { Select, Switch } from 'antd'):
<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 style={{ marginLeft: 8 }}>
按健康等级分组 <Switch size="small" checked={groupByTier}
onChange={(c) => { setGroupByTier(c); tableRef.current?.reload() }} />
</span>
- Step 3: 把控件值透传进 ProTable 请求
ProTable 的 params(约 303 行)改为携带排序与分组:
params={{ type: tab, conditions: filterConditions, sortKey, groupByTier }}
在 request={async ({ type, conditions, ...other }) => {...}}(约 484 行)里,调用 getMaterials 时把参数带上(在现有传参对象内加):
sort_key: other.sortKey,
sort_val: 'asc',
group_by_tier: other.groupByTier ? 1 : 0,
- Step 4: 加"健康档"列(带颜色标签)
在 columns(约 304 行起)数组开头("材料ID"之前或之后)加一列(Tag 若未 import 则补 import { Tag } from 'antd'):
{
title: '健康档',
dataIndex: 'health_tier',
width: 70,
align: 'center',
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>
}
},
- Step 5: 分组分区标题(分组开时)
分组开时后端已按档排序,前端在档变化处插入分区标题行。实现:在 ProTable 的 request 拿到 data(materials 数组)后,若 groupByTier,在返回前把数据用 rowClassName + 首行档标记渲染。最简做法:给 ProTable 增加 rowClassName,并用一列渲染分区头。
具体:在 request 返回 { data, ... } 之前,若 other.groupByTier,为每个"该档第一条"记录打标 __tierHead = health_tier:
if (other.groupByTier) {
let last: string | null | undefined = undefined
data.forEach((m: Material) => {
if (m.health_tier !== last) { (m as never as { __tierHead?: string })['__tierHead'] = m.health_tier ?? '数据不全'; last = m.health_tier }
})
}
并在"健康档"列(Step 4)的 render 前用一个 title 渲染分区头——或在"材料名称"列 render 里,当 m.__tierHead 存在时在名称上方显示一行小标题:
render: (_, m) => (
<>
{(m as never as { __tierHead?: string }).__tierHead && (
<div style={{ fontWeight: 600, color: '#888', margin: '4px 0' }}>
{'档位 ' + (m as never as { __tierHead?: string }).__tierHead}
</div>
)}
<span>{m.name}</span>
</>
)
(把此 render 合并进现有"材料名称"列的 render;若原列无 render,则新增。分组关时 __tierHead 不存在,不显示标题,行为不变。)
- Step 6: 本地验证(浏览器)
前端已在 8001 跑。打开 http://localhost:8001/material:
- 顶部出现"排序依据"下拉与"按健康等级分组"开关
- 默认综合 Ypt 升序,每行有健康档彩色标签(A绿/B黄/C红/缺值—)
- 打开分组开关,列表按 A→B→C→数据不全 分区,出现档位小标题
- 切换"甲醛 Yp",顺序按甲醛 be_area 升序(分组开时为各档内升序)
- 打开一个项目 → 空间选材弹窗,同样能看到档标签与排序(
space-modal-form复用同组件)
- Step 7: 提交
cd C:/code && git add "空气质量预测/源码/用户端/iapip-web/src/components/material-card/index.tsx"
git commit -m "feat(web): 材料库排序依据/分组开关 + 健康档标签与分区"
Task 6: 管理端 material-card —— 同用户端
管理端结构与用户端一致(components/material-card + pages/material),但 API 走 /api/mtrl/${type}(pub/self)。
Files:
- Modify:
源码/管理端/iapip-ms/src/common/types.ts - Modify:
源码/管理端/iapip-ms/src/services/api/material.ts - Modify:
源码/管理端/iapip-ms/src/components/material-card/index.tsx
Interfaces:
-
Consumes: 后端 pub/self 接口(Task 3)
-
Produces: 管理端材料库的排序/分组 UI
-
Step 1: 类型加字段
在 源码/管理端/iapip-ms/src/common/types.ts 的 Material(对应 health_level/sort_order 附近)加:
ypt?: number | null
health_tier?: 'A' | 'B' | 'C' | null
- Step 2: API 层加参数
在 源码/管理端/iapip-ms/src/services/api/material.ts 的材料列表函数(约 147 行 request<Material[]>('/api/mtrl/${type}', {...}))的 query 类型补 sort_key?: string、sort_val?: 'asc'|'desc'、group_by_tier?: 0|1(若已有 sort_key 则只补 group_by_tier),请求透传。
- Step 3: material-card 加控件/档标签/分区
对 源码/管理端/iapip-ms/src/components/material-card/index.tsx 施加与 Task 5 Step 1–5 相同的改动(状态、两个控件、params 透传、健康档列、分区标题)。管理端此组件的 ProTable/请求函数结构与用户端一致;把 getMaterials 换成管理端对应的列表函数名(读该文件确认,通常同名 getMaterials),其余照搬。
- Step 4: 类型检查
Run: cd 源码/管理端/iapip-ms && npx tsc --noEmit
Expected: 无与本改动相关的新报错。
- Step 5: 本地验证
管理端本地未起时可临时 cd 源码/管理端/iapip-ms && PORT=8002 pnpm dev 起一份(或跳过,留待部署后在 8081/iapip-ms 验证)。验证材料库页出现控件、档标签、分组分区。
- Step 6: 提交
cd C:/code && git add "空气质量预测/源码/管理端/iapip-ms/src/common/types.ts" "空气质量预测/源码/管理端/iapip-ms/src/services/api/material.ts" "空气质量预测/源码/管理端/iapip-ms/src/components/material-card/index.tsx"
git commit -m "feat(ms): 管理端材料库排序依据/分组 + 健康档标签"
Task 7: 端到端验证
Files: 无(纯验证)
- Step 1: 后端单测通过
Run: cd 源码/服务端/iapip-svr && npx vitest run
Expected: PASS。
- Step 2: 后端类型编译通过
Run: cd 源码/服务端/iapip-svr && npm run build
Expected: 无新的类型报错(audit.ts 的既有 @types/node Buffer 报错属既知无害,不算新增)。
-
Step 3: 用户端全流程(浏览器 8001)
-
材料库页:排序依据切换(综合/各污染物)× 分组开关 的四种组合均正确
-
健康档标签配色正确;缺 be_area 的材料标"—"且排最后
-
选材弹窗复用同行为
-
全库口径:同一材料在库页与选材弹窗看到的
health_tier一致 -
Step 4: 三分位正确性抽查(可选)
用 Task 2 Step 5 的接口拉全量(size 设大),核对 A/B/C 数量约各占 1/3(完整数据材料集内),且 A 档 Ypt 均 ≤ B 档 ≤ C 档。
部署(本功能完成后)
本地验证通过后,按部署记忆 iapip-deploy-server.md 的"一键更新命令"推到第二套线上系统(后端 build+nssm restart iapip-svr2,前端 build+scp)。