docs: 材料健康分级与 Yp 排序实施计划(7 任务, TDD)

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 02:02:00 -04:00
parent 021d09f33b
commit 5631f2b1a0
1 changed files with 643 additions and 0 deletions

View File

@ -0,0 +1,643 @@
# 材料健康分级与 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 | null`
- `type Tier = 'A' | 'B' | 'C'`
- `buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) => Tier | null`
- `annotate<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` 增加一行(放在字母序合适位置即可):
```json
"vitest": "^1.6.0",
```
并在 `scripts` 增加:
```json
"test": "vitest run",
```
然后安装:
```bash
cd 源码/服务端/iapip-svr && npm install
```
预期:`added N packages`。npmmirror 偶发 ECONNRESET失败重跑。
- [ ] **Step 2: 写失败测试 `test/material-ranking.test.ts`**
```typescript
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`**
```typescript
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: 提交**
```bash
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`)末尾加:
```typescript
import { annotate, buildTierClassifier, computeYpt, Tier } from '../common/material-ranking'
```
- [ ] **Step 2: 在 `materialRouter` 定义后加两个私有辅助**
放在 `material.ts``const materialRouter = new Router()`(约 21 行)之后:
```typescript
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` 的 schemasort_key 加 `ypt`,加 `group_by_tier`**
`material.ts` 列表接口的 `sort_key.enum`(约 200-202 行)数组里加入 `'ypt'`;在 `properties``size` 之后加:
```typescript
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 行),各紧接一行删除分页:
```typescript
delete conditions.take; delete conditions.skip
```
```typescript
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`)替换为:
```typescript
// 统一:注解 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 路径改写):
```powershell
$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: 提交**
```bash
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 起):
1. schema 的 `sort_key.enum``'ypt'`、在 `size` 后加 `group_by_tier`(同 Task 2 Step 3 的两段)
2. `conditions = {...dynSearch(...)}` 之后、`findMany` 之前,加一行去掉 Prisma 预分页:`delete conditions.take; delete conditions.skip`
3. 把结尾 `ctx.body = materials` 替换为:
```typescript
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)`
1. schema 的 `sort_key.enum``'ypt'`、加 `group_by_tier`(同上)
2. `conditions = dynSearch(...)` 之后加 `delete conditions.take; delete conditions.skip`
3. 结尾改为:
```typescript
const selfMaterials = await db.selfMaterial.findMany(conditions as never)
const classify = await loadTierClassifier()
ctx.body = rankAndPaginate(selfMaterials, query, classify)
```
- [ ] **Step 3: 手动验证两个接口**
```powershell
$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: 提交**
```bash
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 行)加:
```typescript
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?`(现有),补充:
```typescript
group_by_tier?: 0 | 1
```
到 query 参数类型里,并确保 `sort_key` 的联合类型包含 `'ypt'`(若是字符串则无需改)。请求透传该参数即可(现有实现已把 query 展开进 request params无需额外代码只补类型
- [ ] **Step 3: 类型检查**
Run: `cd 源码/用户端/iapip-web && npx tsc --noEmit`
Expected: 无与本改动相关的新报错。
- [ ] **Step 4: 提交**
```bash
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 行)加:
```typescript
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'`
```tsx
<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 行)改为携带排序与分组:
```tsx
params={{ type: tab, conditions: filterConditions, sortKey, groupByTier }}
```
`request={async ({ type, conditions, ...other }) => {...}}`(约 484 行)里,调用 `getMaterials` 时把参数带上(在现有传参对象内加):
```typescript
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'`
```tsx
{
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`
```typescript
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` 存在时在名称上方显示一行小标题:
```tsx
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`
1. 顶部出现"排序依据"下拉与"按健康等级分组"开关
2. 默认综合 Ypt 升序每行有健康档彩色标签A绿/B黄/C红/缺值—)
3. 打开分组开关,列表按 A→B→C→数据不全 分区,出现档位小标题
4. 切换"甲醛 Yp",顺序按甲醛 be_area 升序(分组开时为各档内升序)
5. 打开一个项目 → 空间选材弹窗,同样能看到档标签与排序(`space-modal-form` 复用同组件)
- [ ] **Step 7: 提交**
```bash
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` 附近)加:
```typescript
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 15 相同的改动状态、两个控件、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: 提交**
```bash
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