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:
zty 2026-06-12 10:34:54 +08:00
parent 022bd721ee
commit efb537e966
2 changed files with 104 additions and 0 deletions

View File

@ -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/</b>
<span class="limit">限值 {{ limits[p] }} mg/{{ 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>

View File

@ -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(); }