feat(phase4): C端污染源识别接真实材料库+统一引擎

- seed 官方算例6种真实材料(PM2000000x,真实Y0/Yp/B 5污染物)
- 预设样板间(rooms.ts)指向真实材料库;标准间复现官方算例
- SourceTracing 重写:从材料库拉真实参数,客户端调 predictSpace 统一引擎
  实时算5项污染物浓度/超标/各材料贡献溯源/污染源标红/整改建议
  (含按引擎反推的"提高通风至X次/h")
- 实测标准间复现算例:甲醛0.123(PDF0.12),家具贡献90.2%

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zty 2026-06-12 10:50:18 +08:00
parent efb537e966
commit 3437a6d8f5
3 changed files with 256 additions and 129 deletions

View File

@ -62,6 +62,38 @@ async function main() {
} }
console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`); console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`);
// 官方算例 6 种材料(真实 Y0/Yp/B5 污染物),供 C 端样板间 + 复现算例
const ep5 = (
hcho: number[], tvoc: number[], benzene: number[], toluene: number[], xylene: number[],
) => ({
hcho: { y0: hcho[0], yp: hcho[1], b: hcho[2] },
tvoc: { y0: tvoc[0], yp: tvoc[1], b: tvoc[2] },
benzene: { y0: benzene[0], yp: benzene[1], b: benzene[2] },
toluene: { y0: toluene[0], yp: toluene[1], b: toluene[2] },
xylene: { y0: xylene[0], yp: xylene[1], b: xylene[2] },
});
const REAL_MATERIALS = [
{ id: 'PM20000001', name: '多层实木复合地板', category: '木地板/实木地板', brand: '示例', healthGrade: 'B', ep: ep5([0.09, 0.4, 0.47], [0.074, 0.7, 0.205], [0.03, 0.186, 0.265], [0, 0, 0], [0, 0, 0]) },
{ id: 'PM20000002', name: '踢脚线', category: '其他', brand: '示例', healthGrade: 'C', ep: ep5([0.38, 2.3, 0.2], [0.25, 1.69, 0.113], [0.053, 0.446, 0.09], [0.05, 0.229, 0.1], [0.009, 0.35, 0.085]) },
{ id: 'PM20000003', name: '吸音板', category: '其他', brand: '示例', healthGrade: 'B', ep: ep5([0, 0, 0], [0.24, 1.71, 0.09], [0, 0, 0], [0, 0, 0], [0, 0, 0]) },
{ id: 'PM20000004', name: '乳胶漆涂料', category: '涂料/墙面漆', brand: '示例', healthGrade: 'A', ep: ep5([0, 0, 0], [0.04, 0.337, 0.288], [0, 0, 0], [0, 0, 0], [0, 0, 0]) },
{ id: 'PM20000005', name: '免漆木门', category: '其他', brand: '示例', healthGrade: 'C', ep: ep5([0, 0, 0], [0.46, 3.05, 0.132], [0.07, 0.53, 0.27], [0.05, 0.41, 0.29], [0.194, 1.24, 0.223]) },
{ id: 'PM20000006', name: '人造板家具', category: '家具', brand: '示例', healthGrade: 'C', ep: ep5([0.45, 2.63, 0.446], [0.14, 0.88, 0.36], [0, 0, 0], [0.08, 0.5, 0.39], [0, 0, 0]) },
];
for (let i = 0; i < REAL_MATERIALS.length; i++) {
const m = REAL_MATERIALS[i];
await prisma.material.upsert({
where: { id: m.id },
update: { emissionParams: m.ep, healthGrade: m.healthGrade },
create: {
id: m.id, name: m.name, category: m.category, brand: m.brand,
healthGrade: m.healthGrade, sortOrder: 1000 + i, usageUnit: 'm²',
emissionParams: m.ep, isPublic: true,
},
});
}
console.log(`已导入 ${REAL_MATERIALS.length} 条官方算例真实材料(PM2000000x)`);
// 一条公共项目模板(含 1 个空间 + 2 种材料),供模板库展示 // 一条公共项目模板(含 1 个空间 + 2 种材料),供模板库展示
const tplId = 'T13000001'; const tplId = 'T13000001';
await prisma.project.upsert({ await prisma.project.upsert({

View File

@ -0,0 +1,81 @@
// C 端污染源识别的预设样板间。材料指向真实材料库(PM2000000x官方算例真实参数)。
export interface PresetMat {
id: string;
a: number; // 使用面积 m²
}
export interface PresetRoom {
id: string;
name: string;
area: number;
height: number;
temperature: number;
humidity: number;
ventilationRate: number;
materials: PresetMat[];
}
export const PRESET_ROOMS: PresetRoom[] = [
{
id: 'demo',
name: '标准间(官方算例)',
area: 19.8,
height: 3,
temperature: 22,
humidity: 45,
ventilationRate: 0.5,
materials: [
{ id: 'PM20000001', a: 20 },
{ id: 'PM20000002', a: 1.8 },
{ id: 'PM20000003', a: 20 },
{ id: 'PM20000004', a: 51.6 },
{ id: 'PM20000005', a: 3.6 },
{ id: 'PM20000006', a: 17.4 },
],
},
{
id: 'zw',
name: '主卧',
area: 18,
height: 2.7,
temperature: 26,
humidity: 50,
ventilationRate: 0.5,
materials: [
{ id: 'PM20000001', a: 18 },
{ id: 'PM20000002', a: 1.6 },
{ id: 'PM20000006', a: 25 },
{ id: 'PM20000004', a: 40 },
],
},
{
id: 'kt',
name: '客厅',
area: 30,
height: 2.85,
temperature: 26,
humidity: 50,
ventilationRate: 0.6,
materials: [
{ id: 'PM20000001', a: 30 },
{ id: 'PM20000004', a: 55 },
{ id: 'PM20000005', a: 6 },
{ id: 'PM20000006', a: 14 },
{ id: 'PM20000003', a: 30 },
],
},
{
id: 'etf',
name: '儿童房',
area: 14,
height: 2.7,
temperature: 26,
humidity: 55,
ventilationRate: 0.5,
materials: [
{ id: 'PM20000001', a: 14 },
{ id: 'PM20000006', a: 32 },
{ id: 'PM20000004', a: 30 },
{ id: 'PM20000002', a: 1.4 },
],
},
];

View File

@ -3,50 +3,56 @@
<div class="s-top"> <div class="s-top">
<a class="s-back" @click="router.push('/landing')"><ChevronL />返回首页</a> <a class="s-back" @click="router.push('/landing')"><ChevronL />返回首页</a>
<div class="s-logo"><SourceIcon /></div> <div class="s-logo"><SourceIcon /></div>
<div class="s-tt">污染源识别<small>SOURCE TRACING · 甲醛</small></div> <div class="s-tt">污染源识别<small>SOURCE TRACING · 5 项污染物</small></div>
<div class="s-spacer" /> <div class="s-spacer" />
<a class="s-back" @click="router.push('/home')" style="margin-right:12px">进入专业系统 </a> <a class="s-back" @click="router.push('/home')" style="margin-right:12px">进入专业系统 </a>
<span class="muted">{{ auth.org?.name || '访客' }}</span> <span class="muted">{{ auth.org?.name || '访客' }}</span>
</div> </div>
<div class="s-body"> <div class="s-body">
<div class="s-grid"> <div v-if="loading" class="muted" style="padding:40px;text-align:center">加载材料库</div>
<div v-else class="s-grid">
<!-- 输入 --> <!-- 输入 -->
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t"><span class="bar" />房间与材料输入</div><span class="card-step">输入</span></div> <div class="card-h"><div class="card-t"><span class="bar" />房间与材料输入</div><span class="card-step">输入</span></div>
<div class="fld"> <div class="fld">
<div class="fld-lab">选择</div> <div class="fld-lab">选择样板</div>
<div class="rooms"> <div class="rooms">
<div v-for="(r, id) in ROOMS" :key="id" class="room-b" :class="{ on: st.roomId === id }" @click="setRoom(id)">{{ r.name }}</div> <div v-for="r in rooms" :key="r.id" class="room-b" :class="{ on: roomId === r.id }" @click="loadRoom(r.id)">{{ r.name }}</div>
</div> </div>
</div> </div>
<div class="fld"> <div class="fld">
<div class="row2"> <div class="row2">
<div><div class="fld-lab">房间面积</div><div class="num"><input type="number" :value="st.area" @input="set('area', +($event.target as HTMLInputElement).value)" /><span class="unit"></span></div></div> <div><div class="fld-lab">面积</div><div class="num"><input type="number" v-model.number="area" /><span class="unit"></span></div></div>
<div><div class="fld-lab">层高</div><div class="num"><input type="number" step="0.1" :value="st.h" @input="set('h', +($event.target as HTMLInputElement).value)" /><span class="unit">m</span></div></div> <div><div class="fld-lab">层高</div><div class="num"><input type="number" step="0.1" v-model.number="height" /><span class="unit">m</span></div></div>
</div>
</div>
<div class="fld">
<div class="row2">
<div><div class="fld-lab">温度</div><div class="num"><input type="number" v-model.number="temperature" /><span class="unit"></span></div></div>
<div><div class="fld-lab">湿度</div><div class="num"><input type="number" v-model.number="humidity" /><span class="unit">%rh</span></div></div>
</div> </div>
</div> </div>
<div class="fld"> <div class="fld">
<div class="fld-lab">通风换气率 <span class="v">{{ (+st.n).toFixed(1) }} /h</span></div> <div class="fld-lab">通风换气率 <span class="v">{{ (+ventilationRate).toFixed(1) }} /h</span></div>
<input class="slider" type="range" min="0.3" max="3" step="0.1" :value="st.n" @input="set('n', +($event.target as HTMLInputElement).value)" /> <input class="slider" type="range" min="0.3" max="3" step="0.1" v-model.number="ventilationRate" />
<div class="slider-scale"><span>0.3 密闭</span><span>1.0 一般</span><span>3.0 强通风</span></div> <div class="slider-scale"><span>0.3 密闭</span><span>1.0 一般</span><span>3.0 强通风</span></div>
</div> </div>
<div class="fld" style="margin-bottom:0"> <div class="fld" style="margin-bottom:0">
<div class="fld-lab">装修材料清单 <span class="muted">勾选计入 · 填写用量()</span></div> <div class="fld-lab">装修材料 <span class="muted">体积 V={{ volume.toFixed(1) }} · 勾选计入填用量</span></div>
<div class="mats"> <div class="mats">
<div v-for="(m, id) in st.mats" :key="id" class="mat" :class="{ off: !m.on }"> <div v-for="m in mats" :key="m.id" class="mat" :class="{ off: !m.on }">
<div class="mat-chk" :class="{ on: m.on }" @click="toggleMat(id)"><CheckIcon v-if="m.on" /></div> <div class="mat-chk" :class="{ on: m.on }" @click="m.on = !m.on"><CheckIcon v-if="m.on" /></div>
<div class="mat-main"> <div class="mat-main">
<div class="mat-nm">{{ MATS[id].name }}<span v-if="st.upg[id]" style="color:var(--accent);font-weight:700"> · 已换E0</span></div> <div class="mat-nm">{{ m.name }}</div>
<div class="mat-cat">{{ MATS[id].cat }} · EF {{ MATS[id].ef }}</div> <div class="mat-cat">{{ matMap[m.id]?.category }}</div>
</div> </div>
<div class="mat-qty"><input type="number" :value="m.A" @input="setQty(id, +($event.target as HTMLInputElement).value)" /><span class="u"></span></div> <div class="mat-qty"><input type="number" v-model.number="m.area" /><span class="u"></span></div>
</div> </div>
<button v-if="addable" class="add-mat" @click="addMat"><PlusIcon />添加材料 · {{ MATS[addable].name }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -54,60 +60,52 @@
<!-- 结果 --> <!-- 结果 -->
<div class="stack"> <div class="stack">
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t"><span class="bar" />识别结论</div><span class="card-step">结果</span></div> <div class="card-h"><div class="card-t"><span class="bar" />识别结论 · 5 项污染物</div><span class="card-step">结果</span></div>
<div class="verdict"> <table class="res-table">
<div class="verd-num"><span class="big" :style="{ color: `var(--${r.level})` }">{{ fmt(r.C) }}</span><span class="u">mg/</span></div> <thead><tr><th>污染物</th><th>预测浓度</th><th>限值({{ standard }})</th><th>判定</th></tr></thead>
<div class="verd-meta"> <tbody>
<span class="chip" :class="'chip-' + r.level">甲醛 {{ LEVELTXT[r.level] }}</span> <tr v-for="p in pollutants" :key="p">
<div class="verd-sub" v-if="r.level === 'good'">低于 GB/T 18883 限值 <b>{{ LIMIT }}</b>,余量 <b>{{ Math.round((1 - r.ratio) * 100) }}%</b></div> <td>{{ labels[p].zh }}</td>
<div class="verd-sub" v-else>超出 GB/T 18883 限值 <b>{{ LIMIT }}</b> <b style="color:var(--bad)">{{ Math.round((r.ratio - 1) * 100) }}%</b>(GB 50325-I 限值 {{ LIMIT2 }})</div> <td :class="{ over: r.exceeded[p] }">{{ fmt(r.concentration[p]) }} mg/</td>
</div> <td class="muted">{{ r.limits[p] }}</td>
</div> <td><span class="chip" :class="r.exceeded[p] ? 'chip-bad' : 'chip-good'">{{ r.exceeded[p] ? '超标' : '达标' }}</span></td>
<div class="gate"> </tr>
<div class="gate-step act">计算预测浓度</div> </tbody>
<span class="gate-arrow"><ArrowS /></span> </table>
<div class="gate-step" :class="r.level === 'good' ? 'act' : 'bad'">{{ r.level === 'good' ? '判定达标' : '判定超标' }}</div>
<span class="gate-arrow"><ArrowS /></span>
<div class="gate-step" :class="{ bad: r.level !== 'good' }">公式溯源污染材料</div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t"><span class="bar" />溯源公式 · 稳态质量平衡</div><span class="card-step">公式</span></div> <div class="card-h">
<div class="formula"> <div class="card-t"><span class="bar" />公式溯源 · 各材料贡献</div>
<div class="eq">C = <span class="frac"><span class="top">Σ ( EFᵢ · Aᵢ )</span><span class="bot">n · V</span></span></div> <div class="pol-seg">
<div class="plug"> <b v-for="p in pollutants" :key="p" :class="{ on: pol === p }" @click="pol = p">{{ labels[p].zh }}</b>
房间体积 V = 面积 × 层高 = <code>{{ fmt(st.area, 0) }} × {{ (+st.h).toFixed(1) }} = {{ fmt(r.V, 1) }} </code> ·
通风量 n·V = <code>{{ (+st.n).toFixed(1) }} × {{ fmt(r.V, 1) }} = {{ fmt(r.nV, 1) }} /h</code><br />
总释放速率 Σ(EF·A) = <code>{{ fmt(r.totalEmis, 2) }} mg/h</code>
C = <code>{{ fmt(r.totalEmis, 2) }} ÷ {{ fmt(r.nV, 1) }} = {{ fmt(r.C) }} mg/</code>
</div> </div>
</div> </div>
</div>
<div class="card">
<div class="card-h"><div><div class="card-t"><span class="bar" />公式溯源 · 各材料甲醛浓度贡献</div><div class="muted" style="margin-top:3px">贡献ᵢ = EFᵢ·Aᵢ /(n·V) · 已按贡献排序</div></div></div>
<div class="contrib"> <div class="contrib">
<div class="cb" v-for="(x, i) in r.items" :key="x.id"> <div class="cb" v-for="(c, i) in ranked" :key="c.id">
<div class="cb-nm"><span class="rk" :style="{ background: col(i) }">{{ i + 1 }}</span>{{ x.name }}</div> <div class="cb-nm">
<div class="cb-track"><div class="cb-fill" :style="{ width: Math.max(3, x.c / maxC * 100) + '%', background: col(i) }" /></div> <span class="rk" :style="{ background: col(i) }">{{ i + 1 }}</span>{{ c.name }}
<div class="cb-val"><span class="c" :style="{ color: col(i) }">{{ fmt(x.c) }}</span> <span class="p">{{ Math.round(x.pct * 100) }}%</span></div> <span v-if="isSource(c.id)" style="color:var(--bad);font-weight:700"> (污染源)</span>
</div> </div>
<div v-if="!r.items.length" class="muted">未选择任何材料</div> <div class="cb-track"><div class="cb-fill" :style="{ width: Math.max(3, c.rate / maxRate * 100) + '%', background: col(i) }" /></div>
<div class="cb-val"><span class="c" :style="{ color: col(i) }">{{ (c.rate * 100).toFixed(1) }}%</span></div>
</div>
<div v-if="!ranked.length" class="muted">该污染物无材料释放</div>
</div> </div>
</div> </div>
<div class="card" v-if="r.items.length"> <div class="card">
<div class="card-h"><div class="card-t"><span class="bar" />整改建议</div></div> <div class="card-h"><div class="card-t"><span class="bar" />整改建议</div></div>
<div class="sugg"> <div class="sugg">
<div class="sugg-ic"><BulbIcon /></div> <div class="sugg-ic"><BulbIcon /></div>
<div style="flex:1"> <div style="flex:1">
<div class="sugg-tt">{{ r.level === 'good' ? '当前方案达标 ✓' : `主要污染源:${r.items[0].name}` }}</div> <div class="sugg-tt">{{ anyOver ? `${overNames} 超标` : '当前方案全部达标 ✓' }}</div>
<div class="sugg-tx" v-if="r.level === 'good'">预测甲醛 <b>{{ fmt(r.C) }} mg/</b> 已低于国标限值建议入住前仍保持 <b>{{ (+st.n).toFixed(1) }} /h</b> 以上通风,并复测确认</div> <div class="sugg-tx" v-if="anyOver">
<div class="sugg-tx" v-else>该材料贡献 <b>{{ Math.round(r.items[0].pct * 100) }}%</b>({{ fmt(r.items[0].c) }} mg/)将通风提升至 <b>{{ r.requiredN.toFixed(1) }} /h</b> 或更换主源材料即可达标</div> 主要污染源:<b>{{ topSourceNames }}</b>可将通风提升至 <b>{{ requiredACH.toFixed(1) }} /h</b>,或更换/减少这些材料
<div class="sugg-act" v-if="r.level !== 'good'"> </div>
<button class="s-btn s-btn-primary" @click="applyVent">应用:通风至 {{ Math.min(3, r.requiredN).toFixed(1) }} /h</button> <div class="sugg-tx" v-else>各污染物均低于 {{ standard }} 限值建议入住前仍保持通风并复测确认</div>
<button class="s-btn" @click="upgrade">{{ st.upg[r.items[0].id] ? '还原板材' : '更换为 E0 级板材' }}</button> <div class="sugg-act" v-if="anyOver">
<button class="s-btn s-btn-primary" @click="ventilationRate = requiredACH">应用:通风至 {{ requiredACH.toFixed(1) }} /h</button>
</div> </div>
</div> </div>
</div> </div>
@ -115,104 +113,120 @@
</div> </div>
</div> </div>
</div> </div>
<transition name="fade">
<div class="toast" v-if="toast"><SparkIcon />{{ toast }}</div>
</transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, reactive, ref, watch } from 'vue'; import { computed, h, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import {
predictSpace, POLLUTANTS, POLLUTANT_LABELS,
type Pollutant, type StandardCode, type EmissionParams,
} from '@airpredict/shared';
import { listMaterials, type Material } from '../api/materials';
import { PRESET_ROOMS } from '../data/rooms';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const pollutants = POLLUTANTS;
const labels = POLLUTANT_LABELS;
const rooms = PRESET_ROOMS;
//
const ChevronL = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M15 5l-7 7 7 7' })]); const ChevronL = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M15 5l-7 7 7 7' })]);
const SourceIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('circle', { cx: 12, cy: 12, r: 3 }), h('path', { d: 'M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1' })]); const SourceIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('circle', { cx: 12, cy: 12, r: 3 }), h('path', { d: 'M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1' })]);
const CheckIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12l5 5L20 6' })]); const CheckIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12l5 5L20 6' })]);
const PlusIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.4', 'stroke-linecap': 'round' }, [h('path', { d: 'M12 5v14M5 12h14' })]);
const ArrowS = () => h('svg', { viewBox: '0 0 24 24', width: 18, height: 18, fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12h14M13 6l6 6-6 6' })]);
const BulbIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z' })]); const BulbIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z' })]);
const SparkIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5L18 18M18 6l-2.5 2.5M8.5 15.5L6 18' })]);
// EF [mg/(m²·h)] const loading = ref(true);
const MATS: Record<string, { name: string; cat: string; ef: number }> = { const matMap = reactive<Record<string, Material>>({});
floor: { name: '多层实木复合地板', cat: '地板 · 人造板基材', ef: 0.07 },
wardrobe: { name: '人造板衣柜', cat: '板材 · 颗粒板(展开面积)', ef: 0.04 },
woodpaint: { name: '木器漆 · 饰面', cat: '涂料 · 溶剂型', ef: 0.015 },
wallpaper: { name: '壁纸及基膜', cat: '墙面 · 胶粘剂', ef: 0.0073 },
sofa: { name: '布艺沙发软装', cat: '软装 · 纺织/海绵', ef: 0.032 },
latex: { name: '乳胶漆墙面', cat: '墙面 · 水性漆', ef: 0.006 },
ceiling: { name: '石膏板吊顶', cat: '顶面 · 板材', ef: 0.004 },
door: { name: '免漆木门', cat: '门窗 · 人造板', ef: 0.022 },
};
const LIMIT = 0.1, LIMIT2 = 0.07;
const ROOMS: Record<string, { name: string; area: number; h: number; mats: Record<string, number> }> = {
zw: { name: '主卧', area: 18, h: 2.7, mats: { floor: 18, wardrobe: 25, woodpaint: 30, wallpaper: 40, sofa: 6 } },
etf: { name: '儿童房', area: 14, h: 2.7, mats: { floor: 14, wardrobe: 32, woodpaint: 24, wallpaper: 30, sofa: 4 } },
sf: { name: '书房', area: 12, h: 2.7, mats: { floor: 12, wardrobe: 24, woodpaint: 20, door: 8, wallpaper: 16 } },
kt: { name: '客厅', area: 30, h: 2.85, mats: { floor: 30, woodpaint: 38, latex: 55, sofa: 14, ceiling: 30 } },
wsj: { name: '卫生间', area: 6, h: 2.6, mats: { latex: 16, ceiling: 6, door: 4 } },
};
const LEVELTXT: Record<string, string> = { bad: '超标', warn: '临界', good: '达标' };
const fmt = (n: number, d = 3) => Number(n).toFixed(d);
function freshRoom(id: string) { const roomId = ref('demo');
const r = ROOMS[id]; const area = ref(19.8);
const mats: Record<string, { on: boolean; A: number }> = {}; const height = ref(3);
Object.entries(r.mats).forEach(([k, A]) => { mats[k] = { on: true, A }; }); const temperature = ref(22);
return { roomId: id, area: r.area, h: r.h, n: 0.5, mats, upg: {} as Record<string, boolean> }; const humidity = ref(45);
const ventilationRate = ref(0.5);
const standard = ref<StandardCode>('GB50325-2020');
const mats = ref<{ id: string; name: string; area: number; on: boolean }[]>([]);
const pol = ref<Pollutant>('hcho');
const volume = computed(() => +(area.value * height.value).toFixed(2));
function loadRoom(id: string) {
const room = rooms.find((r) => r.id === id)!;
roomId.value = id;
area.value = room.area; height.value = room.height;
temperature.value = room.temperature; humidity.value = room.humidity;
ventilationRate.value = room.ventilationRate;
mats.value = room.materials.map((m) => ({ id: m.id, name: matMap[m.id]?.name || m.id, area: m.a, on: true }));
} }
const st = reactive(freshRoom('zw')); const r = computed(() => {
const input = mats.value
function compute() { .filter((m) => m.on && matMap[m.id])
const V = Math.max(0.1, st.area * st.h); .map((m) => ({
const nV = Math.max(0.01, st.n * V); materialId: m.id,
const items: any[] = []; usageAmount: m.area,
Object.entries(st.mats).forEach(([id, m]: any) => { params: matMap[m.id].emissionParams as Record<Pollutant, EmissionParams>,
if (!m.on) return; }));
const ef = MATS[id].ef * (st.upg[id] ? 0.35 : 1); return predictSpace(input, {
const emis = ef * m.A; volume: volume.value, temperature: temperature.value, humidity: humidity.value,
items.push({ id, name: MATS[id].name, emis, c: emis / nV }); ventilationRate: ventilationRate.value, standard: standard.value,
}); });
const C = items.reduce((s, x) => s + x.c, 0); });
const totalEmis = items.reduce((s, x) => s + x.emis, 0);
items.sort((a, b) => b.c - a.c).forEach((x, i) => { x.rank = i; x.pct = C ? x.c / C : 0; }); const fmt = (n: number) => Number(n ?? 0).toFixed(3);
const ratio = C / LIMIT;
const level = ratio >= 1 ? 'bad' : ratio >= 0.85 ? 'warn' : 'good';
const requiredN = Math.min(3, Math.max(0.3, Math.ceil((C * st.n) / (LIMIT * 0.9) * 10) / 10));
return { V, nV, C, totalEmis, items, ratio, level, requiredN };
}
const r = computed(compute);
const maxC = computed(() => (r.value.items.length ? r.value.items[0].c : 1));
const addable = computed(() => Object.keys(MATS).find((k) => !st.mats[k]) || '');
const col = (i: number) => (i === 0 ? 'var(--bad)' : i === 1 ? 'var(--warn)' : 'var(--accent)'); const col = (i: number) => (i === 0 ? 'var(--bad)' : i === 1 ? 'var(--warn)' : 'var(--accent)');
function set(k: 'area' | 'h' | 'n', v: number) { (st as any)[k] = v; } interface Row { id: string; name: string; rate: number }
function setRoom(id: string) { Object.assign(st, freshRoom(id)); } const ranked = computed<Row[]>(() =>
function toggleMat(id: string) { st.mats[id].on = !st.mats[id].on; } r.value.contributions
function setQty(id: string, A: number) { st.mats[id].A = A || 0; } .map((c) => ({ id: c.materialId, name: matMap[c.materialId]?.name || c.materialId, rate: c.contributionRate[pol.value] }))
function addMat() { const k = addable.value; if (k) st.mats[k] = { on: true, A: 10 }; } .filter((x) => x.rate > 0)
.sort((a, b) => b.rate - a.rate),
);
const maxRate = computed(() => ranked.value[0]?.rate || 1);
const isSource = (id: string) => r.value.sources[pol.value]?.includes(id);
const toast = ref(''); const anyOver = computed(() => POLLUTANTS.some((p) => r.value.exceeded[p]));
let tt: any = null; const overNames = computed(() => POLLUTANTS.filter((p) => r.value.exceeded[p]).map((p) => labels[p].zh).join('、'));
function fireToast(msg: string) { toast.value = msg; if (tt) clearTimeout(tt); tt = setTimeout(() => (toast.value = ''), 2600); } const topSourceNames = computed(() => {
function applyVent() { st.n = Math.min(3, Math.max(st.n, r.value.requiredN)); fireToast(`已将通风换气率调整至 ${Math.min(3, r.value.requiredN).toFixed(1)} 次/h`); } const ids = new Set<string>();
function upgrade() { POLLUTANTS.forEach((p) => r.value.sources[p]?.forEach((id) => ids.add(id)));
const top = r.value.items[0]; return [...ids].map((id) => matMap[id]?.name || id).join('、') || '—';
const was = st.upg[top.id]; });
st.upg = { ...st.upg, [top.id]: !was };
fireToast(was ? `已还原 ${top.name}` : `已将 ${top.name} 替换为 E0 级低释放材料`); //
} const requiredACH = computed(() => {
const input = mats.value.filter((m) => m.on && matMap[m.id]).map((m) => ({
materialId: m.id, usageAmount: m.area, params: matMap[m.id].emissionParams as Record<Pollutant, EmissionParams>,
}));
for (let ach = ventilationRate.value; ach <= 3.001; ach += 0.1) {
const res = predictSpace(input, { volume: volume.value, temperature: temperature.value, humidity: humidity.value, ventilationRate: ach, standard: standard.value });
if (!POLLUTANTS.some((p) => res.exceeded[p])) return Math.round(ach * 10) / 10;
}
return 3;
});
onMounted(async () => {
try {
const res = await listMaterials({ scope: 'public', page: 1, pageSize: 300 });
res.items.forEach((m) => (matMap[m.id] = m));
loadRoom('demo');
} finally {
loading.value = false;
}
});
</script> </script>
<style src="../styles/source.css"></style> <style src="../styles/source.css"></style>
<style scoped> <style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity .2s; } .res-table { width: 100%; border-collapse: collapse; }
.fade-enter-from, .fade-leave-to { opacity: 0; } .res-table th, .res-table td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; font-size: 13px; }
.res-table th { color: var(--faint); font-weight: 600; font-size: 12px; }
.res-table td.over { color: var(--bad); font-weight: 700; }
.pol-seg { display: flex; gap: 2px; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
.pol-seg b { font-size: 11.5px; font-weight: 600; padding: 5px 9px; border-radius: 6px; color: var(--sub); cursor: pointer; }
.pol-seg b.on { background: var(--accent); color: #fff; }
</style> </style>