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:
parent
efb537e966
commit
3437a6d8f5
|
|
@ -62,6 +62,38 @@ async function main() {
|
||||||
}
|
}
|
||||||
console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`);
|
console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`);
|
||||||
|
|
||||||
|
// 官方算例 6 种材料(真实 Y0/Yp/B,5 污染物),供 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({
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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">m²</span></div></div>
|
<div><div class="fld-lab">面积</div><div class="num"><input type="number" v-model.number="area" /><span class="unit">m²</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">勾选计入 · 填写用量(m²)</span></div>
|
<div class="fld-lab">装修材料 <span class="muted">体积 V={{ volume.toFixed(1) }} m³ · 勾选计入、填用量</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">m²</span></div>
|
<div class="mat-qty"><input type="number" v-model.number="m.area" /><span class="u">m²</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/m³</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/m³</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) }} m³</code> ·
|
|
||||||
通风量 n·V = <code>{{ (+st.n).toFixed(1) }} × {{ fmt(r.V, 1) }} = {{ fmt(r.nV, 1) }} m³/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/m³</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 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>
|
||||||
<div v-if="!r.items.length" class="muted">未选择任何材料</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/m³</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/m³)。将通风提升至 <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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue