157 lines
8.0 KiB
Vue
157 lines
8.0 KiB
Vue
<template>
|
||
<div class="report" v-if="project">
|
||
<div class="rpt-bar no-print">
|
||
<a @click="router.back()">← 返回</a>
|
||
<span class="rpt-title">预测报告</span>
|
||
<a-button type="primary" size="small" @click="printReport">打印 / 导出 PDF</a-button>
|
||
</div>
|
||
|
||
<div class="sheet">
|
||
<!-- 封面 / 项目信息 -->
|
||
<div class="cover">
|
||
<div class="cover-tag">室内装修工程污染物预测报告</div>
|
||
<h1>{{ project.name }}</h1>
|
||
<div class="cover-meta">
|
||
<span>项目ID:{{ project.id }}</span>
|
||
<span>项目类型:{{ project.type }}</span>
|
||
<span>所在城市:{{ project.province }}/{{ project.city }}</span>
|
||
<span>建筑面积:{{ project.area }}m²</span>
|
||
</div>
|
||
<div class="cover-rating">
|
||
预测评级 <b :class="'r-' + (project.rating || 'A')">{{ project.rating || '-' }}</b>
|
||
<span class="gen">生成时间:{{ fmt(project.reportGeneratedAt) }}</span>
|
||
</div>
|
||
<div class="cover-summary">
|
||
预测结果:共 {{ project.spaces.length }} 个空间,其中
|
||
<b :class="{ bad: overSpaces > 0 }">{{ overSpaces }}</b> 个存在污染物浓度超标。
|
||
依据 {{ standardsUsed }}。
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 各空间 -->
|
||
<div class="space-block" v-for="(s, idx) in project.spaces" :key="s.id">
|
||
<div class="sb-head">
|
||
<h2>空间 {{ idx + 1 }} · {{ s.name }}</h2>
|
||
<span class="sb-meta">{{ s.type }} · {{ s.area }}m² · {{ s.temperature }}℃ · {{ s.humidity }}%rh · 通风 {{ s.ventilationRate }}次/h · {{ s.standard }}</span>
|
||
</div>
|
||
|
||
<table class="conc-table">
|
||
<thead><tr><th>污染物</th><th v-for="p in pollutants" :key="p">{{ labels[p].zh }}</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>限值 (mg/m³)</td><td v-for="p in pollutants" :key="p">{{ limitOf(s, p) }}</td></tr>
|
||
<tr>
|
||
<td>预测浓度 (mg/m³)</td>
|
||
<td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">
|
||
{{ fmt3(s.predictedConc?.[p]) }}
|
||
</td>
|
||
</tr>
|
||
<tr><td>判定</td><td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">{{ isOver(s, p) ? '超标' : '达标' }}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- 污染源溯源(仅超标污染物) -->
|
||
<div v-if="overPollutants(s).length" class="trace">
|
||
<div class="trace-h">污染源溯源</div>
|
||
<div v-for="p in overPollutants(s)" :key="p" class="trace-pol">
|
||
<div class="tp-name">{{ labels[p].zh }}(超标)主要污染源:<b>{{ sourceNames(s, p) }}</b></div>
|
||
<div class="bars">
|
||
<div class="bar" v-for="(c, i) in ranked(s, p)" :key="c.id">
|
||
<span class="bn">{{ c.name }}<em v-if="isSource(s, p, c.id)">(污染源)</em></span>
|
||
<span class="bt"><span class="bf" :style="{ width: Math.max(2, c.rate / ranked(s,p)[0].rate * 100) + '%', background: i === 0 ? '#bf4a30' : i === 1 ? '#ca8326' : '#1f7a5a' }" /></span>
|
||
<span class="bv">{{ (c.rate * 100).toFixed(1) }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="foot">依据 GB/T 18883-2022 · GB 50325-2020 · 预测结果仅供参考,以 CMA 检测为准</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||
import { getProject, type ProjectDetail, type SpaceRow } from '../api/projects';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const project = ref<ProjectDetail | null>(null);
|
||
const pollutants = POLLUTANTS;
|
||
const labels = POLLUTANT_LABELS;
|
||
|
||
const fmt = (s?: string) => (s ? new Date(s).toLocaleString() : '-');
|
||
const fmt3 = (n?: number) => (n == null ? '-' : Number(n).toFixed(3));
|
||
const limitsOf = (s: SpaceRow) => STANDARD_LIMITS[(s.standard as StandardCode) || 'GB50325-2020'];
|
||
const limitOf = (s: SpaceRow, p: Pollutant) => limitsOf(s)[p];
|
||
const isOver = (s: SpaceRow, p: Pollutant) => (s.predictedConc?.[p] ?? 0) > limitOf(s, p);
|
||
const overPollutants = (s: SpaceRow) => POLLUTANTS.filter((p) => isOver(s, p));
|
||
const overSpaces = computed(() => (project.value?.spaces || []).filter((s) => overPollutants(s).length).length);
|
||
const standardsUsed = computed(() => [...new Set((project.value?.spaces || []).map((s) => s.standard))].join('、'));
|
||
|
||
interface Row { id: string; name: string; rate: number }
|
||
function ranked(s: SpaceRow, p: Pollutant): Row[] {
|
||
return s.materials
|
||
.map((m) => ({ id: m.materialId, name: m.material?.name || m.materialId, rate: m.contributionRate?.[p] ?? 0 }))
|
||
.filter((r) => r.rate > 0)
|
||
.sort((a, b) => b.rate - a.rate);
|
||
}
|
||
function sources(s: SpaceRow, p: Pollutant): Row[] {
|
||
const out: Row[] = []; let cum = 0;
|
||
for (const x of ranked(s, p)) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
|
||
return out;
|
||
}
|
||
const isSource = (s: SpaceRow, p: Pollutant, id: string) => sources(s, p).some((x) => x.id === id);
|
||
const sourceNames = (s: SpaceRow, p: Pollutant) => sources(s, p).map((x) => x.name).join('、');
|
||
|
||
function printReport() { window.print(); }
|
||
|
||
onMounted(async () => {
|
||
project.value = await getProject(route.params.id as string);
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.report { background: #eef0f2; min-height: 100vh; padding-bottom: 40px; }
|
||
.rpt-bar { display: flex; align-items: center; gap: 16px; padding: 12px 24px; background: #fff; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 5; }
|
||
.rpt-bar a { color: #b4232a; cursor: pointer; }
|
||
.rpt-title { flex: 1; font-weight: 600; }
|
||
.sheet { max-width: 900px; margin: 20px auto; background: #fff; padding: 40px 48px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
|
||
.cover { border-bottom: 2px solid #b4232a; padding-bottom: 20px; margin-bottom: 24px; }
|
||
.cover-tag { color: #b4232a; font-size: 13px; font-weight: 600; letter-spacing: 1px; }
|
||
.cover h1 { font-size: 28px; margin: 8px 0 16px; }
|
||
.cover-meta { display: flex; flex-wrap: wrap; gap: 6px 24px; color: #555; font-size: 14px; }
|
||
.cover-rating { margin-top: 16px; font-size: 15px; }
|
||
.cover-rating b { font-size: 22px; margin: 0 8px; }
|
||
.cover-rating b.r-A { color: #2f8f5b; } .cover-rating b.r-B { color: #2778c4; } .cover-rating b.r-C { color: #ca8326; } .cover-rating b.r-D { color: #bf4a30; }
|
||
.cover-rating .gen { color: #999; font-size: 13px; margin-left: 16px; }
|
||
.cover-summary { margin-top: 14px; color: #444; }
|
||
.cover-summary b { color: #2f8f5b; } .cover-summary b.bad { color: #bf4a30; }
|
||
.space-block { margin-bottom: 30px; page-break-inside: avoid; }
|
||
.sb-head h2 { font-size: 18px; margin: 0; }
|
||
.sb-meta { color: #888; font-size: 13px; }
|
||
.conc-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||
.conc-table th, .conc-table td { border: 1px solid #e8e8e8; padding: 8px 10px; text-align: center; font-size: 13px; }
|
||
.conc-table th { background: #fafafa; }
|
||
.conc-table td:first-child, .conc-table th:first-child { text-align: left; color: #666; }
|
||
.conc-table td.over { color: #bf4a30; font-weight: 700; }
|
||
.trace { background: #fcf8f6; border: 1px solid #f0e0d8; border-radius: 8px; padding: 14px 16px; }
|
||
.trace-h { font-weight: 600; color: #b4232a; margin-bottom: 10px; }
|
||
.trace-pol { margin-bottom: 14px; }
|
||
.tp-name { font-size: 13px; margin-bottom: 8px; }
|
||
.bars { display: flex; flex-direction: column; gap: 6px; }
|
||
.bar { display: grid; grid-template-columns: 200px 1fr 52px; align-items: center; gap: 10px; font-size: 12px; }
|
||
.bar em { color: #bf4a30; font-style: normal; font-weight: 700; }
|
||
.bt { height: 12px; background: #eee; border-radius: 4px; overflow: hidden; }
|
||
.bf { display: block; height: 100%; }
|
||
.bv { text-align: right; font-weight: 700; }
|
||
.foot { color: #aaa; font-size: 12px; text-align: center; margin-top: 30px; border-top: 1px solid #eee; padding-top: 16px; }
|
||
@media print {
|
||
.no-print { display: none; }
|
||
.report { background: #fff; }
|
||
.sheet { box-shadow: none; margin: 0; max-width: none; }
|
||
}
|
||
</style>
|