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:
zty 2026-06-12 14:19:37 +08:00
parent 3437a6d8f5
commit 325269c2fe
9 changed files with 387 additions and 5 deletions

View File

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

View File

@ -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' };

View File

@ -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`, {});
}

View File

@ -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 }}</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>

View File

@ -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 }}</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>

View File

@ -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>

View File

@ -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" />

View File

@ -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 }}</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 }} · {{ 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/)</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>

View File

@ -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') },
], ],
}, },
]; ];