From 5631f2b1a0b4bf4ff77a9373993d531fde676e6d Mon Sep 17 00:00:00 2001 From: zty Date: Fri, 10 Jul 2026 02:02:00 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9D=90=E6=96=99=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E5=88=86=E7=BA=A7=E4=B8=8E=20Yp=20=E6=8E=92=E5=BA=8F=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92(7=20=E4=BB=BB=E5=8A=A1,=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VCShKevM5prWZhp1kk1iGh --- .../plans/2026-07-10-material-sorting.md | 643 ++++++++++++++++++ 1 file changed, 643 insertions(+) create mode 100644 空气质量预测/源码/docs/superpowers/plans/2026-07-10-material-sorting.md diff --git a/空气质量预测/源码/docs/superpowers/plans/2026-07-10-material-sorting.md b/空气质量预测/源码/docs/superpowers/plans/2026-07-10-material-sorting.md new file mode 100644 index 0000000..707ccbb --- /dev/null +++ b/空气质量预测/源码/docs/superpowers/plans/2026-07-10-material-sorting.md @@ -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` + - `computeYpt(m: BeAreaSource): number | null` + - `type Tier = 'A' | 'B' | 'C'` + - `buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) => Tier | null` + - `annotate(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( + 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 = { 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` 之后加: + +```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 " } +# 综合 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 " } +(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('/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('ypt') + const [groupByTier, setGroupByTier] = useState(false) +``` + +- [ ] **Step 2: 顶部工具区加两个控件** + +在筛选区(约 150-247 行,"重置"按钮所在的容器)内合适位置加入(用已 import 的 antd `Select`、`Switch`;若未 import 则在文件顶部补 `import { Select, Switch } from 'antd'`): + +```tsx +