feat(phase4): 专业端空间污染源溯源视图
ProjectConfig 每个空间加"溯源"入口 → SpaceTracingModal: 5项污染物分tab,各显示预测浓度vs限值、达标/超标、各材料贡献率排序条、 超标时标红污染源材料(累计贡献>50%)并给整改建议。复用 generate 落库的 contributionRate,污染源前端按累计>50%算。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
022bd721ee
commit
efb537e966
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<a-modal :open="open" :title="`污染源溯源 · ${space?.name || ''}`" :footer="null" width="760px" @cancel="emit('cancel')">
|
||||
<template v-if="space">
|
||||
<a-tabs v-model:activeKey="active">
|
||||
<a-tab-pane v-for="p in pollutants" :key="p">
|
||||
<template #tab>
|
||||
{{ labels[p].zh }}
|
||||
<a-tag v-if="exceeded(p)" color="red" style="margin-left:4px">超标</a-tag>
|
||||
<a-tag v-else color="green" style="margin-left:4px">达标</a-tag>
|
||||
</template>
|
||||
|
||||
<div class="conc-line">
|
||||
预测浓度
|
||||
<b :class="{ over: exceeded(p) }">{{ fmt(conc(p)) }} mg/m³</b>
|
||||
<span class="limit">限值 {{ limits[p] }} mg/m³({{ space.standard }})</span>
|
||||
</div>
|
||||
|
||||
<div class="bars">
|
||||
<div class="bar-row" v-for="(c, i) in ranked(p)" :key="c.id">
|
||||
<div class="nm">
|
||||
<span class="rk" :style="{ background: barColor(i) }">{{ i + 1 }}</span>{{ c.name }}
|
||||
<a-tag v-if="isSource(p, c.id)" color="red" size="small">污染源</a-tag>
|
||||
</div>
|
||||
<div class="track"><div class="fill" :style="{ width: Math.max(2, c.rate / maxRate(p) * 100) + '%', background: barColor(i) }" /></div>
|
||||
<div class="val">{{ (c.rate * 100).toFixed(1) }}%</div>
|
||||
</div>
|
||||
<div v-if="!ranked(p).length" class="muted">该污染物无材料释放</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exceeded(p)" class="sugg">
|
||||
💡 <b>{{ labels[p].zh }}超标</b>。主要污染源:<b>{{ sourceNames(p) }}</b>(累计贡献 {{ sourceCumPct(p) }}%)。建议优先更换/减少这些材料,或提高通风换气率。
|
||||
</div>
|
||||
<div v-else class="sugg ok">✓ {{ labels[p].zh }}达标,余量 {{ marginPct(p) }}%。</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||||
import type { SpaceRow } from '../api/projects';
|
||||
|
||||
const props = defineProps<{ open: boolean; space?: SpaceRow | null }>();
|
||||
const emit = defineEmits<{ (e: 'cancel'): void }>();
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const active = ref<Pollutant>('hcho');
|
||||
watch(() => props.open, (o) => { if (o) active.value = 'hcho'; });
|
||||
|
||||
const limits = computed(() => STANDARD_LIMITS[(props.space?.standard as StandardCode) || 'GB50325-2020']);
|
||||
const fmt = (n: number) => Number(n ?? 0).toFixed(3);
|
||||
const conc = (p: Pollutant) => props.space?.predictedConc?.[p] ?? 0;
|
||||
const exceeded = (p: Pollutant) => conc(p) > (limits.value[p] ?? Infinity);
|
||||
const marginPct = (p: Pollutant) => Math.max(0, Math.round((1 - conc(p) / (limits.value[p] || 1)) * 100));
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
function ranked(p: Pollutant): Row[] {
|
||||
const ms = props.space?.materials || [];
|
||||
return ms
|
||||
.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);
|
||||
}
|
||||
const maxRate = (p: Pollutant) => (ranked(p)[0]?.rate || 1);
|
||||
function sources(p: Pollutant): Row[] {
|
||||
const r = ranked(p);
|
||||
const out: Row[] = [];
|
||||
let cum = 0;
|
||||
for (const x of r) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
|
||||
return out;
|
||||
}
|
||||
const isSource = (p: Pollutant, id: string) => exceeded(p) && sources(p).some((s) => s.id === id);
|
||||
const sourceNames = (p: Pollutant) => sources(p).map((s) => s.name).join('、');
|
||||
const sourceCumPct = (p: Pollutant) => Math.round(sources(p).reduce((s, x) => s + x.rate, 0) * 100);
|
||||
const barColor = (i: number) => (i === 0 ? '#b4232a' : i === 1 ? '#ca8326' : '#1f7a5a');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conc-line { margin: 4px 0 16px; color: #555; }
|
||||
.conc-line b { font-size: 18px; margin: 0 8px; }
|
||||
.conc-line b.over { color: #b4232a; }
|
||||
.conc-line .limit { color: #999; font-size: 13px; }
|
||||
.bars { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bar-row { display: grid; grid-template-columns: 220px 1fr 56px; align-items: center; gap: 12px; }
|
||||
.nm { font-size: 13px; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rk { display: inline-flex; width: 18px; height: 18px; border-radius: 5px; color: #fff; font-size: 11px; font-weight: 700; align-items: center; justify-content: center; }
|
||||
.track { height: 16px; background: #f0f0f0; border-radius: 5px; overflow: hidden; }
|
||||
.fill { height: 100%; border-radius: 5px; }
|
||||
.val { text-align: right; font-weight: 700; font-size: 13px; }
|
||||
.sugg { margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #fff7f5; border: 1px solid #f0d0c8; font-size: 13px; line-height: 1.6; }
|
||||
.sugg.ok { background: #f3faf6; border-color: #cce8d8; }
|
||||
.muted { color: #aaa; }
|
||||
</style>
|
||||
|
|
@ -43,6 +43,8 @@
|
|||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="openTracing(record)">溯源</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="openEditSpace(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该空间?" @confirm="onDeleteSpace(record)">
|
||||
|
|
@ -65,6 +67,7 @@
|
|||
@ok="onSpaceSaved"
|
||||
@cancel="drawerOpen = false"
|
||||
/>
|
||||
<SpaceTracingModal :open="tracingOpen" :space="tracingSpace" @cancel="tracingOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -77,6 +80,7 @@ import { getProject, generateReport, type ProjectDetail, type SpaceRow } from '.
|
|||
import { deleteSpace } from '../api/spaces';
|
||||
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||
import SpaceDrawer from '../components/SpaceDrawer.vue';
|
||||
import SpaceTracingModal from '../components/SpaceTracingModal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
|
@ -116,6 +120,10 @@ async function load() {
|
|||
|
||||
function openAddSpace() { editingSpace.value = null; drawerOpen.value = true; }
|
||||
function openEditSpace(s: SpaceRow) { editingSpace.value = s; drawerOpen.value = true; }
|
||||
|
||||
const tracingOpen = ref(false);
|
||||
const tracingSpace = ref<SpaceRow | null>(null);
|
||||
function openTracing(s: SpaceRow) { tracingSpace.value = s; tracingOpen.value = true; }
|
||||
function onSpaceSaved() { drawerOpen.value = false; load(); }
|
||||
function onEdited() { editOpen.value = false; load(); }
|
||||
async function onDeleteSpace(s: SpaceRow) { await deleteSpace(s.id); message.success('已删除'); load(); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue