feat: 历史记录页 + 快速导入项目 + 报告查看页
- 历史记录(History.vue): 列已生成报告项目,筛选,详情/查看报告/复用 - 复用: 后端 POST /projects/:id/duplicate 复制项目+空间+材料为新草稿 - 快速导入(ImportProjectModal): 首页"快速导入"→选模板→fromTemplateId建项目 - 报告查看(Report.vue, /report/:id): 封面+各空间5污染物预测+超标标红+ 污染源溯源,支持打印/导出PDF;配置页生成后/历史页"查看报告"跳此 注: 数据库 10.0.11.51 当前不可达,以上构建+类型检查通过,待DB恢复后实跑验证。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3437a6d8f5
commit
325269c2fe
|
|
@ -50,4 +50,9 @@ export class ProjectsController {
|
||||||
generate(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
generate(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||||
return this.projects.generate(org.id, id);
|
return this.projects.generate(org.id, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/duplicate')
|
||||||
|
duplicate(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||||
|
return this.projects.duplicate(org.id, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,51 @@ export class ProjectsService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复用:把任意自有项目(或模板)复制成一个新草稿 */
|
||||||
|
async duplicate(orgId: string, id: string) {
|
||||||
|
const src = await this.prisma.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { spaces: { include: { materials: true } } },
|
||||||
|
});
|
||||||
|
if (!src) throw new NotFoundException('项目不存在');
|
||||||
|
if (src.ownerOrgId !== orgId && !src.isPublic) throw new ForbiddenException('无权复用');
|
||||||
|
|
||||||
|
return this.prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: this.genId('P'),
|
||||||
|
name: src.name + ' (复用)',
|
||||||
|
type: src.type,
|
||||||
|
province: src.province,
|
||||||
|
city: src.city,
|
||||||
|
area: src.area,
|
||||||
|
status: 'configuring',
|
||||||
|
ownerOrgId: orgId,
|
||||||
|
spaces: {
|
||||||
|
create: src.spaces.map((s) => ({
|
||||||
|
id: this.genId('S'),
|
||||||
|
name: s.name,
|
||||||
|
type: s.type,
|
||||||
|
layout: s.layout,
|
||||||
|
height: s.height,
|
||||||
|
area: s.area,
|
||||||
|
volume: s.volume,
|
||||||
|
temperature: s.temperature,
|
||||||
|
humidity: s.humidity,
|
||||||
|
ventilationRate: s.ventilationRate,
|
||||||
|
standard: s.standard,
|
||||||
|
materials: {
|
||||||
|
create: s.materials.map((m) => ({
|
||||||
|
materialId: m.materialId,
|
||||||
|
usageUnit: m.usageUnit,
|
||||||
|
usageAmount: m.usageAmount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async list(orgId: string, q: QueryProjectsDto) {
|
async list(orgId: string, q: QueryProjectsDto) {
|
||||||
const where: Prisma.ProjectWhereInput = { ownerOrgId: orgId, isTemplate: false };
|
const where: Prisma.ProjectWhereInput = { ownerOrgId: orgId, isTemplate: false };
|
||||||
if (q.id) where.id = { contains: q.id, mode: 'insensitive' };
|
if (q.id) where.id = { contains: q.id, mode: 'insensitive' };
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ export function deleteProject(id: string) {
|
||||||
export function generateReport(id: string) {
|
export function generateReport(id: string) {
|
||||||
return http.post<any, ProjectDetail>(`/projects/${id}/generate`, {});
|
return http.post<any, ProjectDetail>(`/projects/${id}/generate`, {});
|
||||||
}
|
}
|
||||||
export function listProjects(params: { status?: string; unfinished?: string; page?: number; pageSize?: number }) {
|
export function listProjects(params: { status?: string; unfinished?: string; name?: string; type?: string; rating?: string; page?: number; pageSize?: number }) {
|
||||||
return http.get<any, Paged<ProjectRow>>('/projects', { params });
|
return http.get<any, Paged<ProjectRow>>('/projects', { params });
|
||||||
}
|
}
|
||||||
|
export function duplicateProject(id: string) {
|
||||||
|
return http.post<any, ProjectDetail>(`/projects/${id}/duplicate`, {});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" title="快速导入项目 · 选择模板" :footer="null" width="860px" @cancel="emit('cancel')">
|
||||||
|
<a-tabs v-model:activeKey="scope" @change="reload">
|
||||||
|
<a-tab-pane key="public" tab="公共模板" />
|
||||||
|
<a-tab-pane key="self" tab="自建模板" />
|
||||||
|
</a-tabs>
|
||||||
|
<a-table :columns="columns" :data-source="data.items" :loading="loading" :pagination="pagination" row-key="id" size="small" @change="onTableChange">
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
|
||||||
|
<template v-else-if="column.key === 'area'">{{ record.area }}m²</template>
|
||||||
|
<template v-else-if="column.key === 'op'">
|
||||||
|
<a-button type="link" size="small" :loading="usingId === record.id" @click="useTemplate(record)">使用此模板</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { listTemplates, type TemplateRow } from '../api/templates';
|
||||||
|
import { createProject } from '../api/projects';
|
||||||
|
import type { Paged } from '../api/materials';
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>();
|
||||||
|
const emit = defineEmits<{ (e: 'created', id: string): void; (e: 'cancel'): void }>();
|
||||||
|
|
||||||
|
const scope = ref<'public' | 'self'>('public');
|
||||||
|
const loading = ref(false);
|
||||||
|
const usingId = ref('');
|
||||||
|
const data = ref<Paged<TemplateRow>>({ total: 0, page: 1, pageSize: 8, items: [] });
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '模板ID', dataIndex: 'id' },
|
||||||
|
{ title: '工程名称', dataIndex: 'name' },
|
||||||
|
{ title: '项目类型', dataIndex: 'type' },
|
||||||
|
{ title: '所在城市', key: 'city' },
|
||||||
|
{ title: '建筑面积', key: 'area' },
|
||||||
|
{ title: '空间数', dataIndex: 'spaceCount' },
|
||||||
|
{ title: '操作', key: 'op', width: 120 },
|
||||||
|
];
|
||||||
|
const pagination = computed(() => ({ current: data.value.page, pageSize: data.value.pageSize, total: data.value.total }));
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
data.value = await listTemplates({ scope: scope.value, page: page.value, pageSize: 8 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||||
|
|
||||||
|
async function useTemplate(r: TemplateRow) {
|
||||||
|
usingId.value = r.id;
|
||||||
|
try {
|
||||||
|
const p = await createProject({
|
||||||
|
name: r.name, type: r.type, province: r.province, city: r.city, area: r.area, fromTemplateId: r.id,
|
||||||
|
});
|
||||||
|
message.success('已按模板创建项目');
|
||||||
|
emit('created', p.id);
|
||||||
|
} finally {
|
||||||
|
usingId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (o) => { if (o) { page.value = 1; reload(); } });
|
||||||
|
</script>
|
||||||
|
|
@ -1,7 +1,102 @@
|
||||||
<template>
|
<template>
|
||||||
<a-card title="历史预测记录">
|
<a-card title="历史预测记录">
|
||||||
<a-empty description="历史记录将在阶段 5 实现" />
|
<a-form layout="inline" class="filters">
|
||||||
|
<a-form-item label="工程名称"><a-input v-model:value="q.name" allow-clear @pressEnter="reload" /></a-form-item>
|
||||||
|
<a-form-item label="项目类型">
|
||||||
|
<a-select v-model:value="q.type" allow-clear style="width: 120px" @change="reload">
|
||||||
|
<a-select-option v-for="t in projectTypes" :key="t" :value="t">{{ t }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="预测评级">
|
||||||
|
<a-select v-model:value="q.rating" allow-clear style="width: 90px" @change="reload">
|
||||||
|
<a-select-option v-for="r in ratings" :key="r" :value="r">{{ r }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button @click="reset">重 置</a-button>
|
||||||
|
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<a-table :columns="columns" :data-source="data.items" :loading="loading" :pagination="pagination" row-key="id" size="middle" @change="onTableChange">
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
|
||||||
|
<template v-else-if="column.key === 'area'">{{ record.area }}m²</template>
|
||||||
|
<template v-else-if="column.key === 'rating'">
|
||||||
|
<a-tag :color="ratingColor(record.rating)">{{ record.rating || '-' }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'time'">{{ fmt(record.reportGeneratedAt) }}</template>
|
||||||
|
<template v-else-if="column.key === 'op'">
|
||||||
|
<a @click="goDetail(record)">详情</a>
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
<a @click="goReport(record)">查看报告</a>
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
<a @click="onReuse(record)">复用</a>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { PROJECT_TYPES, PREDICTION_RATINGS } from '@airpredict/shared';
|
||||||
|
import { listProjects, duplicateProject, type ProjectRow } from '../api/projects';
|
||||||
|
import type { Paged } from '../api/materials';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const projectTypes = PROJECT_TYPES;
|
||||||
|
const ratings = PREDICTION_RATINGS;
|
||||||
|
const loading = ref(false);
|
||||||
|
const data = ref<Paged<ProjectRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||||
|
const q = reactive<any>({ name: '', type: undefined, rating: undefined });
|
||||||
|
const page = ref(1);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '项目ID', dataIndex: 'id' },
|
||||||
|
{ title: '工程名称', dataIndex: 'name' },
|
||||||
|
{ title: '项目类型', dataIndex: 'type' },
|
||||||
|
{ title: '所在城市', key: 'city' },
|
||||||
|
{ title: '建筑面积', key: 'area' },
|
||||||
|
{ title: '空间数', dataIndex: 'spaceCount' },
|
||||||
|
{ title: '预测评级', key: 'rating' },
|
||||||
|
{ title: '生成报告时间', key: 'time' },
|
||||||
|
{ title: '操作', key: 'op', width: 180 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: data.value.page, pageSize: data.value.pageSize, total: data.value.total,
|
||||||
|
showTotal: (t: number) => `共 ${t} 条`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function fmt(s?: string) { return s ? new Date(s).toLocaleString() : '-'; }
|
||||||
|
function ratingColor(r?: string) { return ({ A: 'green', B: 'blue', C: 'orange', D: 'red' } as any)[r || ''] || 'default'; }
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
data.value = await listProjects({ status: 'report_generated', ...q, page: page.value, pageSize: 10 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||||
|
function reset() { Object.keys(q).forEach((k) => (q[k] = undefined)); page.value = 1; reload(); }
|
||||||
|
|
||||||
|
function goDetail(r: ProjectRow) { router.push({ name: 'predict', params: { id: r.id } }); }
|
||||||
|
function goReport(r: ProjectRow) { router.push({ name: 'report', params: { id: r.id } }); }
|
||||||
|
async function onReuse(r: ProjectRow) {
|
||||||
|
const p = await duplicateProject(r.id);
|
||||||
|
message.success('已复用为新草稿');
|
||||||
|
router.push({ name: 'predict', params: { id: p.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reload);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filters { margin-bottom: 16px; }
|
||||||
|
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<NewProjectModal :open="createOpen" @ok="onCreated" @cancel="createOpen = false" />
|
<NewProjectModal :open="createOpen" @ok="onCreated" @cancel="createOpen = false" />
|
||||||
|
<ImportProjectModal :open="importOpen" @created="onImported" @cancel="importOpen = false" />
|
||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -31,17 +32,18 @@ import { useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import NewProjectModal from '../components/NewProjectModal.vue';
|
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||||
|
import ImportProjectModal from '../components/ImportProjectModal.vue';
|
||||||
import type { ProjectDetail } from '../api/projects';
|
import type { ProjectDetail } from '../api/projects';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const todo = () => message.info('该功能将在后续阶段实现');
|
|
||||||
|
|
||||||
const createOpen = ref(false);
|
const createOpen = ref(false);
|
||||||
|
const importOpen = ref(false);
|
||||||
|
|
||||||
const predictCards = [
|
const predictCards = [
|
||||||
{ title: '新建项目预测', desc: '从头配置项目、空间、材料进行预测', action: () => (createOpen.value = true) },
|
{ title: '新建项目预测', desc: '从头配置项目、空间、材料进行预测', action: () => (createOpen.value = true) },
|
||||||
{ title: '快速导入项目', desc: '根据模板或文件导入后调整配置预测', action: () => router.push({ name: 'template' }) },
|
{ title: '快速导入项目', desc: '根据模板导入后调整配置预测', action: () => (importOpen.value = true) },
|
||||||
{ title: '继续配置预测', desc: '继续已保存、未提交的配置', action: () => router.push({ name: 'drafts' }) },
|
{ title: '继续配置预测', desc: '继续已保存、未提交的配置', action: () => router.push({ name: 'drafts' }) },
|
||||||
{ title: '污染源识别 · 快速溯源', desc: '单空间快速预测,溯源主要污染材料', action: () => router.push('/source') },
|
{ title: '污染源识别 · 快速溯源', desc: '单空间快速预测,溯源主要污染材料', action: () => router.push('/source') },
|
||||||
];
|
];
|
||||||
|
|
@ -55,6 +57,10 @@ function onCreated(p: ProjectDetail) {
|
||||||
createOpen.value = false;
|
createOpen.value = false;
|
||||||
router.push({ name: 'predict', params: { id: p.id } });
|
router.push({ name: 'predict', params: { id: p.id } });
|
||||||
}
|
}
|
||||||
|
function onImported(id: string) {
|
||||||
|
importOpen.value = false;
|
||||||
|
router.push({ name: 'predict', params: { id } });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a-button type="primary" size="large" :loading="generating" @click="onGenerate">生成预测报告</a-button>
|
<a-button type="primary" size="large" :loading="generating" @click="onGenerate">生成预测报告</a-button>
|
||||||
|
<a-button v-if="project.status === 'report_generated'" size="large" style="margin-left: 12px" @click="router.push({ name: 'report', params: { id: project.id } })">查看报告</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewProjectModal :open="editOpen" :project="project" @ok="onEdited" @cancel="editOpen = false" />
|
<NewProjectModal :open="editOpen" :project="project" @ok="onEdited" @cancel="editOpen = false" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
<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>
|
||||||
|
|
@ -15,6 +15,7 @@ const routes = [
|
||||||
{ path: 'history-project', name: 'history', component: () => import('../pages/History.vue') },
|
{ path: 'history-project', name: 'history', component: () => import('../pages/History.vue') },
|
||||||
{ path: 'drafts', name: 'drafts', component: () => import('../pages/Drafts.vue') },
|
{ path: 'drafts', name: 'drafts', component: () => import('../pages/Drafts.vue') },
|
||||||
{ path: 'predict/:id', name: 'predict', component: () => import('../pages/ProjectConfig.vue') },
|
{ path: 'predict/:id', name: 'predict', component: () => import('../pages/ProjectConfig.vue') },
|
||||||
|
{ path: 'report/:id', name: 'report', component: () => import('../pages/Report.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue