airpredict/apps/web/src/pages/Report.vue

157 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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/)</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>