From 5a6945a0623faf887c8ec7606c404ac4e20d3446 Mon Sep 17 00:00:00 2001 From: zty Date: Tue, 7 Jul 2026 21:31:49 -0400 Subject: [PATCH] feat(source): integrate whole-home bar chart and contribution pie --- .../iapip-web/src/pages/source/index.tsx | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx diff --git a/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx b/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx new file mode 100644 index 0000000..7b4d4e1 --- /dev/null +++ b/空气质量预测/源码/用户端/iapip-web/src/pages/source/index.tsx @@ -0,0 +1,341 @@ +import { useEffect, useMemo, useState } from 'react' +import api from '@/services/api' +import { DATA_TYPE } from '@/common/constants' +import { ApcLimitStandard, Material, MTRL_UNIT, Space } from '@/common/types' +import { PRESET_ROOMS } from './rooms' +import RoomBarChart, { RoomBar } from './RoomBarChart' +import ContribPie from './ContribPie' +import './source.css' + +type Pollutant = 'methanal' | 'tvoc' | 'benzene' | 'toluene' | 'p_xylene' +const POLLUTANTS: Pollutant[] = ['methanal', 'tvoc', 'benzene', 'toluene', 'p_xylene'] +const LABELS: Record = { methanal: '甲醛', tvoc: 'TVOC', benzene: '苯', toluene: '甲苯', p_xylene: '二甲苯' } +const POLLUTANT_COLORS = ['#2AA9A0', '#3B7DD8', '#8B5CF6', '#E8A13A', '#C2569B'] +const PIE_COLORS = ['#C0392B', '#E67E22', '#E0A100', '#159A86', '#2E7FC1', '#6FB1E0', '#9AA7B4'] + +const get = (obj: any, key: string) => obj?.[key] +const fmt = (n?: number) => Number(n ?? 0).toFixed(3) + +const CheckIcon = () => ( + +) +const BulbIcon = () => ( + +) + +interface MatRow { id: string; name: string; area: number; on: boolean } + +export default function SourceTracing() { + const [loading, setLoading] = useState(true) + const [matMap, setMatMap] = useState>({}) + const [standards, setStandards] = useState([]) + const [standardId, setStandardId] = useState('') + const [roomId, setRoomId] = useState('demo') + const [area, setArea] = useState(19.8) + const [height, setHeight] = useState(3) + const [temperature, setTemperature] = useState(22) + const [humidity, setHumidity] = useState(45) + const [ventilationRate, setVentilationRate] = useState(0.5) + const [mats, setMats] = useState([]) + const [pol, setPol] = useState('methanal') + const [result, setResult] = useState(null) + const [requiredACH, setRequiredACH] = useState(0.5) + const [homeStats, setHomeStats] = useState([]) + + const standard = standards.find(s => s.id === standardId) + const volume = +(area * height).toFixed(2) + + const matLabel = (m?: Material) => m?.name || m?.category || m?.material_id || '' + + function loadRoom(id: string, map: Record = matMap) { + const room = PRESET_ROOMS.find(r => r.id === id) + if (!room) return + setRoomId(id) + setArea(room.area); setHeight(room.height) + setTemperature(room.temperature); setHumidity(room.humidity) + setVentilationRate(room.ventilationRate) + setMats(room.materials.map(m => ({ id: m.id, name: matLabel(map[m.id]), area: m.a, on: true }))) + } + + useEffect(() => { + (async () => { + try { + const [stds, matsRes] = await Promise.all([ + api.config.getAPCLmtStdList(), + api.material.search(DATA_TYPE.PUBLIC, { size: 300 }) + ]) + setStandards(stds) + const def = stds.find(s => s.id.includes('GB50325')) ?? stds[0] + if (def) setStandardId(def.id) + const map: Record = {} + matsRes.forEach(m => { map[m.material_id] = m }) + setMatMap(map) + loadRoom('demo', map) + + const gb = stds.find(s => s.id.includes('GB50325')) ?? def + if (gb) { + const settled = await Promise.allSettled( + PRESET_ROOMS.map(r => api.predict.space(buildRoomPayload(r, gb, map))) + ) + const stats: RoomBar[] = [] + settled.forEach((res, idx) => { + if (res.status === 'fulfilled') { + stats.push({ + name: PRESET_ROOMS[idx].name, + pct: POLLUTANTS.map(p => { + const c = get(res.value, `${p}_c`) + const lim = get(gb, `${p}_c_lmt`) + return (typeof lim === 'number' && lim > 0 && typeof c === 'number') ? (c / lim * 100) : null + }) + }) + } + }) + setHomeStats(stats) + } + } catch (e) { + // 静默:接口异常时页面保持空态 + } finally { + setLoading(false) + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function buildPayload(ventRate = ventilationRate) { + const selected = mats.filter(m => m.on && matMap[m.id]) + return { + id: 'src', project_id: 'src', name: roomId, + height, area, + env_temp: temperature, env_hum: humidity, env_vent_rate: ventRate, + methanal_c_lmt: standard?.methanal_c_lmt, + tvoc_c_lmt: standard?.tvoc_c_lmt, + benzene_c_lmt: standard?.benzene_c_lmt, + toluene_c_lmt: standard?.toluene_c_lmt, + p_xylene_c_lmt: standard?.p_xylene_c_lmt, + materials: selected.map((m, i) => ({ + ...matMap[m.id], + id: i + 1, project_id: 'src', space_id: 'src', + unit: MTRL_UNIT.AREA, qty: m.area + })) + } + } + + async function runPredict(ventRate = ventilationRate): Promise { + return await api.predict.space(buildPayload(ventRate)) + } + + function buildRoomPayload(room: typeof PRESET_ROOMS[number], std: ApcLimitStandard, map: Record) { + return { + id: 'src', project_id: 'src', name: room.id, + height: room.height, area: room.area, + env_temp: room.temperature, env_hum: room.humidity, env_vent_rate: room.ventilationRate, + methanal_c_lmt: std.methanal_c_lmt, + tvoc_c_lmt: std.tvoc_c_lmt, + benzene_c_lmt: std.benzene_c_lmt, + toluene_c_lmt: std.toluene_c_lmt, + p_xylene_c_lmt: std.p_xylene_c_lmt, + materials: room.materials.filter(m => map[m.id]).map((m, i) => ({ + ...map[m.id], id: i + 1, project_id: 'src', space_id: 'src', + unit: MTRL_UNIT.AREA, qty: m.a + })) + } + } + + // 输入变化 → 防抖预测 + useEffect(() => { + if (!standard || !mats.length) return + // 面积/层高为 0 时体积为 0,后端会校验「空间体积」失败;改值过程中的瞬时空值跳过本次预测 + if (!(area > 0) || !(height > 0)) return + const t = setTimeout(() => { runPredict().then(setResult).catch(() => {}) }, 300) + return () => clearTimeout(t) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [area, height, temperature, humidity, ventilationRate, standardId, mats, standard]) + + const anyOver = useMemo( + () => !!result && POLLUTANTS.some(p => get(result, `${p}_c_exceed`)), + [result] + ) + const overNames = useMemo( + () => POLLUTANTS.filter(p => get(result, `${p}_c_exceed`)).map(p => LABELS[p]).join('、'), + [result] + ) + const topSourceNames = useMemo(() => { + if (!result) return '—' + const ids = new Set() + POLLUTANTS.forEach(p => { + result.materials?.forEach(m => { if (get(m, `is_${p}_ps`)) ids.add(m.material_id) }) + }) + return [...ids].map(id => matLabel(matMap[id])).join('、') || '—' + }, [result, matMap]) + + const ranked = useMemo(() => { + if (!result?.materials) return [] + return result.materials + .map(m => ({ id: m.material_id, name: matLabel(matMap[m.material_id]) || m.material_id, rate: get(m, `${pol}_cr`) ?? 0, isSrc: !!get(m, `is_${pol}_ps`) })) + .filter((x: any) => x.rate > 0) + .sort((a: any, b: any) => b.rate - a.rate) + }, [result, pol, matMap]) + + // 超标时反推达标所需最小换气率 + useEffect(() => { + if (!result) return + if (!anyOver) { setRequiredACH(ventilationRate); return } + let cancelled = false + ;(async () => { + for (let ach = +(Math.round(ventilationRate * 10) / 10 + 0.1).toFixed(1); ach <= 3.001; ach = +(ach + 0.1).toFixed(1)) { + try { + const r = await runPredict(ach) + if (!POLLUTANTS.some(p => get(r, `${p}_c_exceed`))) { + if (!cancelled) setRequiredACH(+ach.toFixed(1)) + return + } + } catch { /* 忽略单次失败 */ } + } + if (!cancelled) setRequiredACH(3) + })() + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [result]) + + if (loading) { + return
加载材料库…
+ } + + return ( +
+
+
+ {/* 输入 */} +
+
+
房间与材料输入
+ 输入 +
+ +
+
选择样板间
+
+ {PRESET_ROOMS.map(r => ( +
loadRoom(r.id)}>{r.name}
+ ))} +
+
+ +
+
+
面积
setArea(parseFloat(e.target.value) || 0)} />
+
层高
setHeight(parseFloat(e.target.value) || 0)} />m
+
+
+
+
+
温度
setTemperature(parseFloat(e.target.value) || 0)} />
+
湿度
setHumidity(parseFloat(e.target.value) || 0)} />%rh
+
+
+ +
+
通风换气率 {(+ventilationRate).toFixed(1)} 次/h
+ setVentilationRate(+e.target.value)} /> +
0.3 密闭1.0 一般3.0 强通风
+
+ +
+
限值标准 {standardId}
+
+ +
+
+ +
+
装修材料 体积 V={volume.toFixed(1)} m³ · 勾选计入、填用量
+
+ {mats.map((m, idx) => ( +
+
setMats(prev => prev.map((x, i) => i === idx ? { ...x, on: !x.on } : x))}>{m.on && }
+
{m.name}
{matMap[m.id]?.category}
+
setMats(prev => prev.map((x, i) => i === idx ? { ...x, area: +e.target.value || 0 } : x))} />
+
+ ))} +
+
+
+ + {/* 结果 */} +
+
+
全屋污染概览 · 各房间占 GB50325 限值百分比
概览
+ LABELS[p])} colors={POLLUTANT_COLORS} /> +
+
+
识别结论 · 5 项污染物
结果
+ + + + {POLLUTANTS.map(p => { + const over = !!get(result, `${p}_c_exceed`) + return ( + + + + + + + ) + })} + +
污染物预测浓度限值({standardId})判定
{LABELS[p]}{fmt(get(result, `${p}_c`))} mg/m³{get(standard, `${p}_c_lmt`) ?? '—'}{over ? '超标' : '达标'}
+
+ +
+
+
公式溯源 · 各材料贡献
+
+ {POLLUTANTS.map(p => setPol(p)}>{LABELS[p]})} +
+
+ ({ name: c.name, rate: c.rate, isSrc: c.isSrc }))} colors={PIE_COLORS} /> +
+ +
+
整改建议
+
+
+
+
{anyOver ? `${overNames} 超标` : '当前方案全部达标 ✓'}
+ {anyOver ? ( + <> +
主要污染源:{topSourceNames}。可提升通风换气率,或将这些材料替换为更低释放量的环保产品。
+
+
优先选用 E0 / E1 级认证材料,同类产品甲醛释放量可降低 60%–90%,从源头减少污染。
+
专业版支持多空间联合预测、污染贡献溯源及合规 PDF 报告,适用于工程验收与交付。
+
+
+ + 浏览环保材料库 + 使用专业版 → +
+ + ) : ( + <> +
各污染物均低于 {standardId} 限值。建议入住前仍保持通风并复测确认。
+
+
需要多空间联合分析、污染贡献溯源或合规 PDF 报告?专业版提供完整预测与工程验收支持。
+
+ + + )} +
+
+
+
+
+
+
+ ) +} \ No newline at end of file