airpredict/空气质量预测/源码/docs/superpowers/plans/2026-07-10-material-sorting.md

27 KiB
Raw Blame History

材料健康分级与 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 的三个列表接口调用,给每条材料注解 ypthealth_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_areaYp甲醛=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=nullhealth_tier=null,排最后、不分档
  • 三分位口径:全库所有"数据完整"材料(公共+自建合一)按 Ypt 升序,边界索引 ceil(n/3)ceil(2n/3);相同 Ypt 落同档
  • 排序恒升序sort_val=ascsort_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.jsondevDependencies 增加一行(放在字母序合适位置即可):

"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: FAILCannot 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 的 buildTierClassifierannotatecomputeYpt

  • 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.tsconst 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 的 schemasort_key 加 ypt,加 group_by_tier

material.ts 列表接口的 sort_key.enum(约 200-202 行)数组里加入 'ypt';在 propertiessize 之后加:

                group_by_tier: {
                    type: 'number',
                    enum: [0, 1]
                },
  • Step 4: 去掉 Prisma 预分页(关键)

dynSearchutils.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

预期:返回的每条材料含 ypthealth_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 的 loadTierClassifierrankAndPaginate

Files:

  • Modify: 源码/服务端/iapip-svr/src/controllers/material.ts

Interfaces:

  • Consumes: Task 2 的 loadTierClassifierrankAndPaginate

  • 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 替换为:
        const classify = await loadTierClassifier()
        ctx.body = rankAndPaginate(materials, query, classify)

注意保留 with_cltrscollected 注入逻辑不动(在 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. 结尾改为:
        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

预期:两者返回材料均含 ypthealth_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: 后端响应新增 ypthealth_tier

  • Produces: Material 类型含 ypt?: number|null; health_tier?: 'A'|'B'|'C'|nullgetMaterials 参数支持 sort_keygroup_by_tier

  • Step 1: types.tsMaterial 加字段

源码/用户端/iapip-web/src/common/types.tsMaterial 接口里(health_level?: stringsort_order?: number 附近,约 132-133 行)加:

    ypt?: number | null
    health_tier?: 'A' | 'B' | 'C' | null
  • Step 2: services/api/material.tsgetMaterials 支持新参数

找到 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 SelectSwitch;若未 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 拿到 datamaterials 数组)后,若 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 4render 前用一个 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

  1. 顶部出现"排序依据"下拉与"按健康等级分组"开关
  2. 默认综合 Ypt 升序每行有健康档彩色标签A绿/B黄/C红/缺值—)
  3. 打开分组开关,列表按 A→B→C→数据不全 分区,出现档位小标题
  4. 切换"甲醛 Yp",顺序按甲醛 be_area 升序(分组开时为各档内升序)
  5. 打开一个项目 → 空间选材弹窗,同样能看到档标签与排序(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.tsMaterial(对应 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?: stringsort_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: 提交
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