init: 室内装修工程污染物预测系统复刻 (阶段0-3)
全栈 TypeScript monorepo (pnpm + NestJS + Prisma + Vue3 + Ant Design Vue)。 - 登录鉴权、材料库(筛选/收藏/自建CRUD/新建材料含Y0/Yp/B散发参数) - 模板库、项目配置(新建项目→空间抽屉→分类窗口选材→预计算→生成报告) - 继续配置预测(草稿)、共享预测引擎(质量平衡稳态模型,公式待标定) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f79f0a1249
|
|
@ -0,0 +1,13 @@
|
|||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vite/
|
||||
.turbo/
|
||||
# research/ 是对原站的探查脚本(含原站登录凭据)+截图,属本地scratch,不入库
|
||||
research/
|
||||
prisma/*.db
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# 室内装修工程污染物预测系统(复刻版)
|
||||
|
||||
基于原 [indoorhealthair.com](https://indoorhealthair.com/iapip-web) 功能复刻。全栈 TypeScript。
|
||||
|
||||
## 技术栈
|
||||
| 层 | 选型 |
|
||||
|---|---|
|
||||
| 前端 | Vue3 + Vite + Ant Design Vue + Pinia + Vue Router |
|
||||
| 后端 | NestJS + Prisma |
|
||||
| 数据库 | PostgreSQL |
|
||||
| 包管理 | pnpm 单仓(monorepo) |
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
apps/
|
||||
api/ NestJS 后端
|
||||
prisma/schema.prisma 数据模型
|
||||
prisma/seed.ts 种子数据(组织 + 公共材料)
|
||||
src/auth/ 登录鉴权 (JWT)
|
||||
src/materials/ 材料库接口
|
||||
web/ Vue3 前端
|
||||
src/pages/ 页面(登录/首页/材料库/模板库/历史)
|
||||
src/layouts/ 顶部导航布局
|
||||
src/api/ 接口封装
|
||||
src/stores/ Pinia 状态
|
||||
packages/
|
||||
shared/ 前后端共享:污染物/标准/枚举 + 预测引擎
|
||||
research/ 对原站的功能抓取(截图+脚本,仅供参考)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 2. 配置数据库连接
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
# 编辑 .env,填入 DATABASE_URL
|
||||
|
||||
# 3. 建表 + 种子数据
|
||||
pnpm db:migrate # 创建数据库表
|
||||
pnpm db:seed # 写入组织(YPJKKJ/CBMA123456) + 示例材料
|
||||
|
||||
# 4. 启动(前后端并行)
|
||||
pnpm dev
|
||||
# API: http://localhost:3000/api
|
||||
# Web: http://localhost:5173 登录账号 YPJKKJ / CBMA123456
|
||||
```
|
||||
|
||||
## 开发路线图
|
||||
- [x] **阶段 0** 地基:monorepo、共享域、登录、布局导航
|
||||
- [x] **阶段 1** 数据底座:数据模型、材料库接口、种子数据
|
||||
- [x] **阶段 2** 材料库 + 模板库(收藏、自建库 CRUD、新建材料表单)
|
||||
- [x] **阶段 3** 项目配置核心(新建项目、空间抽屉、选材/预计算、生成报告)
|
||||
- [ ] **阶段 4** 预测引擎(接入标定散发公式、贡献率、评级)
|
||||
- [ ] **阶段 5** 报告 + 历史记录(生成/查看/复用)
|
||||
- [ ] **阶段 6** 联调打磨、部署
|
||||
|
||||
## ⚠️ 待补充的关键资产
|
||||
1. **散发模型公式**:`packages/shared/src/prediction.ts` 的 `emissionRate()` 目前为占位实现,需替换为你已标定的公式。
|
||||
2. **材料检测数据**:`apps/api/prisma/seed.ts` 中材料散发参数(Y0/Yp/B)为占位值,需导入真实实验室检测数据。
|
||||
3. **国标限值**:`packages/shared/src/pollutants.ts` 中 GB39126-2020 / GB-T18883-2022 限值为草拟值,需按官方标准核对(GB50325-2020 已按原站抓取)。
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# PostgreSQL 连接串。本地安装或用云端 Neon/Supabase 均可。
|
||||
DATABASE_URL="postgresql://用户名:密码@主机:5432/airpredict?schema=public"
|
||||
|
||||
# JWT 密钥(请改成随机长字符串)
|
||||
JWT_SECRET="change-me-to-a-long-random-secret"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# API 端口
|
||||
PORT=3000
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@airpredict/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@airpredict/shared": "workspace:*",
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.4",
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"prisma": "^5.20.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "materials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"brand" TEXT,
|
||||
"manufacturer" TEXT,
|
||||
"spec" TEXT,
|
||||
"envGrade" TEXT,
|
||||
"usageUnit" TEXT NOT NULL DEFAULT 'm²',
|
||||
"emissionParams" JSONB NOT NULL,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerOrgId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "materials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projects" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"province" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL,
|
||||
"area" DOUBLE PRECISION NOT NULL,
|
||||
"rating" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"isTemplate" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerOrgId" TEXT NOT NULL,
|
||||
"reportGeneratedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "spaces" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"layout" TEXT NOT NULL DEFAULT 'uniform',
|
||||
"height" DOUBLE PRECISION,
|
||||
"area" DOUBLE PRECISION NOT NULL,
|
||||
"volume" DOUBLE PRECISION NOT NULL,
|
||||
"temperature" DOUBLE PRECISION NOT NULL,
|
||||
"humidity" DOUBLE PRECISION NOT NULL,
|
||||
"ventilationRate" DOUBLE PRECISION NOT NULL,
|
||||
"standard" TEXT NOT NULL DEFAULT 'GB50325-2020',
|
||||
"predictedConc" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "spaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "space_materials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spaceId" TEXT NOT NULL,
|
||||
"materialId" TEXT NOT NULL,
|
||||
"usageUnit" TEXT NOT NULL DEFAULT 'm²',
|
||||
"usageAmount" DOUBLE PRECISION NOT NULL,
|
||||
"contribution" JSONB,
|
||||
"contributionRate" JSONB,
|
||||
|
||||
CONSTRAINT "space_materials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "favorites" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orgId" TEXT NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reports" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"rating" TEXT,
|
||||
"payload" JSONB NOT NULL,
|
||||
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "organizations_username_key" ON "organizations"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_category_idx" ON "materials"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_brand_idx" ON "materials"("brand");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_envGrade_idx" ON "materials"("envGrade");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_isPublic_idx" ON "materials"("isPublic");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_ownerOrgId_idx" ON "projects"("ownerOrgId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_isTemplate_isPublic_idx" ON "projects"("isTemplate", "isPublic");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "spaces_projectId_idx" ON "spaces"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "space_materials_spaceId_idx" ON "space_materials"("spaceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "space_materials_materialId_idx" ON "space_materials"("materialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "favorites_orgId_targetType_targetId_key" ON "favorites"("orgId", "targetType", "targetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reports_projectId_idx" ON "reports"("projectId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materials" ADD CONSTRAINT "materials_ownerOrgId_fkey" FOREIGN KEY ("ownerOrgId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_ownerOrgId_fkey" FOREIGN KEY ("ownerOrgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "spaces" ADD CONSTRAINT "spaces_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "space_materials" ADD CONSTRAINT "space_materials_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "space_materials" ADD CONSTRAINT "space_materials_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// 室内装修工程污染物预测系统 — 数据模型
|
||||
// Prisma schema. 详见各模型注释(字段来自对原系统的功能抓取)。
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// 组织/账号(登录主体)。原系统一个账号对应一个组织,如 YPJKKJ → 一品健康空间。
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
username String @unique // 账号名,如 YPJKKJ
|
||||
name String // 组织展示名,如 一品健康空间
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
materials Material[]
|
||||
projects Project[]
|
||||
favorites Favorite[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
/// 材料库条目。公共库由平台维护,自建库归属某组织。
|
||||
model Material {
|
||||
id String @id // 业务ID,如 PM13000003
|
||||
name String // 材料名称
|
||||
category String // 材料类别,如 人造板/胶合板
|
||||
brand String? // 材料品牌
|
||||
manufacturer String? // 材料厂家
|
||||
spec String? // 材料规格,如 2.7SE / 18mm
|
||||
envGrade String? // 环保等级 E0/E1/E2
|
||||
usageUnit String @default("m²") // 用量单位
|
||||
|
||||
/// 污染物散发参数 Record<Pollutant, {y0,yp,b}>,对应 shared 的 EmissionParams
|
||||
emissionParams Json
|
||||
|
||||
isPublic Boolean @default(false) // true=公共库
|
||||
ownerOrg Organization? @relation(fields: [ownerOrgId], references: [id])
|
||||
ownerOrgId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
spaceMaterials SpaceMaterial[]
|
||||
|
||||
@@index([category])
|
||||
@@index([brand])
|
||||
@@index([envGrade])
|
||||
@@index([isPublic])
|
||||
@@map("materials")
|
||||
}
|
||||
|
||||
/// 预测项目;isTemplate=true 时作为项目模板库条目。
|
||||
model Project {
|
||||
id String @id // 业务ID,如 P36WEVFEV / 模板ID
|
||||
name String // 工程名称
|
||||
type String // 项目类型:住宅/酒店/办公楼/医院/学校/养老院/其他
|
||||
province String // 省
|
||||
city String // 市
|
||||
area Float // 建筑面积 m²
|
||||
rating String? // 预测评级 A/B/C/D
|
||||
|
||||
status String @default("draft") // draft|configuring|report_generated
|
||||
isTemplate Boolean @default(false)
|
||||
isPublic Boolean @default(false)
|
||||
|
||||
owner Organization @relation(fields: [ownerOrgId], references: [id])
|
||||
ownerOrgId String
|
||||
|
||||
reportGeneratedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
spaces Space[]
|
||||
reports Report[]
|
||||
|
||||
@@index([ownerOrgId])
|
||||
@@index([isTemplate, isPublic])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
/// 项目内的空间(房间)。
|
||||
model Space {
|
||||
id String @id // 业务ID,如 S36WF116D
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
name String // 空间名称
|
||||
type String // 空间类型:客厅/卧室/...
|
||||
layout String @default("uniform") // uniform=等高 | non-uniform=非等高
|
||||
height Float? // 高度 m
|
||||
area Float // 面积 m²
|
||||
volume Float // 体积 m³
|
||||
temperature Float // 温度 ℃
|
||||
humidity Float // 湿度 %rh
|
||||
ventilationRate Float // 通风换气率 次/小时
|
||||
standard String @default("GB50325-2020") // 污染物浓度限值标准
|
||||
|
||||
/// 预测浓度 Record<Pollutant, number> (mg/m³),预计算/生成报告后写入
|
||||
predictedConc Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
materials SpaceMaterial[]
|
||||
|
||||
@@index([projectId])
|
||||
@@map("spaces")
|
||||
}
|
||||
|
||||
/// 空间-材料关联及该材料的用量与污染物贡献。
|
||||
model SpaceMaterial {
|
||||
id String @id @default(cuid())
|
||||
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
|
||||
spaceId String
|
||||
material Material @relation(fields: [materialId], references: [id])
|
||||
materialId String
|
||||
usageUnit String @default("m²")
|
||||
usageAmount Float
|
||||
|
||||
/// 各污染物贡献量 Record<Pollutant, number> (mg/m³)
|
||||
contribution Json?
|
||||
/// 各污染物贡献率 Record<Pollutant, number> (0~1)
|
||||
contributionRate Json?
|
||||
|
||||
@@index([spaceId])
|
||||
@@index([materialId])
|
||||
@@map("space_materials")
|
||||
}
|
||||
|
||||
/// 收藏(材料或模板)。
|
||||
model Favorite {
|
||||
id String @id @default(cuid())
|
||||
org Organization @relation(fields: [orgId], references: [id])
|
||||
orgId String
|
||||
targetType String // 'material' | 'template'
|
||||
targetId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([orgId, targetType, targetId])
|
||||
@@map("favorites")
|
||||
}
|
||||
|
||||
/// 生成的预测报告快照。
|
||||
model Report {
|
||||
id String @id @default(cuid())
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
rating String?
|
||||
/// 报告完整数据快照(项目+空间+材料+预测结果)
|
||||
payload Json
|
||||
generatedAt DateTime @default(now())
|
||||
|
||||
@@index([projectId])
|
||||
@@map("reports")
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 五项污染物默认散发参数生成器(占位值;真实数据由实验室检测导入)
|
||||
function ep(y0: number) {
|
||||
return {
|
||||
hcho: { y0, yp: y0 * 1.5, b: 0.01 },
|
||||
tvoc: { y0: y0 * 0.6, yp: y0 * 0.9, b: 0.01 },
|
||||
benzene: { y0: y0 * 0.05, yp: y0 * 0.08, b: 0.005 },
|
||||
toluene: { y0: y0 * 0.1, yp: y0 * 0.15, b: 0.005 },
|
||||
xylene: { y0: y0 * 0.08, yp: y0 * 0.12, b: 0.005 },
|
||||
};
|
||||
}
|
||||
|
||||
// 取自原系统公共材料库前若干条(散发参数为占位)
|
||||
const MATERIALS = [
|
||||
{ id: 'PM13000003', name: '胶合板', category: '人造板/胶合板', brand: '东营正和', manufacturer: '东营正和', spec: '2.7SE', envGrade: 'E1', y0: 0.06 },
|
||||
{ id: 'PM13000004', name: '胶合板', category: '人造板/胶合板', brand: '东营正和', manufacturer: '东营正和', spec: '3N-3mm', envGrade: 'E0', y0: 0.03 },
|
||||
{ id: 'PM13000005', name: '胶合板', category: '人造板/胶合板', brand: '金秋', manufacturer: '河北金秋木业有限责任公司', spec: '8mm', envGrade: null, y0: 0.05 },
|
||||
{ id: 'PM13000006', name: '胶合板', category: '人造板/胶合板', brand: '金秋', manufacturer: '河北金秋木业有限责任公司', spec: '12mm', envGrade: null, y0: 0.05 },
|
||||
{ id: 'PM13000007', name: '胶合板', category: '人造板/胶合板', brand: '金秋', manufacturer: '河北金秋木业有限责任公司', spec: '15mm', envGrade: null, y0: 0.055 },
|
||||
{ id: 'PM13000008', name: '胶合板', category: '人造板/胶合板', brand: '金秋', manufacturer: '河北金秋木业有限责任公司', spec: '18mm', envGrade: null, y0: 0.06 },
|
||||
{ id: 'PM13000009', name: '阻燃胶合板', category: '人造板/阻燃胶合板', brand: '兔宝宝', manufacturer: '德华兔宝宝装饰新材股份有限公司', spec: null, envGrade: null, y0: 0.045 },
|
||||
{ id: 'PM13000010', name: '阻燃板', category: '人造板/胶合板', brand: '福益安', manufacturer: '北京江夏木业有限公司', spec: null, envGrade: 'E1', y0: 0.05 },
|
||||
{ id: 'PM13000011', name: '阻燃板', category: '人造板/阻燃胶合板', brand: '莫干山', manufacturer: '浙江升华云峰新材股份有限公司', spec: 'E1', envGrade: 'E1', y0: 0.05 },
|
||||
{ id: 'PM13000012', name: '非醛多层基材胶合板', category: '人造板/胶合板', brand: '升达', manufacturer: '四川升达林产业股份有限公司', spec: null, envGrade: null, y0: 0.02 },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('CBMA123456', 10);
|
||||
const org = await prisma.organization.upsert({
|
||||
where: { username: 'YPJKKJ' },
|
||||
update: {},
|
||||
create: { username: 'YPJKKJ', name: '一品健康空间', passwordHash },
|
||||
});
|
||||
console.log('组织已就绪:', org.username, org.name);
|
||||
|
||||
for (const m of MATERIALS) {
|
||||
await prisma.material.upsert({
|
||||
where: { id: m.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
category: m.category,
|
||||
brand: m.brand,
|
||||
manufacturer: m.manufacturer,
|
||||
spec: m.spec ?? undefined,
|
||||
envGrade: m.envGrade ?? undefined,
|
||||
usageUnit: 'm²',
|
||||
emissionParams: ep(m.y0),
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`);
|
||||
|
||||
// 一条公共项目模板(含 1 个空间 + 2 种材料),供模板库展示
|
||||
const tplId = 'T13000001';
|
||||
await prisma.project.upsert({
|
||||
where: { id: tplId },
|
||||
update: {},
|
||||
create: {
|
||||
id: tplId,
|
||||
name: '标准住宅卧室模板',
|
||||
type: '住宅',
|
||||
province: '北京市',
|
||||
city: '北京市',
|
||||
area: 90,
|
||||
isTemplate: true,
|
||||
isPublic: true,
|
||||
ownerOrgId: org.id,
|
||||
status: 'report_generated',
|
||||
rating: 'A',
|
||||
spaces: {
|
||||
create: [
|
||||
{
|
||||
id: 'TS13000001',
|
||||
name: '主卧',
|
||||
type: '卧室',
|
||||
layout: 'uniform',
|
||||
height: 2.8,
|
||||
area: 15,
|
||||
volume: 42,
|
||||
temperature: 25,
|
||||
humidity: 50,
|
||||
ventilationRate: 0.5,
|
||||
standard: 'GB50325-2020',
|
||||
materials: {
|
||||
create: [
|
||||
{ materialId: 'PM13000004', usageUnit: 'm²', usageAmount: 30 },
|
||||
{ materialId: 'PM13000012', usageUnit: 'm²', usageAmount: 20 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('已导入 1 条公共项目模板');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { MaterialsModule } from './materials/materials.module';
|
||||
import { FavoritesModule } from './favorites/favorites.module';
|
||||
import { TemplatesModule } from './templates/templates.module';
|
||||
import { PredictionModule } from './prediction/prediction.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { SpacesModule } from './spaces/spaces.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
MaterialsModule,
|
||||
FavoritesModule,
|
||||
TemplatesModule,
|
||||
PredictionModule,
|
||||
ProjectsModule,
|
||||
SpacesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from './current-org.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto.username, dto.password);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentOrg() org: OrgPayload) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'change-me',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const org = await this.prisma.organization.findUnique({ where: { username } });
|
||||
if (!org || !(await bcrypt.compare(password, org.passwordHash))) {
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
const token = await this.jwt.signAsync({ sub: org.id, username: org.username });
|
||||
return { token, org: { id: org.id, username: org.username, name: org.name } };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface OrgPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const CurrentOrg = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): OrgPayload => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
return req.user;
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'change-me',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string; username: string }) {
|
||||
return { id: payload.sub, username: payload.username };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsIn, IsString } from 'class-validator';
|
||||
|
||||
export class ToggleFavoriteDto {
|
||||
@IsIn(['material', 'template'])
|
||||
targetType!: 'material' | 'template';
|
||||
|
||||
@IsString()
|
||||
targetId!: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { ToggleFavoriteDto } from './dto/toggle-favorite.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from '../auth/current-org.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('favorites')
|
||||
export class FavoritesController {
|
||||
constructor(private favorites: FavoritesService) {}
|
||||
|
||||
@Post('toggle')
|
||||
toggle(@CurrentOrg() org: OrgPayload, @Body() dto: ToggleFavoriteDto) {
|
||||
return this.favorites.toggle(org.id, dto.targetType, dto.targetId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { FavoritesController } from './favorites.controller';
|
||||
|
||||
@Module({
|
||||
providers: [FavoritesService],
|
||||
controllers: [FavoritesController],
|
||||
exports: [FavoritesService],
|
||||
})
|
||||
export class FavoritesModule {}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type FavoriteTarget = 'material' | 'template';
|
||||
|
||||
@Injectable()
|
||||
export class FavoritesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** 切换收藏,返回切换后的状态 */
|
||||
async toggle(orgId: string, targetType: FavoriteTarget, targetId: string) {
|
||||
const existing = await this.prisma.favorite.findUnique({
|
||||
where: { orgId_targetType_targetId: { orgId, targetType, targetId } },
|
||||
});
|
||||
if (existing) {
|
||||
await this.prisma.favorite.delete({ where: { id: existing.id } });
|
||||
return { favorited: false };
|
||||
}
|
||||
await this.prisma.favorite.create({ data: { orgId, targetType, targetId } });
|
||||
return { favorited: true };
|
||||
}
|
||||
|
||||
/** 取某组织某类型的全部收藏 id */
|
||||
async idsOf(orgId: string, targetType: FavoriteTarget): Promise<Set<string>> {
|
||||
const rows = await this.prisma.favorite.findMany({
|
||||
where: { orgId, targetType },
|
||||
select: { targetId: true },
|
||||
});
|
||||
return new Set(rows.map((r) => r.targetId));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`API 已启动: http://localhost:${port}/api`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class EmissionParamDto {
|
||||
@IsNumber() @Min(0) y0!: number;
|
||||
@IsNumber() @Min(0) yp!: number;
|
||||
@IsNumber() @Min(0) b!: number;
|
||||
}
|
||||
|
||||
export class EmissionParamsDto {
|
||||
@ValidateNested() @Type(() => EmissionParamDto) hcho!: EmissionParamDto;
|
||||
@ValidateNested() @Type(() => EmissionParamDto) tvoc!: EmissionParamDto;
|
||||
@ValidateNested() @Type(() => EmissionParamDto) benzene!: EmissionParamDto;
|
||||
@ValidateNested() @Type(() => EmissionParamDto) toluene!: EmissionParamDto;
|
||||
@ValidateNested() @Type(() => EmissionParamDto) xylene!: EmissionParamDto;
|
||||
}
|
||||
|
||||
export class CreateMaterialDto {
|
||||
@IsString() name!: string;
|
||||
@IsString() category!: string;
|
||||
@IsOptional() @IsString() brand?: string;
|
||||
@IsOptional() @IsString() manufacturer?: string;
|
||||
@IsOptional() @IsString() spec?: string;
|
||||
@IsOptional() @IsString() envGrade?: string;
|
||||
@IsOptional() @IsString() usageUnit?: string;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EmissionParamsDto)
|
||||
emissionParams!: EmissionParamsDto;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class QueryMaterialsDto {
|
||||
@IsOptional() @IsString() id?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
@IsOptional() @IsString() brand?: string;
|
||||
@IsOptional() @IsString() manufacturer?: string;
|
||||
@IsOptional() @IsString() spec?: string;
|
||||
@IsOptional() @IsString() envGrade?: string;
|
||||
|
||||
/** 公共库 public | 自建库 self */
|
||||
@IsOptional() @IsIn(['public', 'self']) scope?: 'public' | 'self';
|
||||
/** 仅看收藏 */
|
||||
@IsOptional() @IsString() favorited?: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) page?: number = 1;
|
||||
@IsOptional() @Type(() => Number) pageSize?: number = 10;
|
||||
@IsOptional() @IsString() sort?: string; // e.g. updatedAt:desc
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import { IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { EmissionParamsDto } from './create-material.dto';
|
||||
|
||||
export class UpdateMaterialDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() category?: string;
|
||||
@IsOptional() @IsString() brand?: string;
|
||||
@IsOptional() @IsString() manufacturer?: string;
|
||||
@IsOptional() @IsString() spec?: string;
|
||||
@IsOptional() @IsString() envGrade?: string;
|
||||
@IsOptional() @IsString() usageUnit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EmissionParamsDto)
|
||||
emissionParams?: EmissionParamsDto;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { MaterialsService } from './materials.service';
|
||||
import { QueryMaterialsDto } from './dto/query-materials.dto';
|
||||
import { CreateMaterialDto } from './dto/create-material.dto';
|
||||
import { UpdateMaterialDto } from './dto/update-material.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from '../auth/current-org.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('materials')
|
||||
export class MaterialsController {
|
||||
constructor(private materials: MaterialsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentOrg() org: OrgPayload, @Query() q: QueryMaterialsDto) {
|
||||
return this.materials.list(org.id, q);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.materials.detail(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentOrg() org: OrgPayload, @Body() dto: CreateMaterialDto) {
|
||||
return this.materials.create(org.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentOrg() org: OrgPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMaterialDto,
|
||||
) {
|
||||
return this.materials.update(org.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.materials.remove(org.id, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MaterialsService } from './materials.service';
|
||||
import { MaterialsController } from './materials.controller';
|
||||
import { FavoritesModule } from '../favorites/favorites.module';
|
||||
|
||||
@Module({
|
||||
imports: [FavoritesModule],
|
||||
providers: [MaterialsService],
|
||||
controllers: [MaterialsController],
|
||||
})
|
||||
export class MaterialsModule {}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FavoritesService } from '../favorites/favorites.service';
|
||||
import { QueryMaterialsDto } from './dto/query-materials.dto';
|
||||
import { CreateMaterialDto } from './dto/create-material.dto';
|
||||
import { UpdateMaterialDto } from './dto/update-material.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MaterialsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private favorites: FavoritesService,
|
||||
) {}
|
||||
|
||||
async list(orgId: string, q: QueryMaterialsDto) {
|
||||
const where: Prisma.MaterialWhereInput = {};
|
||||
|
||||
if (q.scope === 'self') where.ownerOrgId = orgId;
|
||||
else where.isPublic = true;
|
||||
|
||||
if (q.id) where.id = { contains: q.id, mode: 'insensitive' };
|
||||
if (q.name) where.name = { contains: q.name, mode: 'insensitive' };
|
||||
if (q.category) where.category = { contains: q.category };
|
||||
if (q.brand) where.brand = { contains: q.brand };
|
||||
if (q.manufacturer) where.manufacturer = { contains: q.manufacturer };
|
||||
if (q.spec) where.spec = { contains: q.spec };
|
||||
if (q.envGrade) where.envGrade = q.envGrade;
|
||||
|
||||
if (q.favorited === 'true') {
|
||||
const favIds = await this.favorites.idsOf(orgId, 'material');
|
||||
where.id = { in: [...favIds] };
|
||||
}
|
||||
|
||||
const page = Number(q.page) || 1;
|
||||
const pageSize = Number(q.pageSize) || 10;
|
||||
const orderBy = this.parseSort(q.sort);
|
||||
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.material.count({ where }),
|
||||
this.prisma.material.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
const favIds = await this.favorites.idsOf(orgId, 'material');
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
items: items.map((m) => ({ ...m, favorited: favIds.has(m.id) })),
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string) {
|
||||
const m = await this.prisma.material.findUnique({ where: { id } });
|
||||
if (!m) throw new NotFoundException('材料不存在');
|
||||
return m;
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateMaterialDto) {
|
||||
return this.prisma.material.create({
|
||||
data: {
|
||||
id: this.genId(),
|
||||
name: dto.name,
|
||||
category: dto.category,
|
||||
brand: dto.brand,
|
||||
manufacturer: dto.manufacturer,
|
||||
spec: dto.spec,
|
||||
envGrade: dto.envGrade,
|
||||
usageUnit: dto.usageUnit ?? 'm²',
|
||||
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
|
||||
isPublic: false,
|
||||
ownerOrgId: orgId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(orgId: string, id: string, dto: UpdateMaterialDto) {
|
||||
await this.assertOwned(orgId, id);
|
||||
return this.prisma.material.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(orgId: string, id: string) {
|
||||
await this.assertOwned(orgId, id);
|
||||
await this.prisma.material.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async assertOwned(orgId: string, id: string) {
|
||||
const m = await this.prisma.material.findUnique({ where: { id } });
|
||||
if (!m) throw new NotFoundException('材料不存在');
|
||||
if (m.isPublic || m.ownerOrgId !== orgId) {
|
||||
throw new ForbiddenException('只能修改自建材料');
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成业务ID,形如 PM + 11位数字 */
|
||||
private genId(): string {
|
||||
const n = Date.now().toString().slice(-9) + Math.floor(Math.random() * 90 + 10);
|
||||
return 'PM' + n;
|
||||
}
|
||||
|
||||
private parseSort(sort?: string): Prisma.MaterialOrderByWithRelationInput {
|
||||
if (!sort) return { updatedAt: 'desc' };
|
||||
const [field, dir] = sort.split(':');
|
||||
const allowed = ['updatedAt', 'name', 'id', 'envGrade'];
|
||||
if (!allowed.includes(field)) return { updatedAt: 'desc' };
|
||||
return { [field]: dir === 'asc' ? 'asc' : 'desc' };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PredictionService } from './prediction.service';
|
||||
|
||||
@Module({
|
||||
providers: [PredictionService],
|
||||
exports: [PredictionService],
|
||||
})
|
||||
export class PredictionModule {}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
predictSpace,
|
||||
type EmissionParams,
|
||||
type Pollutant,
|
||||
type SpaceMaterialInput,
|
||||
type StandardCode,
|
||||
} from '@airpredict/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface SpaceMaterialUsage {
|
||||
materialId: string;
|
||||
usageAmount: number;
|
||||
}
|
||||
|
||||
export interface SpaceComputeInput {
|
||||
volume: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
ventilationRate: number;
|
||||
standard: StandardCode;
|
||||
materials: SpaceMaterialUsage[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PredictionService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** 拉取材料散发参数,调用 shared 预测引擎,返回浓度+贡献+评级 */
|
||||
async computeSpace(input: SpaceComputeInput) {
|
||||
const ids = input.materials.map((m) => m.materialId);
|
||||
const materials = await this.prisma.material.findMany({ where: { id: { in: ids } } });
|
||||
const byId = new Map(materials.map((m) => [m.id, m]));
|
||||
|
||||
const engineInputs: SpaceMaterialInput[] = input.materials.map((m) => {
|
||||
const mat = byId.get(m.materialId);
|
||||
if (!mat) throw new BadRequestException(`材料不存在: ${m.materialId}`);
|
||||
return {
|
||||
materialId: m.materialId,
|
||||
usageAmount: m.usageAmount,
|
||||
params: mat.emissionParams as unknown as Record<Pollutant, EmissionParams>,
|
||||
};
|
||||
});
|
||||
|
||||
return predictSpace(engineInputs, {
|
||||
volume: input.volume,
|
||||
temperature: input.temperature,
|
||||
humidity: input.humidity,
|
||||
ventilationRate: input.ventilationRate,
|
||||
standard: input.standard,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString() name!: string;
|
||||
@IsString() type!: string;
|
||||
@IsString() province!: string;
|
||||
@IsString() city!: string;
|
||||
@IsNumber() @IsPositive() area!: number;
|
||||
|
||||
/** 可选:从模板复制创建 */
|
||||
@IsOptional() @IsString() fromTemplateId?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class QueryProjectsDto {
|
||||
@IsOptional() @IsString() id?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() rating?: string;
|
||||
|
||||
/** draft|configuring|report_generated;history 用 report_generated */
|
||||
@IsOptional() @IsString() status?: string;
|
||||
/** 仅未生成报告的草稿(继续配置预测用) */
|
||||
@IsOptional() @IsIn(['true', 'false']) unfinished?: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) page?: number = 1;
|
||||
@IsOptional() @Type(() => Number) pageSize?: number = 10;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
@IsOptional() @IsString() province?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsNumber() @IsPositive() area?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { QueryProjectsDto } from './dto/query-projects.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from '../auth/current-org.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('projects')
|
||||
export class ProjectsController {
|
||||
constructor(private projects: ProjectsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentOrg() org: OrgPayload, @Query() q: QueryProjectsDto) {
|
||||
return this.projects.list(org.id, q);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.projects.detail(org.id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentOrg() org: OrgPayload, @Body() dto: CreateProjectDto) {
|
||||
return this.projects.create(org.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentOrg() org: OrgPayload, @Param('id') id: string, @Body() dto: UpdateProjectDto) {
|
||||
return this.projects.update(org.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.projects.remove(org.id, id);
|
||||
}
|
||||
|
||||
@Post(':id/generate')
|
||||
generate(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.projects.generate(org.id, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import { ProjectsController } from './projects.controller';
|
||||
import { PredictionModule } from '../prediction/prediction.module';
|
||||
|
||||
@Module({
|
||||
imports: [PredictionModule],
|
||||
providers: [ProjectsService],
|
||||
controllers: [ProjectsController],
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { ratingFor, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PredictionService } from '../prediction/prediction.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { QueryProjectsDto } from './dto/query-projects.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private prediction: PredictionService,
|
||||
) {}
|
||||
|
||||
async create(orgId: string, dto: CreateProjectDto) {
|
||||
const id = this.genId('P');
|
||||
|
||||
// 从模板复制空间+材料
|
||||
let spacesCreate: Prisma.SpaceCreateWithoutProjectInput[] | undefined;
|
||||
if (dto.fromTemplateId) {
|
||||
const tpl = await this.prisma.project.findFirst({
|
||||
where: { id: dto.fromTemplateId, isTemplate: true },
|
||||
include: { spaces: { include: { materials: true } } },
|
||||
});
|
||||
if (tpl) {
|
||||
spacesCreate = tpl.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,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.project.create({
|
||||
data: {
|
||||
id,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
province: dto.province,
|
||||
city: dto.city,
|
||||
area: dto.area,
|
||||
status: 'configuring',
|
||||
ownerOrgId: orgId,
|
||||
...(spacesCreate ? { spaces: { create: spacesCreate } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(orgId: string, q: QueryProjectsDto) {
|
||||
const where: Prisma.ProjectWhereInput = { ownerOrgId: orgId, isTemplate: false };
|
||||
if (q.id) where.id = { contains: q.id, mode: 'insensitive' };
|
||||
if (q.name) where.name = { contains: q.name, mode: 'insensitive' };
|
||||
if (q.type) where.type = q.type;
|
||||
if (q.rating) where.rating = q.rating;
|
||||
if (q.city) where.OR = [{ province: { contains: q.city } }, { city: { contains: q.city } }];
|
||||
if (q.status) where.status = q.status;
|
||||
if (q.unfinished === 'true') where.status = { not: 'report_generated' };
|
||||
|
||||
const page = Number(q.page) || 1;
|
||||
const pageSize = Number(q.pageSize) || 10;
|
||||
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.project.count({ where }),
|
||||
this.prisma.project.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { _count: { select: { spaces: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
items: items.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
province: p.province,
|
||||
city: p.city,
|
||||
area: p.area,
|
||||
rating: p.rating,
|
||||
status: p.status,
|
||||
spaceCount: p._count.spaces,
|
||||
reportGeneratedAt: p.reportGeneratedAt,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async detail(orgId: string, id: string) {
|
||||
const p = await this.prisma.project.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
spaces: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { materials: { include: { material: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!p || p.isTemplate) throw new NotFoundException('项目不存在');
|
||||
if (p.ownerOrgId !== orgId) throw new ForbiddenException('无权访问');
|
||||
return p;
|
||||
}
|
||||
|
||||
async update(orgId: string, id: string, dto: UpdateProjectDto) {
|
||||
await this.assertOwned(orgId, id);
|
||||
return this.prisma.project.update({ where: { id }, data: { ...dto } });
|
||||
}
|
||||
|
||||
async remove(orgId: string, id: string) {
|
||||
await this.assertOwned(orgId, id);
|
||||
await this.prisma.project.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** 生成预测报告:逐空间预测,落库浓度+贡献,算项目评级,写 Report */
|
||||
async generate(orgId: string, id: string) {
|
||||
const p = await this.detail(orgId, id);
|
||||
if (!p.spaces.length) throw new NotFoundException('项目下没有空间,无法生成报告');
|
||||
|
||||
const spaceResults: { rating: string; conc: Record<Pollutant, number> }[] = [];
|
||||
|
||||
for (const space of p.spaces) {
|
||||
const result = await this.prediction.computeSpace({
|
||||
volume: space.volume,
|
||||
temperature: space.temperature,
|
||||
humidity: space.humidity,
|
||||
ventilationRate: space.ventilationRate,
|
||||
standard: space.standard as StandardCode,
|
||||
materials: space.materials.map((m) => ({ materialId: m.materialId, usageAmount: m.usageAmount })),
|
||||
});
|
||||
|
||||
await this.prisma.space.update({
|
||||
where: { id: space.id },
|
||||
data: { predictedConc: result.concentration as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
for (const c of result.contributions) {
|
||||
const sm = space.materials.find((m) => m.materialId === c.materialId);
|
||||
if (sm) {
|
||||
await this.prisma.spaceMaterial.update({
|
||||
where: { id: sm.id },
|
||||
data: {
|
||||
contribution: c.contribution as unknown as Prisma.InputJsonValue,
|
||||
contributionRate: c.contributionRate as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spaceResults.push({ rating: result.rating, conc: result.concentration });
|
||||
}
|
||||
|
||||
// 项目评级 = 各空间中最差评级
|
||||
const order = { A: 0, B: 1, C: 2, D: 3 } as const;
|
||||
const projectRating = spaceResults.reduce(
|
||||
(worst, s) => (order[s.rating as keyof typeof order] > order[worst as keyof typeof order] ? s.rating : worst),
|
||||
'A',
|
||||
);
|
||||
|
||||
const updated = await this.prisma.project.update({
|
||||
where: { id },
|
||||
data: { rating: projectRating, status: 'report_generated', reportGeneratedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.report.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
rating: projectRating,
|
||||
payload: { generatedAt: updated.reportGeneratedAt, spaceResults } as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return this.detail(orgId, id);
|
||||
}
|
||||
|
||||
private async assertOwned(orgId: string, id: string) {
|
||||
const p = await this.prisma.project.findUnique({ where: { id } });
|
||||
if (!p || p.isTemplate) throw new NotFoundException('项目不存在');
|
||||
if (p.ownerOrgId !== orgId) throw new ForbiddenException('无权操作');
|
||||
}
|
||||
|
||||
private genId(prefix: string): string {
|
||||
let s = '';
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
||||
return prefix + s;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SpaceMaterialDto {
|
||||
@IsString() materialId!: string;
|
||||
@IsOptional() @IsString() usageUnit?: string;
|
||||
@IsNumber() @Min(0) usageAmount!: number;
|
||||
}
|
||||
|
||||
export class CreateSpaceDto {
|
||||
@IsString() projectId!: string;
|
||||
@IsString() name!: string;
|
||||
@IsString() type!: string;
|
||||
@IsOptional() @IsIn(['uniform', 'non-uniform']) layout?: string;
|
||||
@IsOptional() @IsNumber() @IsPositive() height?: number;
|
||||
@IsNumber() @IsPositive() area!: number;
|
||||
@IsNumber() @IsPositive() volume!: number;
|
||||
@IsNumber() temperature!: number;
|
||||
@IsNumber() humidity!: number;
|
||||
@IsNumber() @Min(0) ventilationRate!: number;
|
||||
@IsString() standard!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SpaceMaterialDto)
|
||||
materials!: SpaceMaterialDto[];
|
||||
}
|
||||
|
||||
export class UpdateSpaceDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
@IsOptional() @IsIn(['uniform', 'non-uniform']) layout?: string;
|
||||
@IsOptional() @IsNumber() @IsPositive() height?: number;
|
||||
@IsOptional() @IsNumber() @IsPositive() area?: number;
|
||||
@IsOptional() @IsNumber() @IsPositive() volume?: number;
|
||||
@IsOptional() @IsNumber() temperature?: number;
|
||||
@IsOptional() @IsNumber() humidity?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) ventilationRate?: number;
|
||||
@IsOptional() @IsString() standard?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SpaceMaterialDto)
|
||||
materials?: SpaceMaterialDto[];
|
||||
}
|
||||
|
||||
export class PrecalcDto {
|
||||
@IsNumber() @IsPositive() volume!: number;
|
||||
@IsNumber() temperature!: number;
|
||||
@IsNumber() humidity!: number;
|
||||
@IsNumber() @Min(0) ventilationRate!: number;
|
||||
@IsString() standard!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SpaceMaterialDto)
|
||||
materials!: SpaceMaterialDto[];
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Body, Controller, Delete, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { SpacesService } from './spaces.service';
|
||||
import { CreateSpaceDto, UpdateSpaceDto, PrecalcDto } from './dto/space.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from '../auth/current-org.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('spaces')
|
||||
export class SpacesController {
|
||||
constructor(private spaces: SpacesService) {}
|
||||
|
||||
/** 预计算(不落库) */
|
||||
@Post('precalc')
|
||||
precalc(@Body() dto: PrecalcDto) {
|
||||
return this.spaces.precalc(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentOrg() org: OrgPayload, @Body() dto: CreateSpaceDto) {
|
||||
return this.spaces.create(org.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@CurrentOrg() org: OrgPayload, @Param('id') id: string, @Body() dto: UpdateSpaceDto) {
|
||||
return this.spaces.update(org.id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.spaces.remove(org.id, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SpacesService } from './spaces.service';
|
||||
import { SpacesController } from './spaces.controller';
|
||||
import { PredictionModule } from '../prediction/prediction.module';
|
||||
|
||||
@Module({
|
||||
imports: [PredictionModule],
|
||||
providers: [SpacesService],
|
||||
controllers: [SpacesController],
|
||||
})
|
||||
export class SpacesModule {}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type StandardCode } from '@airpredict/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PredictionService } from '../prediction/prediction.service';
|
||||
import { CreateSpaceDto, UpdateSpaceDto, PrecalcDto } from './dto/space.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SpacesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private prediction: PredictionService,
|
||||
) {}
|
||||
|
||||
/** 预计算:不落库,直接返回浓度+贡献率 */
|
||||
async precalc(dto: PrecalcDto) {
|
||||
return this.prediction.computeSpace({
|
||||
volume: dto.volume,
|
||||
temperature: dto.temperature,
|
||||
humidity: dto.humidity,
|
||||
ventilationRate: dto.ventilationRate,
|
||||
standard: dto.standard as StandardCode,
|
||||
materials: dto.materials.map((m) => ({ materialId: m.materialId, usageAmount: m.usageAmount })),
|
||||
});
|
||||
}
|
||||
|
||||
async create(orgId: string, dto: CreateSpaceDto) {
|
||||
await this.assertProjectOwned(orgId, dto.projectId);
|
||||
const space = await this.prisma.space.create({
|
||||
data: {
|
||||
id: this.genId('S'),
|
||||
projectId: dto.projectId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
layout: dto.layout ?? 'uniform',
|
||||
height: dto.height,
|
||||
area: dto.area,
|
||||
volume: dto.volume,
|
||||
temperature: dto.temperature,
|
||||
humidity: dto.humidity,
|
||||
ventilationRate: dto.ventilationRate,
|
||||
standard: dto.standard,
|
||||
materials: {
|
||||
create: dto.materials.map((m) => ({
|
||||
materialId: m.materialId,
|
||||
usageUnit: m.usageUnit ?? 'm²',
|
||||
usageAmount: m.usageAmount,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { materials: { include: { material: true } } },
|
||||
});
|
||||
await this.touchProject(dto.projectId);
|
||||
return space;
|
||||
}
|
||||
|
||||
async update(orgId: string, id: string, dto: UpdateSpaceDto) {
|
||||
const space = await this.prisma.space.findUnique({ where: { id }, include: { project: true } });
|
||||
if (!space) throw new NotFoundException('空间不存在');
|
||||
if (space.project.ownerOrgId !== orgId) throw new ForbiddenException('无权操作');
|
||||
|
||||
const { materials, ...rest } = dto;
|
||||
const updated = await this.prisma.space.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
// 配置已改,predictedConc 失效
|
||||
predictedConc: Prisma.JsonNull,
|
||||
...(materials
|
||||
? {
|
||||
materials: {
|
||||
deleteMany: {},
|
||||
create: materials.map((m) => ({
|
||||
materialId: m.materialId,
|
||||
usageUnit: m.usageUnit ?? 'm²',
|
||||
usageAmount: m.usageAmount,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { materials: { include: { material: true } } },
|
||||
});
|
||||
await this.touchProject(space.projectId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(orgId: string, id: string) {
|
||||
const space = await this.prisma.space.findUnique({ where: { id }, include: { project: true } });
|
||||
if (!space) throw new NotFoundException('空间不存在');
|
||||
if (space.project.ownerOrgId !== orgId) throw new ForbiddenException('无权操作');
|
||||
await this.prisma.space.delete({ where: { id } });
|
||||
await this.touchProject(space.projectId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async assertProjectOwned(orgId: string, projectId: string) {
|
||||
const p = await this.prisma.project.findUnique({ where: { id: projectId } });
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.ownerOrgId !== orgId) throw new ForbiddenException('无权操作');
|
||||
}
|
||||
|
||||
/** 配置变更后,项目回到未生成报告状态 */
|
||||
private async touchProject(projectId: string) {
|
||||
await this.prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: { status: 'configuring', rating: null, reportGeneratedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
private genId(prefix: string): string {
|
||||
let s = '';
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
||||
return prefix + s;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class QueryTemplatesDto {
|
||||
@IsOptional() @IsString() id?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
|
||||
@IsOptional() @IsIn(['public', 'self']) scope?: 'public' | 'self';
|
||||
@IsOptional() @IsString() favorited?: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) page?: number = 1;
|
||||
@IsOptional() @Type(() => Number) pageSize?: number = 10;
|
||||
@IsOptional() @IsString() sort?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Controller, Delete, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { TemplatesService } from './templates.service';
|
||||
import { QueryTemplatesDto } from './dto/query-templates.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from '../auth/current-org.decorator';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('templates')
|
||||
export class TemplatesController {
|
||||
constructor(private templates: TemplatesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentOrg() org: OrgPayload, @Query() q: QueryTemplatesDto) {
|
||||
return this.templates.list(org.id, q);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.templates.detail(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.templates.remove(org.id, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TemplatesService } from './templates.service';
|
||||
import { TemplatesController } from './templates.controller';
|
||||
import { FavoritesModule } from '../favorites/favorites.module';
|
||||
|
||||
@Module({
|
||||
imports: [FavoritesModule],
|
||||
providers: [TemplatesService],
|
||||
controllers: [TemplatesController],
|
||||
})
|
||||
export class TemplatesModule {}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FavoritesService } from '../favorites/favorites.service';
|
||||
import { QueryTemplatesDto } from './dto/query-templates.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TemplatesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private favorites: FavoritesService,
|
||||
) {}
|
||||
|
||||
async list(orgId: string, q: QueryTemplatesDto) {
|
||||
const where: Prisma.ProjectWhereInput = { isTemplate: true };
|
||||
|
||||
if (q.scope === 'self') where.ownerOrgId = orgId;
|
||||
else where.isPublic = true;
|
||||
|
||||
if (q.id) where.id = { contains: q.id, mode: 'insensitive' };
|
||||
if (q.name) where.name = { contains: q.name, mode: 'insensitive' };
|
||||
if (q.type) where.type = q.type;
|
||||
if (q.city) where.OR = [{ province: { contains: q.city } }, { city: { contains: q.city } }];
|
||||
|
||||
if (q.favorited === 'true') {
|
||||
const favIds = await this.favorites.idsOf(orgId, 'template');
|
||||
where.id = { in: [...favIds] };
|
||||
}
|
||||
|
||||
const page = Number(q.page) || 1;
|
||||
const pageSize = Number(q.pageSize) || 10;
|
||||
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.project.count({ where }),
|
||||
this.prisma.project.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { _count: { select: { spaces: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const favIds = await this.favorites.idsOf(orgId, 'template');
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
items: items.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
province: p.province,
|
||||
city: p.city,
|
||||
area: p.area,
|
||||
spaceCount: p._count.spaces,
|
||||
updatedAt: p.updatedAt,
|
||||
favorited: favIds.has(p.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: string) {
|
||||
const p = await this.prisma.project.findFirst({
|
||||
where: { id, isTemplate: true },
|
||||
include: { spaces: { include: { materials: true } } },
|
||||
});
|
||||
if (!p) throw new NotFoundException('模板不存在');
|
||||
return p;
|
||||
}
|
||||
|
||||
async remove(orgId: string, id: string) {
|
||||
const p = await this.prisma.project.findUnique({ where: { id } });
|
||||
if (!p || !p.isTemplate) throw new NotFoundException('模板不存在');
|
||||
if (p.isPublic || p.ownerOrgId !== orgId) throw new ForbiddenException('只能删除自建模板');
|
||||
await this.prisma.project.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>室内装修工程污染物预测系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@airpredict/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@airpredict/shared": "workspace:*",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.7",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.9",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<a-config-provider :locale="zhCN" :theme="{ token: { colorPrimary: '#b4232a' } }">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
</script>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { http } from './http';
|
||||
|
||||
export function toggleFavorite(targetType: 'material' | 'template', targetId: string) {
|
||||
return http.post<any, { favorited: boolean }>('/favorites/toggle', { targetType, targetId });
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import axios from 'axios';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
export const http = axios.create({ baseURL: '/api' });
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
const msg = err.response?.data?.message || err.message || '请求失败';
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
if (location.hash !== '#/login') location.hash = '#/login';
|
||||
}
|
||||
message.error(Array.isArray(msg) ? msg.join('; ') : msg);
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { EmissionParams, Pollutant } from '@airpredict/shared';
|
||||
import { http } from './http';
|
||||
|
||||
export interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
spec?: string;
|
||||
envGrade?: string;
|
||||
usageUnit: string;
|
||||
emissionParams: Record<Pollutant, EmissionParams>;
|
||||
isPublic: boolean;
|
||||
ownerOrgId?: string;
|
||||
updatedAt: string;
|
||||
favorited: boolean;
|
||||
}
|
||||
|
||||
export interface MaterialQuery {
|
||||
id?: string;
|
||||
name?: string;
|
||||
category?: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
spec?: string;
|
||||
envGrade?: string;
|
||||
scope?: 'public' | 'self';
|
||||
favorited?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export interface MaterialInput {
|
||||
name: string;
|
||||
category: string;
|
||||
brand?: string;
|
||||
manufacturer?: string;
|
||||
spec?: string;
|
||||
envGrade?: string;
|
||||
usageUnit?: string;
|
||||
emissionParams: Record<Pollutant, EmissionParams>;
|
||||
}
|
||||
|
||||
export function listMaterials(q: MaterialQuery) {
|
||||
return http.get<any, Paged<Material>>('/materials', { params: q });
|
||||
}
|
||||
|
||||
export function getMaterial(id: string) {
|
||||
return http.get<any, Material>(`/materials/${id}`);
|
||||
}
|
||||
|
||||
export function createMaterial(input: MaterialInput) {
|
||||
return http.post<any, Material>('/materials', input);
|
||||
}
|
||||
|
||||
export function updateMaterial(id: string, input: Partial<MaterialInput>) {
|
||||
return http.patch<any, Material>(`/materials/${id}`, input);
|
||||
}
|
||||
|
||||
export function deleteMaterial(id: string) {
|
||||
return http.delete<any, { success: boolean }>(`/materials/${id}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import type { Pollutant } from '@airpredict/shared';
|
||||
import { http } from './http';
|
||||
import type { Paged } from './materials';
|
||||
|
||||
export interface ProjectRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
province: string;
|
||||
city: string;
|
||||
area: number;
|
||||
rating?: string;
|
||||
status: string;
|
||||
spaceCount: number;
|
||||
reportGeneratedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SpaceMaterialRow {
|
||||
id: string;
|
||||
materialId: string;
|
||||
usageUnit: string;
|
||||
usageAmount: number;
|
||||
contribution?: Record<Pollutant, number>;
|
||||
contributionRate?: Record<Pollutant, number>;
|
||||
material: { id: string; name: string; category: string; brand?: string; envGrade?: string };
|
||||
}
|
||||
|
||||
export interface SpaceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
layout: string;
|
||||
height?: number;
|
||||
area: number;
|
||||
volume: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
ventilationRate: number;
|
||||
standard: string;
|
||||
predictedConc?: Record<Pollutant, number>;
|
||||
materials: SpaceMaterialRow[];
|
||||
}
|
||||
|
||||
export interface ProjectDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
province: string;
|
||||
city: string;
|
||||
area: number;
|
||||
rating?: string;
|
||||
status: string;
|
||||
reportGeneratedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
spaces: SpaceRow[];
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
name: string;
|
||||
type: string;
|
||||
province: string;
|
||||
city: string;
|
||||
area: number;
|
||||
fromTemplateId?: string;
|
||||
}
|
||||
|
||||
export function createProject(input: CreateProjectInput) {
|
||||
return http.post<any, ProjectDetail>('/projects', input);
|
||||
}
|
||||
export function getProject(id: string) {
|
||||
return http.get<any, ProjectDetail>(`/projects/${id}`);
|
||||
}
|
||||
export function updateProject(id: string, input: Partial<CreateProjectInput>) {
|
||||
return http.patch<any, ProjectDetail>(`/projects/${id}`, input);
|
||||
}
|
||||
export function deleteProject(id: string) {
|
||||
return http.delete<any, { success: boolean }>(`/projects/${id}`);
|
||||
}
|
||||
export function generateReport(id: string) {
|
||||
return http.post<any, ProjectDetail>(`/projects/${id}/generate`, {});
|
||||
}
|
||||
export function listProjects(params: { status?: string; unfinished?: string; page?: number; pageSize?: number }) {
|
||||
return http.get<any, Paged<ProjectRow>>('/projects', { params });
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { Pollutant } from '@airpredict/shared';
|
||||
import { http } from './http';
|
||||
import type { SpaceRow } from './projects';
|
||||
|
||||
export interface SpaceMaterialInput {
|
||||
materialId: string;
|
||||
usageUnit?: string;
|
||||
usageAmount: number;
|
||||
}
|
||||
|
||||
export interface SpaceInput {
|
||||
projectId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
layout?: string;
|
||||
height?: number;
|
||||
area: number;
|
||||
volume: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
ventilationRate: number;
|
||||
standard: string;
|
||||
materials: SpaceMaterialInput[];
|
||||
}
|
||||
|
||||
export interface PrecalcResult {
|
||||
concentration: Record<Pollutant, number>;
|
||||
exceeded: Record<Pollutant, boolean>;
|
||||
contributions: {
|
||||
materialId: string;
|
||||
contribution: Record<Pollutant, number>;
|
||||
contributionRate: Record<Pollutant, number>;
|
||||
}[];
|
||||
rating: string;
|
||||
}
|
||||
|
||||
export interface PrecalcInput {
|
||||
volume: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
ventilationRate: number;
|
||||
standard: string;
|
||||
materials: SpaceMaterialInput[];
|
||||
}
|
||||
|
||||
export function precalc(input: PrecalcInput) {
|
||||
return http.post<any, PrecalcResult>('/spaces/precalc', input);
|
||||
}
|
||||
export function createSpace(input: SpaceInput) {
|
||||
return http.post<any, SpaceRow>('/spaces', input);
|
||||
}
|
||||
export function updateSpace(id: string, input: Partial<SpaceInput>) {
|
||||
return http.patch<any, SpaceRow>(`/spaces/${id}`, input);
|
||||
}
|
||||
export function deleteSpace(id: string) {
|
||||
return http.delete<any, { success: boolean }>(`/spaces/${id}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { http } from './http';
|
||||
import type { Paged } from './materials';
|
||||
|
||||
export interface TemplateRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
province: string;
|
||||
city: string;
|
||||
area: number;
|
||||
spaceCount: number;
|
||||
updatedAt: string;
|
||||
favorited: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateQuery {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
city?: string;
|
||||
scope?: 'public' | 'self';
|
||||
favorited?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function listTemplates(q: TemplateQuery) {
|
||||
return http.get<any, Paged<TemplateRow>>('/templates', { params: q });
|
||||
}
|
||||
|
||||
export function getTemplate(id: string) {
|
||||
return http.get<any, any>(`/templates/${id}`);
|
||||
}
|
||||
|
||||
export function deleteTemplate(id: string) {
|
||||
return http.delete<any, { success: boolean }>(`/templates/${id}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="isEdit ? '编辑材料' : '新建材料'"
|
||||
width="780px"
|
||||
:confirm-loading="saving"
|
||||
@ok="onOk"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" layout="vertical">
|
||||
<div class="section-title">基本信息</div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="材料名称" name="name" :rules="[{ required: true, message: '请输入材料名称' }]">
|
||||
<a-input v-model:value="form.name" placeholder="请输入" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="材料类别" name="category" :rules="[{ required: true, message: '请选择材料类别' }]">
|
||||
<a-select v-model:value="form.category" placeholder="请选择" show-search>
|
||||
<a-select-option v-for="c in categories" :key="c" :value="c">{{ c }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="材料品牌"><a-input v-model:value="form.brand" placeholder="请输入" /></a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="材料厂家"><a-input v-model:value="form.manufacturer" placeholder="请输入" /></a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="材料规格"><a-input v-model:value="form.spec" placeholder="请输入" /></a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="环保级别">
|
||||
<a-select v-model:value="form.envGrade" allow-clear placeholder="请选择">
|
||||
<a-select-option v-for="g in envGrades" :key="g" :value="g">{{ g }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="用量单位">
|
||||
<a-select v-model:value="form.usageUnit">
|
||||
<a-select-option v-for="u in units" :key="u" :value="u">{{ u }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="section-title">污染物释放参数</div>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>污染物</th>
|
||||
<th>最低平衡释放量 Y0 (mg/m²)</th>
|
||||
<th>平衡释放量范围 Yp (mg/m²)</th>
|
||||
<th>平衡释放量变化率 B (m²/m³)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pollutants" :key="p">
|
||||
<td class="pol">{{ labels[p].zh }}</td>
|
||||
<td><a-input-number v-model:value="form.emissionParams[p].y0" :min="0" :step="0.001" style="width: 100%" /></td>
|
||||
<td><a-input-number v-model:value="form.emissionParams[p].yp" :min="0" :step="0.001" style="width: 100%" /></td>
|
||||
<td><a-input-number v-model:value="form.emissionParams[p].b" :min="0" :step="0.001" style="width: 100%" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
POLLUTANTS,
|
||||
POLLUTANT_LABELS,
|
||||
MATERIAL_CATEGORIES,
|
||||
ENV_GRADES,
|
||||
USAGE_UNITS,
|
||||
type Pollutant,
|
||||
type EmissionParams,
|
||||
} from '@airpredict/shared';
|
||||
import { createMaterial, updateMaterial, type Material, type MaterialInput } from '../api/materials';
|
||||
|
||||
const props = defineProps<{ open: boolean; material?: Material | null }>();
|
||||
const emit = defineEmits<{ (e: 'ok'): void; (e: 'cancel'): void }>();
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const categories = MATERIAL_CATEGORIES;
|
||||
const envGrades = ENV_GRADES;
|
||||
const units = USAGE_UNITS;
|
||||
|
||||
const formRef = ref();
|
||||
const saving = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
function emptyParams(): Record<Pollutant, EmissionParams> {
|
||||
return POLLUTANTS.reduce((acc, p) => {
|
||||
acc[p] = { y0: 0, yp: 0, b: 0 };
|
||||
return acc;
|
||||
}, {} as Record<Pollutant, EmissionParams>);
|
||||
}
|
||||
|
||||
const form = reactive<MaterialInput>({
|
||||
name: '',
|
||||
category: '',
|
||||
brand: '',
|
||||
manufacturer: '',
|
||||
spec: '',
|
||||
envGrade: undefined,
|
||||
usageUnit: 'm²',
|
||||
emissionParams: emptyParams(),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (!o) return;
|
||||
if (props.material) {
|
||||
isEdit.value = true;
|
||||
Object.assign(form, {
|
||||
name: props.material.name,
|
||||
category: props.material.category,
|
||||
brand: props.material.brand,
|
||||
manufacturer: props.material.manufacturer,
|
||||
spec: props.material.spec,
|
||||
envGrade: props.material.envGrade,
|
||||
usageUnit: props.material.usageUnit,
|
||||
emissionParams: JSON.parse(JSON.stringify(props.material.emissionParams)),
|
||||
});
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
Object.assign(form, {
|
||||
name: '', category: '', brand: '', manufacturer: '', spec: '',
|
||||
envGrade: undefined, usageUnit: 'm²', emissionParams: emptyParams(),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function onOk() {
|
||||
await formRef.value.validate();
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEdit.value && props.material) {
|
||||
await updateMaterial(props.material.id, { ...form });
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await createMaterial({ ...form });
|
||||
message.success('已创建');
|
||||
}
|
||||
emit('ok');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #b4232a;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
.param-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.param-table th,
|
||||
.param-table td {
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.param-table th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
.param-table .pol {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<template>
|
||||
<a-modal :open="open" title="选择材料" :width="1180" :z-index="1100" @cancel="emit('cancel')">
|
||||
<template #footer>
|
||||
<a-button @click="emit('cancel')">关 闭</a-button>
|
||||
<a-button type="primary" @click="emit('cancel')">完 成(已加 {{ existingIds.length }})</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 顶部:库 + 分类勾选 -->
|
||||
<div class="picker-head">
|
||||
<div class="scope-row">
|
||||
<span class="lbl">材料库:</span>
|
||||
<a-radio-group v-model:value="scope" size="small" button-style="solid" @change="onScopeChange">
|
||||
<a-radio-button value="public">公共库</a-radio-button>
|
||||
<a-radio-button value="self">自建库</a-radio-button>
|
||||
</a-radio-group>
|
||||
<span class="tip">勾选大类或二级分类,下方生成对应窗口,点击窗口放大快速录入</span>
|
||||
</div>
|
||||
<a-checkbox-group v-model:value="selectedCats" class="cat-group">
|
||||
<div v-for="g in tree" :key="g.major" class="cat-line">
|
||||
<a-checkbox :value="g.major" class="major">{{ g.major }}<template v-if="g.subs.length">(整类)</template></a-checkbox>
|
||||
<span v-if="g.subs.length" class="arrow">▸</span>
|
||||
<a-checkbox v-for="s in g.subs" :key="s" :value="g.major + '/' + s">{{ s }}</a-checkbox>
|
||||
</div>
|
||||
</a-checkbox-group>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 12px 0" />
|
||||
|
||||
<!-- 放大窗口 -->
|
||||
<div v-if="enlarged" class="enlarged">
|
||||
<div class="ehead">
|
||||
<a @click="enlarged = null">← 返回平铺</a>
|
||||
<b>{{ catLabel(enlarged) }}({{ (cache[enlarged] || []).length }} 种)</b>
|
||||
<a-button type="primary" size="small" :disabled="checkedCount === 0" @click="addSelected">
|
||||
批量添加所选({{ checkedCount }})
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="bigColumns"
|
||||
:data-source="cache[enlarged] || []"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 360 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'check'">
|
||||
<a-checkbox
|
||||
:checked="!!rowState[record.id]?.checked"
|
||||
:disabled="picked.has(record.id)"
|
||||
@change="(e: any) => toggleRow(record.id, e.target.checked)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'envGrade'">
|
||||
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag><span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'area'">
|
||||
<span v-if="picked.has(record.id)" class="added">已添加</span>
|
||||
<a-input-number
|
||||
v-else
|
||||
:value="rowState[record.id]?.area"
|
||||
:min="0"
|
||||
size="small"
|
||||
placeholder="面积"
|
||||
style="width: 110px"
|
||||
addon-after="m²"
|
||||
@change="(v: any) => setArea(record.id, v)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 平铺窗口 -->
|
||||
<div v-else>
|
||||
<a-empty v-if="!selectedCats.length" description="请在上方勾选材料类别" />
|
||||
<a-row v-else :gutter="[12, 12]">
|
||||
<a-col v-for="cat in selectedCats" :key="cat" :span="6">
|
||||
<a-card hoverable size="small" class="win" @click="enlarge(cat)">
|
||||
<template #title>
|
||||
<span class="win-title">{{ catLabel(cat) }}</span>
|
||||
<a-badge :count="(cache[cat] || []).length" :number-style="{ backgroundColor: '#b4232a' }" show-zero />
|
||||
</template>
|
||||
<div v-if="loadingCats.has(cat)" class="prev muted">加载中…</div>
|
||||
<template v-else>
|
||||
<div v-for="m in (cache[cat] || []).slice(0, 4)" :key="m.id" class="prev">
|
||||
· {{ m.brand || m.name }} {{ m.spec || '' }}
|
||||
</div>
|
||||
<div v-if="!(cache[cat] || []).length" class="prev muted">该类暂无材料</div>
|
||||
</template>
|
||||
<div class="more">点击放大 →</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { MATERIAL_CATEGORIES } from '@airpredict/shared';
|
||||
import { listMaterials, type Material } from '../api/materials';
|
||||
|
||||
const props = defineProps<{ open: boolean; existingIds: string[] }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'add', items: { material: Material; usageAmount: number }[]): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const loading = ref(false);
|
||||
const selectedCats = ref<string[]>([]);
|
||||
const enlarged = ref<string | null>(null);
|
||||
const cache = reactive<Record<string, Material[]>>({});
|
||||
const loadingCats = reactive<Set<string>>(new Set());
|
||||
const rowState = reactive<Record<string, { checked: boolean; area: number | null }>>({});
|
||||
|
||||
const picked = computed(() => new Set(props.existingIds));
|
||||
|
||||
// 由 MATERIAL_CATEGORIES 解析出 大类 -> 二级[]
|
||||
const tree = computed(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const c of MATERIAL_CATEGORIES) {
|
||||
const [major, sub] = c.split('/');
|
||||
if (!map.has(major)) map.set(major, []);
|
||||
if (sub && !map.get(major)!.includes(sub)) map.get(major)!.push(sub);
|
||||
}
|
||||
return [...map.entries()].map(([major, subs]) => ({ major, subs }));
|
||||
});
|
||||
|
||||
const bigColumns = [
|
||||
{ title: '', key: 'check', width: 40 },
|
||||
{ title: '材料ID', dataIndex: 'id' },
|
||||
{ title: '材料名称', dataIndex: 'name' },
|
||||
{ title: '材料类别', dataIndex: 'category' },
|
||||
{ title: '品牌', dataIndex: 'brand' },
|
||||
{ title: '规格', dataIndex: 'spec' },
|
||||
{ title: '环保', key: 'envGrade', width: 64 },
|
||||
{ title: '使用量(面积)', key: 'area', width: 150 },
|
||||
];
|
||||
|
||||
const checkedCount = computed(
|
||||
() => Object.values(rowState).filter((r) => r.checked).length,
|
||||
);
|
||||
|
||||
function catLabel(cat: string) {
|
||||
return cat.includes('/') ? cat.split('/')[1] : cat + '(整类)';
|
||||
}
|
||||
|
||||
async function loadCat(cat: string) {
|
||||
if (loadingCats.has(cat)) return;
|
||||
loadingCats.add(cat);
|
||||
try {
|
||||
const res = await listMaterials({ category: cat, scope: scope.value, page: 1, pageSize: 200 });
|
||||
cache[cat] = res.items;
|
||||
} finally {
|
||||
loadingCats.delete(cat);
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedCats, (cats) => {
|
||||
for (const c of cats) if (!cache[c]) loadCat(c);
|
||||
});
|
||||
|
||||
function onScopeChange() {
|
||||
for (const k of Object.keys(cache)) delete cache[k];
|
||||
for (const c of selectedCats.value) loadCat(c);
|
||||
}
|
||||
|
||||
function enlarge(cat: string) {
|
||||
enlarged.value = cat;
|
||||
for (const k of Object.keys(rowState)) delete rowState[k];
|
||||
for (const m of cache[cat] || []) rowState[m.id] = { checked: false, area: null };
|
||||
}
|
||||
|
||||
function toggleRow(id: string, checked: boolean) {
|
||||
if (rowState[id]) rowState[id].checked = checked;
|
||||
}
|
||||
function setArea(id: string, v: number | null) {
|
||||
if (!rowState[id]) rowState[id] = { checked: false, area: null };
|
||||
rowState[id].area = v;
|
||||
if (v && v > 0) rowState[id].checked = true; // 填了面积自动勾选
|
||||
}
|
||||
|
||||
function addSelected() {
|
||||
const cat = enlarged.value!;
|
||||
const items = (cache[cat] || [])
|
||||
.filter((m) => rowState[m.id]?.checked && !picked.value.has(m.id))
|
||||
.map((m) => ({ material: m, usageAmount: Number(rowState[m.id].area) || 0 }));
|
||||
if (!items.length) return message.warning('请先勾选材料');
|
||||
emit('add', items);
|
||||
message.success(`已添加 ${items.length} 种材料`);
|
||||
enlarged.value = null;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
enlarged.value = null;
|
||||
for (const k of Object.keys(cache)) delete cache[k];
|
||||
for (const c of selectedCats.value) loadCat(c);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-head { }
|
||||
.scope-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.scope-row .lbl { color: #555; }
|
||||
.scope-row .tip { color: #999; font-size: 12px; }
|
||||
.cat-group { display: flex; flex-direction: column; gap: 6px; max-height: 150px; overflow-y: auto; }
|
||||
.cat-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; padding: 2px 0; }
|
||||
.cat-line .major { font-weight: 600; }
|
||||
.cat-line .arrow { color: #ccc; }
|
||||
.win { cursor: pointer; min-height: 150px; }
|
||||
.win-title { font-weight: 600; margin-right: 8px; }
|
||||
.prev { font-size: 12px; color: #666; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.prev.muted { color: #bbb; }
|
||||
.more { margin-top: 8px; color: #b4232a; font-size: 12px; }
|
||||
.enlarged .ehead { display: flex; align-items: center; gap: 16px; margin-bottom: 10px; }
|
||||
.enlarged .ehead b { flex: 1; }
|
||||
.added { color: #999; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="isEdit ? '修改项目信息' : '新建项目'"
|
||||
:confirm-loading="saving"
|
||||
width="480px"
|
||||
@ok="onOk"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
||||
<a-form-item label="工程名称" name="name" :rules="[{ required: true, message: '请输入工程名称' }]">
|
||||
<a-input v-model:value="form.name" placeholder="请输入" />
|
||||
</a-form-item>
|
||||
<a-form-item label="项目类型" name="type" :rules="[{ required: true, message: '请选择项目类型' }]">
|
||||
<a-select v-model:value="form.type" placeholder="请选择">
|
||||
<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="所在城市" name="region" :rules="[{ required: true, message: '请选择所在城市' }]">
|
||||
<a-cascader v-model:value="form.region" :options="regions" placeholder="请选择" />
|
||||
</a-form-item>
|
||||
<a-form-item label="建筑面积" name="area" :rules="[{ required: true, message: '请输入建筑面积' }]">
|
||||
<a-input-number v-model:value="form.area" :min="1" style="width: 100%" addon-after="m²" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PROJECT_TYPES } from '@airpredict/shared';
|
||||
import { REGIONS } from '../data/regions';
|
||||
import { createProject, updateProject, type ProjectDetail } from '../api/projects';
|
||||
|
||||
const props = defineProps<{ open: boolean; project?: ProjectDetail | null }>();
|
||||
const emit = defineEmits<{ (e: 'ok', p: ProjectDetail): void; (e: 'cancel'): void }>();
|
||||
|
||||
const projectTypes = PROJECT_TYPES;
|
||||
const regions = REGIONS;
|
||||
const formRef = ref();
|
||||
const saving = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const form = reactive<{ name: string; type?: string; region?: string[]; area?: number }>({
|
||||
name: '', type: undefined, region: undefined, area: undefined,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (!o) return;
|
||||
if (props.project) {
|
||||
isEdit.value = true;
|
||||
form.name = props.project.name;
|
||||
form.type = props.project.type;
|
||||
form.region = [props.project.province, props.project.city];
|
||||
form.area = props.project.area;
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
form.name = '';
|
||||
form.type = undefined;
|
||||
form.region = undefined;
|
||||
form.area = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function onOk() {
|
||||
await formRef.value.validate();
|
||||
saving.value = true;
|
||||
try {
|
||||
const [province, city] = form.region!;
|
||||
const payload = { name: form.name, type: form.type!, province, city, area: form.area! };
|
||||
const res = props.project
|
||||
? await updateProject(props.project.id, payload)
|
||||
: await createProject(payload);
|
||||
message.success(isEdit.value ? '已保存' : '已创建');
|
||||
emit('ok', res);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
<template>
|
||||
<a-drawer
|
||||
:open="open"
|
||||
:title="isEdit ? '编辑空间' : '添加包含空间'"
|
||||
:width="drawerWidth"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<div class="cols">
|
||||
<!-- 基本信息 -->
|
||||
<div class="col">
|
||||
<div class="section-title">🏠 基本信息</div>
|
||||
<a-form :label-col="{ span: 7 }" :wrapper-col="{ span: 17 }">
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12"><a-form-item label="空间名称" required><a-input v-model:value="form.name" placeholder="请输入" /></a-form-item></a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="空间类型" required>
|
||||
<a-select v-model:value="form.type" placeholder="请选择">
|
||||
<a-select-option v-for="t in spaceTypes" :key="t" :value="t">{{ t }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="空间户型" :label-col="{ span: 4 }">
|
||||
<a-radio-group v-model:value="form.layout">
|
||||
<a-radio value="uniform">等高</a-radio>
|
||||
<a-radio value="non-uniform">非等高</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8"><a-form-item label="高度" :label-col="{ span: 10 }"><a-input-number v-model:value="form.height" :min="0" addon-after="m" style="width: 100%" /></a-form-item></a-col>
|
||||
<a-col :span="8"><a-form-item label="面积" :label-col="{ span: 10 }"><a-input-number v-model:value="form.area" :min="0" addon-after="m²" style="width: 100%" /></a-form-item></a-col>
|
||||
<a-col :span="8"><a-form-item label="体积" :label-col="{ span: 10 }"><a-input-number v-model:value="form.volume" :min="0" addon-after="m³" style="width: 100%" /></a-form-item></a-col>
|
||||
<a-col :span="8"><a-form-item label="温度" :label-col="{ span: 10 }"><a-input-number v-model:value="form.temperature" addon-after="℃" style="width: 100%" /></a-form-item></a-col>
|
||||
<a-col :span="8"><a-form-item label="湿度" :label-col="{ span: 10 }"><a-input-number v-model:value="form.humidity" :min="0" :max="100" addon-after="%rh" style="width: 100%" /></a-form-item></a-col>
|
||||
<a-col :span="8"><a-form-item label="换气率" :label-col="{ span: 10 }"><a-input-number v-model:value="form.ventilationRate" :min="0" :step="0.1" addon-after="次/h" style="width: 100%" /></a-form-item></a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 空间污染预计算 -->
|
||||
<div class="col">
|
||||
<div class="section-title">📊 空间污染预计算</div>
|
||||
<a-form-item label="污染物值标准" :label-col="{ span: 5 }">
|
||||
<a-select v-model:value="form.standard" style="width: 220px">
|
||||
<a-select-option v-for="s in standardCodes" :key="s" :value="s">{{ s }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<table class="std-table">
|
||||
<thead><tr><th>项</th><th v-for="p in pollutants" :key="p">{{ labels[p].zh }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>标准限值</td>
|
||||
<td v-for="p in pollutants" :key="p">{{ limits[p] }}mg/m³</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>预测浓度</td>
|
||||
<td v-for="p in pollutants" :key="p" :class="{ over: result?.exceeded[p] }">
|
||||
{{ result ? result.concentration[p].toFixed(4) + 'mg/m³' : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="result" class="rating">空间评级:<a-tag :color="ratingColor">{{ result.rating }}</a-tag></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 材料 -->
|
||||
<div class="section-title" style="margin-top: 8px">
|
||||
📦 使用材料及空气污染物预计算 ({{ materials.length }})
|
||||
<span class="right">
|
||||
<a-button size="small" @click="newMaterialOpen = true">+ 新建材料</a-button>
|
||||
<a-button size="small" type="primary" style="margin-left: 8px" @click="pickerOpen = true">选择材料</a-button>
|
||||
</span>
|
||||
</div>
|
||||
<a-table :columns="matColumns" :data-source="materials" row-key="materialId" size="small" :pagination="false">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'usageAmount'">
|
||||
<a-input-number v-model:value="record.usageAmount" :min="0" size="small" style="width: 90px" />
|
||||
</template>
|
||||
<template v-else-if="column.key?.startsWith('cr_')">
|
||||
{{ rateText(record, column.key.slice(3)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a style="color: #b4232a" @click="removeMat(index)">移除</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right">
|
||||
<a-button @click="emit('cancel')">取 消</a-button>
|
||||
<a-button style="margin: 0 8px" :loading="precalcing" @click="doPrecalc">预计算</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="onSave">确 定</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MaterialPickerModal
|
||||
:open="pickerOpen"
|
||||
:existing-ids="materials.map((m) => m.materialId)"
|
||||
@add="onAddBatch"
|
||||
@cancel="pickerOpen = false"
|
||||
/>
|
||||
<MaterialFormModal :open="newMaterialOpen" @ok="onNewMaterial" @cancel="newMaterialOpen = false" />
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, STANDARD_CODES, SPACE_TYPES,
|
||||
type Pollutant, type StandardCode,
|
||||
} from '@airpredict/shared';
|
||||
import type { Material } from '../api/materials';
|
||||
import type { SpaceRow } from '../api/projects';
|
||||
import { precalc, createSpace, updateSpace, type PrecalcResult } from '../api/spaces';
|
||||
import MaterialPickerModal from './MaterialPickerModal.vue';
|
||||
import MaterialFormModal from './MaterialFormModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean; projectId: string; space?: SpaceRow | null }>();
|
||||
const emit = defineEmits<{ (e: 'ok'): void; (e: 'cancel'): void }>();
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const standardCodes = STANDARD_CODES;
|
||||
const spaceTypes = SPACE_TYPES;
|
||||
|
||||
const isEdit = ref(false);
|
||||
const saving = ref(false);
|
||||
const precalcing = ref(false);
|
||||
const pickerOpen = ref(false);
|
||||
const newMaterialOpen = ref(false);
|
||||
const result = ref<PrecalcResult | null>(null);
|
||||
|
||||
interface MatRow {
|
||||
materialId: string; name: string; category: string; brand?: string; envGrade?: string;
|
||||
usageUnit: string; usageAmount: number;
|
||||
}
|
||||
const materials = ref<MatRow[]>([]);
|
||||
|
||||
const form = reactive<any>({
|
||||
name: '', type: undefined, layout: 'uniform',
|
||||
height: 2.8, area: undefined, volume: undefined,
|
||||
temperature: 25, humidity: 50, ventilationRate: 0.5, standard: 'GB50325-2020',
|
||||
});
|
||||
|
||||
const limits = computed(() => STANDARD_LIMITS[form.standard as StandardCode]);
|
||||
const ratingColor = computed(() => ({ A: 'green', B: 'blue', C: 'orange', D: 'red' }[result.value?.rating || 'A']));
|
||||
|
||||
// 抽屉宽度:随屏幕自适应(核心配置屏,尽量宽),限定 1100~1760
|
||||
const drawerWidth = ref(1400);
|
||||
function calcWidth() {
|
||||
drawerWidth.value = Math.min(1760, Math.max(1100, Math.round(window.innerWidth * 0.88)));
|
||||
}
|
||||
calcWidth();
|
||||
watch(() => props.open, (o) => { if (o) calcWidth(); });
|
||||
onMounted(() => window.addEventListener('resize', calcWidth));
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', calcWidth));
|
||||
|
||||
const matColumns = [
|
||||
{ title: '材料名称', dataIndex: 'name' },
|
||||
{ title: '类别', dataIndex: 'category' },
|
||||
{ title: '品牌', dataIndex: 'brand' },
|
||||
{ title: '用量单位', dataIndex: 'usageUnit' },
|
||||
{ title: '使用量', key: 'usageAmount' },
|
||||
...POLLUTANTS.map((p) => ({ title: `${POLLUTANT_LABELS[p].zh}贡献率`, key: `cr_${p}` })),
|
||||
{ title: '操作', key: 'op' },
|
||||
];
|
||||
|
||||
// 等高时自动算体积
|
||||
watch([() => form.height, () => form.area, () => form.layout], () => {
|
||||
if (form.layout === 'uniform' && form.height && form.area) {
|
||||
form.volume = +(form.height * form.area).toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (!o) return;
|
||||
result.value = null;
|
||||
if (props.space) {
|
||||
isEdit.value = true;
|
||||
Object.assign(form, {
|
||||
name: props.space.name, type: props.space.type, layout: props.space.layout,
|
||||
height: props.space.height, area: props.space.area, volume: props.space.volume,
|
||||
temperature: props.space.temperature, humidity: props.space.humidity,
|
||||
ventilationRate: props.space.ventilationRate, standard: props.space.standard,
|
||||
});
|
||||
materials.value = props.space.materials.map((m) => ({
|
||||
materialId: m.materialId, name: m.material.name, category: m.material.category,
|
||||
brand: m.material.brand, envGrade: m.material.envGrade,
|
||||
usageUnit: m.usageUnit, usageAmount: m.usageAmount,
|
||||
}));
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
Object.assign(form, {
|
||||
name: '', type: undefined, layout: 'uniform', height: 2.8, area: undefined, volume: undefined,
|
||||
temperature: 25, humidity: 50, ventilationRate: 0.5, standard: 'GB50325-2020',
|
||||
});
|
||||
materials.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onAddBatch(items: { material: Material; usageAmount: number }[]) {
|
||||
for (const { material: m, usageAmount } of items) {
|
||||
if (materials.value.some((x) => x.materialId === m.id)) continue;
|
||||
materials.value.push({
|
||||
materialId: m.id, name: m.name, category: m.category, brand: m.brand,
|
||||
envGrade: m.envGrade, usageUnit: m.usageUnit, usageAmount: usageAmount || 1,
|
||||
});
|
||||
}
|
||||
result.value = null; // 材料变了,预测失效
|
||||
}
|
||||
function onNewMaterial() {
|
||||
newMaterialOpen.value = false;
|
||||
message.success('已创建,请在「选择材料」中查找添加');
|
||||
}
|
||||
function removeMat(i: number) {
|
||||
materials.value.splice(i, 1);
|
||||
result.value = null;
|
||||
}
|
||||
function rateText(record: MatRow, pol: string) {
|
||||
const c = result.value?.contributions.find((x) => x.materialId === record.materialId);
|
||||
if (!c) return '-';
|
||||
return (c.contributionRate[pol as Pollutant] * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
async function doPrecalc() {
|
||||
if (!materials.value.length) return message.warning('请先添加材料');
|
||||
if (!form.volume) return message.warning('请填写体积');
|
||||
precalcing.value = true;
|
||||
try {
|
||||
result.value = await precalc({
|
||||
volume: form.volume, temperature: form.temperature, humidity: form.humidity,
|
||||
ventilationRate: form.ventilationRate, standard: form.standard,
|
||||
materials: materials.value.map((m) => ({ materialId: m.materialId, usageAmount: m.usageAmount })),
|
||||
});
|
||||
} finally {
|
||||
precalcing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.name || !form.type) return message.warning('请填写空间名称和类型');
|
||||
if (!form.area || !form.volume) return message.warning('请填写面积和体积');
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name, type: form.type, layout: form.layout, height: form.height,
|
||||
area: form.area, volume: form.volume, temperature: form.temperature, humidity: form.humidity,
|
||||
ventilationRate: form.ventilationRate, standard: form.standard,
|
||||
materials: materials.value.map((m) => ({ materialId: m.materialId, usageUnit: m.usageUnit, usageAmount: m.usageAmount })),
|
||||
};
|
||||
if (isEdit.value && props.space) {
|
||||
await updateSpace(props.space.id, payload);
|
||||
} else {
|
||||
await createSpace({ projectId: props.projectId, ...payload });
|
||||
}
|
||||
message.success('已保存');
|
||||
emit('ok');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cols { display: flex; gap: 24px; }
|
||||
.col { flex: 1; min-width: 0; }
|
||||
.section-title { font-weight: 600; color: #b4232a; margin-bottom: 12px; }
|
||||
.section-title .right { float: right; font-weight: 400; }
|
||||
.std-table { width: 100%; border-collapse: collapse; }
|
||||
.std-table th, .std-table td { border: 1px solid #f0f0f0; padding: 6px; text-align: center; font-size: 13px; }
|
||||
.std-table th { background: #fafafa; }
|
||||
.std-table .over { color: #b4232a; font-weight: 600; }
|
||||
.rating { margin-top: 12px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// 中国省/市级联数据(Ant Cascader options)。覆盖全部省级行政区 + 主要城市。
|
||||
// 如需完整地级市,可后续替换为权威数据集。
|
||||
export interface RegionNode {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: RegionNode[];
|
||||
}
|
||||
|
||||
function p(prov: string, cities: string[]): RegionNode {
|
||||
return { value: prov, label: prov, children: cities.map((c) => ({ value: c, label: c })) };
|
||||
}
|
||||
|
||||
export const REGIONS: RegionNode[] = [
|
||||
p('北京市', ['北京市']),
|
||||
p('天津市', ['天津市']),
|
||||
p('上海市', ['上海市']),
|
||||
p('重庆市', ['重庆市']),
|
||||
p('河北省', ['石家庄市', '唐山市', '秦皇岛市', '邯郸市', '保定市', '张家口市', '承德市', '沧州市', '廊坊市', '衡水市']),
|
||||
p('山西省', ['太原市', '大同市', '阳泉市', '长治市', '晋城市', '朔州市', '晋中市', '运城市', '忻州市', '临汾市', '吕梁市']),
|
||||
p('内蒙古自治区', ['呼和浩特市', '包头市', '乌海市', '赤峰市', '通辽市', '鄂尔多斯市', '呼伦贝尔市', '巴彦淖尔市']),
|
||||
p('辽宁省', ['沈阳市', '大连市', '鞍山市', '抚顺市', '本溪市', '丹东市', '锦州市', '营口市', '盘锦市']),
|
||||
p('吉林省', ['长春市', '吉林市', '四平市', '辽源市', '通化市', '白山市', '松原市', '白城市', '延边朝鲜族自治州']),
|
||||
p('黑龙江省', ['哈尔滨市', '齐齐哈尔市', '鸡西市', '鹤岗市', '大庆市', '伊春市', '佳木斯市', '牡丹江市', '绥化市']),
|
||||
p('江苏省', ['南京市', '无锡市', '徐州市', '常州市', '苏州市', '南通市', '连云港市', '淮安市', '盐城市', '扬州市', '镇江市', '泰州市', '宿迁市']),
|
||||
p('浙江省', ['杭州市', '宁波市', '温州市', '嘉兴市', '湖州市', '绍兴市', '金华市', '衢州市', '舟山市', '台州市', '丽水市']),
|
||||
p('安徽省', ['合肥市', '芜湖市', '蚌埠市', '淮南市', '马鞍山市', '安庆市', '黄山市', '阜阳市', '宿州市', '六安市', '亳州市']),
|
||||
p('福建省', ['福州市', '厦门市', '莆田市', '三明市', '泉州市', '漳州市', '南平市', '龙岩市', '宁德市']),
|
||||
p('江西省', ['南昌市', '景德镇市', '萍乡市', '九江市', '新余市', '鹰潭市', '赣州市', '吉安市', '宜春市', '抚州市', '上饶市']),
|
||||
p('山东省', ['济南市', '青岛市', '淄博市', '枣庄市', '东营市', '烟台市', '潍坊市', '济宁市', '泰安市', '威海市', '日照市', '临沂市', '德州市', '聊城市', '滨州市', '菏泽市']),
|
||||
p('河南省', ['郑州市', '开封市', '洛阳市', '平顶山市', '安阳市', '鹤壁市', '新乡市', '焦作市', '濮阳市', '许昌市', '漯河市', '三门峡市', '南阳市', '商丘市', '信阳市', '周口市', '驻马店市']),
|
||||
p('湖北省', ['武汉市', '黄石市', '十堰市', '宜昌市', '襄阳市', '鄂州市', '荆门市', '孝感市', '荆州市', '黄冈市', '咸宁市', '随州市']),
|
||||
p('湖南省', ['长沙市', '株洲市', '湘潭市', '衡阳市', '邵阳市', '岳阳市', '常德市', '张家界市', '益阳市', '郴州市', '永州市', '怀化市', '娄底市']),
|
||||
p('广东省', ['广州市', '深圳市', '珠海市', '汕头市', '佛山市', '韶关市', '湛江市', '肇庆市', '江门市', '茂名市', '惠州市', '梅州市', '汕尾市', '河源市', '阳江市', '清远市', '东莞市', '中山市', '潮州市', '揭阳市', '云浮市']),
|
||||
p('广西壮族自治区', ['南宁市', '柳州市', '桂林市', '梧州市', '北海市', '防城港市', '钦州市', '贵港市', '玉林市', '百色市', '贺州市', '河池市', '来宾市', '崇左市']),
|
||||
p('海南省', ['海口市', '三亚市', '三沙市', '儋州市']),
|
||||
p('四川省', ['成都市', '自贡市', '攀枝花市', '泸州市', '德阳市', '绵阳市', '广元市', '遂宁市', '内江市', '乐山市', '南充市', '眉山市', '宜宾市', '广安市', '达州市', '雅安市', '巴中市', '资阳市']),
|
||||
p('贵州省', ['贵阳市', '六盘水市', '遵义市', '安顺市', '毕节市', '铜仁市']),
|
||||
p('云南省', ['昆明市', '曲靖市', '玉溪市', '保山市', '昭通市', '丽江市', '普洱市', '临沧市']),
|
||||
p('西藏自治区', ['拉萨市', '日喀则市', '昌都市', '林芝市', '山南市', '那曲市']),
|
||||
p('陕西省', ['西安市', '铜川市', '宝鸡市', '咸阳市', '渭南市', '延安市', '汉中市', '榆林市', '安康市', '商洛市']),
|
||||
p('甘肃省', ['兰州市', '嘉峪关市', '金昌市', '白银市', '天水市', '武威市', '张掖市', '平凉市', '酒泉市', '庆阳市', '定西市', '陇南市']),
|
||||
p('青海省', ['西宁市', '海东市']),
|
||||
p('宁夏回族自治区', ['银川市', '石嘴山市', '吴忠市', '固原市', '中卫市']),
|
||||
p('新疆维吾尔自治区', ['乌鲁木齐市', '克拉玛依市', '吐鲁番市', '哈密市']),
|
||||
];
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-header class="header">
|
||||
<div class="brand">
|
||||
<span class="leaf">🍃</span>
|
||||
室内装修工程污染物预测系统
|
||||
</div>
|
||||
<a-menu
|
||||
mode="horizontal"
|
||||
:selectedKeys="[current]"
|
||||
class="nav"
|
||||
@click="onNav"
|
||||
>
|
||||
<a-menu-item key="home">首页</a-menu-item>
|
||||
<a-menu-item key="template">模板库</a-menu-item>
|
||||
<a-menu-item key="material">材料库</a-menu-item>
|
||||
<a-menu-item key="history">历史记录</a-menu-item>
|
||||
</a-menu>
|
||||
<a-dropdown>
|
||||
<span class="org">{{ auth.org?.name || '一品健康空间' }}</span>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="logout" @click="logout">退出登录</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const current = computed(() => (route.name as string) || 'home');
|
||||
|
||||
function onNav({ key }: { key: string }) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.replace('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin-right: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.leaf { color: #b4232a; }
|
||||
.nav { flex: 1; border-bottom: none; }
|
||||
.org { cursor: pointer; color: #555; }
|
||||
.content { padding: 16px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import Antd from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(Antd).mount('#app');
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<a-card title="继续配置预测">
|
||||
<template #extra><span class="hint">仅显示尚未生成预测报告的草稿项目</span></template>
|
||||
<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 === 'updatedAt'">{{ fmt(record.updatedAt) }}</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="goConfig(record)">继续配置</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该草稿?" @confirm="onDelete(record)">
|
||||
<a style="color: #b4232a">删除</a>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { listProjects, deleteProject, type ProjectRow } from '../api/projects';
|
||||
import type { Paged } from '../api/materials';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const data = ref<Paged<ProjectRow>>({ total: 0, page: 1, pageSize: 10, 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: 'updatedAt' },
|
||||
{ title: '操作', key: 'op', width: 160 },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: data.value.page,
|
||||
pageSize: data.value.pageSize,
|
||||
total: data.value.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
}));
|
||||
|
||||
function fmt(s: string) { return new Date(s).toLocaleString(); }
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listProjects({ unfinished: 'true', page: page.value, pageSize: 10 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
function goConfig(r: ProjectRow) { router.push({ name: 'predict', params: { id: r.id } }); }
|
||||
async function onDelete(r: ProjectRow) { await deleteProject(r.id); message.success('已删除'); reload(); }
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: #999; font-size: 13px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<a-card title="历史预测记录">
|
||||
<a-empty description="历史记录将在阶段 5 实现" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<a-card>
|
||||
<div class="hi">👋 欢迎,{{ auth.org?.name || '一品健康空间' }}</div>
|
||||
<a-divider />
|
||||
<div class="group-title">预测</div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8" v-for="c in predictCards" :key="c.title">
|
||||
<a-card hoverable class="entry" @click="c.action">
|
||||
<div class="entry-title">{{ c.title }} ›</div>
|
||||
<div class="entry-desc">{{ c.desc }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div class="group-title">更多</div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8" v-for="c in moreCards" :key="c.title">
|
||||
<a-card hoverable class="entry" @click="c.action">
|
||||
<div class="entry-title">{{ c.title }} ›</div>
|
||||
<div class="entry-desc">{{ c.desc }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<NewProjectModal :open="createOpen" @ok="onCreated" @cancel="createOpen = false" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||
import type { ProjectDetail } from '../api/projects';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const todo = () => message.info('该功能将在后续阶段实现');
|
||||
|
||||
const createOpen = ref(false);
|
||||
|
||||
const predictCards = [
|
||||
{ title: '新建项目预测', desc: '从头配置项目、空间、材料进行预测', action: () => (createOpen.value = true) },
|
||||
{ title: '快速导入项目', desc: '根据模板或文件导入后调整配置预测', action: () => router.push({ name: 'template' }) },
|
||||
{ title: '继续配置预测', desc: '继续已保存、未提交的配置', action: () => router.push({ name: 'drafts' }) },
|
||||
];
|
||||
const moreCards = [
|
||||
{ title: '项目模板库', desc: '查看管理公共、自建的项目模板', action: () => router.push({ name: 'template' }) },
|
||||
{ title: '材料数据库', desc: '查看管理公共、自建的材料数据', action: () => router.push({ name: 'material' }) },
|
||||
{ title: '历史预测记录', desc: '查看、复用过往预测记录', action: () => router.push({ name: 'history' }) },
|
||||
];
|
||||
|
||||
function onCreated(p: ProjectDetail) {
|
||||
createOpen.value = false;
|
||||
router.push({ name: 'predict', params: { id: p.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hi { font-size: 18px; font-weight: 600; }
|
||||
.group-title { font-weight: 600; margin: 20px 0 12px; color: #b4232a; }
|
||||
.entry { height: 96px; }
|
||||
.entry-title { font-weight: 600; }
|
||||
.entry-desc { color: #999; margin-top: 8px; font-size: 13px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div class="login-wrap">
|
||||
<div class="title"><span class="leaf">🍃</span> 室内装修工程污染物预测系统</div>
|
||||
<a-card class="card">
|
||||
<div class="welcome">欢迎</div>
|
||||
<a-divider style="margin: 12px 0 24px" />
|
||||
<a-form layout="vertical" @submit.prevent="onSubmit">
|
||||
<a-form-item>
|
||||
<a-input v-model:value="form.username" size="large" placeholder="账号名">
|
||||
<template #prefix><user-outlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-input-password v-model:value="form.password" size="large" placeholder="密码">
|
||||
<template #prefix><lock-outlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-button type="primary" size="large" block :loading="loading" @click="onSubmit">登 录</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const form = reactive({ username: '', password: '' });
|
||||
const loading = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(form.username, form.password);
|
||||
router.replace('/home');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.title { font-size: 22px; font-weight: 600; margin-bottom: 24px; }
|
||||
.leaf { color: #b4232a; }
|
||||
.card { width: 420px; }
|
||||
.welcome { text-align: center; font-size: 18px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<template>
|
||||
<a-card title="材料数据库">
|
||||
<a-tabs v-model:activeKey="scope" @change="onScopeChange">
|
||||
<a-tab-pane key="public" tab="公共库" />
|
||||
<a-tab-pane key="self" tab="自建库" />
|
||||
</a-tabs>
|
||||
|
||||
<div class="toolbar">
|
||||
<a-form layout="inline" class="filters">
|
||||
<a-form-item label="材料ID"><a-input v-model:value="q.id" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="材料名称"><a-input v-model:value="q.name" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="材料类别"><a-input v-model:value="q.category" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="材料品牌"><a-input v-model:value="q.brand" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="环保级别">
|
||||
<a-select v-model:value="q.envGrade" allow-clear style="width: 100px" @change="reload">
|
||||
<a-select-option value="E0">E0</a-select-option>
|
||||
<a-select-option value="E1">E1</a-select-option>
|
||||
<a-select-option value="E2">E2</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-button v-if="scope === 'self'" type="primary" @click="openCreate">+ 新建材料</a-button>
|
||||
</div>
|
||||
|
||||
<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 === 'envGrade'">
|
||||
<a-tag v-if="record.envGrade">{{ record.envGrade }}</a-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a @click="showDetail(record)">详情</a>
|
||||
<template v-if="scope === 'self'">
|
||||
<a-divider type="vertical" />
|
||||
<a @click="openEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该材料?" @confirm="onDelete(record)">
|
||||
<a style="color: #b4232a">删除</a>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'favorite'">
|
||||
<a @click="toggleFav(record)">{{ record.favorited ? '取消' : '收藏' }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="detailOpen" title="材料详情" :footer="null" width="680px">
|
||||
<a-descriptions v-if="detail" bordered :column="2" size="small">
|
||||
<a-descriptions-item label="材料ID">{{ detail.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="材料名称">{{ detail.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="材料类别">{{ detail.category }}</a-descriptions-item>
|
||||
<a-descriptions-item label="材料品牌">{{ detail.brand || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="材料厂家" :span="2">{{ detail.manufacturer || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="材料规格">{{ detail.spec || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="环保等级">{{ detail.envGrade || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<template v-if="detail">
|
||||
<div class="detail-sub">污染物释放参数</div>
|
||||
<table class="mini-table">
|
||||
<thead><tr><th>污染物</th><th>Y0</th><th>Yp</th><th>B</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pollutants" :key="p">
|
||||
<td>{{ labels[p].zh }}</td>
|
||||
<td>{{ detail.emissionParams?.[p]?.y0 ?? '-' }}</td>
|
||||
<td>{{ detail.emissionParams?.[p]?.yp ?? '-' }}</td>
|
||||
<td>{{ detail.emissionParams?.[p]?.b ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<MaterialFormModal :open="formOpen" :material="editing" @ok="onFormOk" @cancel="formOpen = false" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS } from '@airpredict/shared';
|
||||
import { listMaterials, deleteMaterial, type Material, type Paged } from '../api/materials';
|
||||
import { toggleFavorite } from '../api/favorites';
|
||||
import MaterialFormModal from '../components/MaterialFormModal.vue';
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const loading = ref(false);
|
||||
const data = ref<Paged<Material>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||
const q = reactive<any>({ id: '', name: '', category: '', brand: '', envGrade: undefined });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '材料ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '材料名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '材料类别', dataIndex: 'category', key: 'category' },
|
||||
{ title: '材料品牌', dataIndex: 'brand', key: 'brand' },
|
||||
{ title: '材料厂家', dataIndex: 'manufacturer', key: 'manufacturer' },
|
||||
{ title: '材料规格', dataIndex: 'spec', key: 'spec' },
|
||||
{ title: '环保等级', key: 'envGrade' },
|
||||
{ title: '操作', key: 'action', width: 160 },
|
||||
{ title: '收藏', key: 'favorite', width: 70 },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: data.value.page,
|
||||
pageSize: data.value.pageSize,
|
||||
total: data.value.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
}));
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listMaterials({ ...q, scope: scope.value, page: page.value, pageSize: 10 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScopeChange() {
|
||||
page.value = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
function onTableChange(pg: any) {
|
||||
page.value = pg.current;
|
||||
reload();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.keys(q).forEach((k) => (q[k] = undefined));
|
||||
page.value = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<Material | null>(null);
|
||||
function showDetail(r: Material) {
|
||||
detail.value = r;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
async function toggleFav(r: Material) {
|
||||
const res = await toggleFavorite('material', r.id);
|
||||
r.favorited = res.favorited;
|
||||
message.success(res.favorited ? '已收藏' : '已取消收藏');
|
||||
}
|
||||
|
||||
// 新建 / 编辑
|
||||
const formOpen = ref(false);
|
||||
const editing = ref<Material | null>(null);
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
function openEdit(r: Material) {
|
||||
editing.value = r;
|
||||
formOpen.value = true;
|
||||
}
|
||||
function onFormOk() {
|
||||
formOpen.value = false;
|
||||
reload();
|
||||
}
|
||||
|
||||
async function onDelete(r: Material) {
|
||||
await deleteMaterial(r.id);
|
||||
message.success('已删除');
|
||||
reload();
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
|
||||
.detail-sub { font-weight: 600; color: #b4232a; margin: 16px 0 8px; }
|
||||
.mini-table { width: 100%; border-collapse: collapse; }
|
||||
.mini-table th, .mini-table td { border: 1px solid #f0f0f0; padding: 6px; text-align: center; font-size: 13px; }
|
||||
.mini-table th { background: #fafafa; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<div v-if="project">
|
||||
<a-page-header class="ph" @back="router.push('/home')">
|
||||
<template #title><a style="color: #b4232a">返回首页</a> / 预测项目配置</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 项目信息 -->
|
||||
<a-card class="card">
|
||||
<div class="proj-head">
|
||||
<div>
|
||||
<span class="proj-name">{{ project.name }}</span>
|
||||
<span class="proj-id">项目ID:{{ project.id }}</span>
|
||||
<a-tag :color="project.status === 'report_generated' ? 'green' : 'orange'">
|
||||
{{ project.status === 'report_generated' ? '已生成预测报告' : '未生成预测报告' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="times">
|
||||
创建时间:{{ fmt(project.createdAt) }} 最近更新:{{ fmt(project.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="proj-meta">
|
||||
<span>项目类型:{{ project.type }}</span>
|
||||
<span>所在城市:{{ project.province }}/{{ project.city }}</span>
|
||||
<span>建筑面积:{{ project.area }}m²</span>
|
||||
<span>预测评级:<a-tag v-if="project.rating" :color="ratingColor(project.rating)">{{ project.rating }}</a-tag><span v-else>-</span></span>
|
||||
<a class="edit" @click="editOpen = true">✎ 修改项目信息</a>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 空间 -->
|
||||
<a-card class="card">
|
||||
<div class="spaces-head">
|
||||
<span class="title">包含空间 ({{ project.spaces.length }})</span>
|
||||
<a-button type="link" @click="openAddSpace">+ 添加包含空间</a-button>
|
||||
</div>
|
||||
<a-table :columns="spaceColumns" :data-source="project.spaces" row-key="id" size="middle" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'ventilationRate'">{{ record.ventilationRate }}次/小时</template>
|
||||
<template v-else-if="column.key === 'matCount'">{{ record.materials.length }}</template>
|
||||
<template v-else-if="column.key?.startsWith('conc_')">
|
||||
<span :class="{ over: isOver(record, column.key.slice(5)) }">
|
||||
{{ concText(record, column.key.slice(5)) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="openEditSpace(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该空间?" @confirm="onDeleteSpace(record)">
|
||||
<a style="color: #b4232a">删除</a>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<div class="actions">
|
||||
<a-button type="primary" size="large" :loading="generating" @click="onGenerate">生成预测报告</a-button>
|
||||
</div>
|
||||
|
||||
<NewProjectModal :open="editOpen" :project="project" @ok="onEdited" @cancel="editOpen = false" />
|
||||
<SpaceDrawer
|
||||
:open="drawerOpen"
|
||||
:project-id="project.id"
|
||||
:space="editingSpace"
|
||||
@ok="onSpaceSaved"
|
||||
@cancel="drawerOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, type Pollutant } from '@airpredict/shared';
|
||||
import { getProject, generateReport, type ProjectDetail, type SpaceRow } from '../api/projects';
|
||||
import { deleteSpace } from '../api/spaces';
|
||||
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||
import SpaceDrawer from '../components/SpaceDrawer.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const project = ref<ProjectDetail | null>(null);
|
||||
const editOpen = ref(false);
|
||||
const drawerOpen = ref(false);
|
||||
const editingSpace = ref<SpaceRow | null>(null);
|
||||
const generating = ref(false);
|
||||
|
||||
const spaceColumns = [
|
||||
{ title: '空间ID', dataIndex: 'id' },
|
||||
{ title: '空间名称', dataIndex: 'name' },
|
||||
{ title: '空间类型', dataIndex: 'type' },
|
||||
{ title: '面积', dataIndex: 'area', customRender: ({ text }: any) => `${text}m²` },
|
||||
{ title: '温度', dataIndex: 'temperature', customRender: ({ text }: any) => `${text}℃` },
|
||||
{ title: '湿度', dataIndex: 'humidity', customRender: ({ text }: any) => `${text}%rh` },
|
||||
{ title: '通风换气率', key: 'ventilationRate' },
|
||||
{ title: '材料数', key: 'matCount' },
|
||||
{ title: '限值标准', dataIndex: 'standard' },
|
||||
...POLLUTANTS.map((p) => ({ title: `${POLLUTANT_LABELS[p].zh}预测浓度`, key: `conc_${p}` })),
|
||||
{ title: '操作', key: 'op', width: 130 },
|
||||
];
|
||||
|
||||
function fmt(s?: string) { return s ? new Date(s).toLocaleString() : '-'; }
|
||||
function ratingColor(r: string) { return { A: 'green', B: 'blue', C: 'orange', D: 'red' }[r] || 'default'; }
|
||||
function concText(record: SpaceRow, pol: string) {
|
||||
const v = record.predictedConc?.[pol as Pollutant];
|
||||
return v == null ? '-' : v.toFixed(4) + 'mg/m³';
|
||||
}
|
||||
function isOver(record: SpaceRow, pol: string) {
|
||||
return false; // 颜色提示在报告页更完整,此处仅展示数值
|
||||
}
|
||||
|
||||
async function load() {
|
||||
project.value = await getProject(route.params.id as string);
|
||||
}
|
||||
|
||||
function openAddSpace() { editingSpace.value = null; drawerOpen.value = true; }
|
||||
function openEditSpace(s: SpaceRow) { editingSpace.value = s; drawerOpen.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(); }
|
||||
|
||||
async function onGenerate() {
|
||||
if (!project.value?.spaces.length) return message.warning('请先添加空间');
|
||||
generating.value = true;
|
||||
try {
|
||||
project.value = await generateReport(project.value.id);
|
||||
message.success(`报告已生成,项目评级 ${project.value.rating}`);
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ph { padding: 8px 0; }
|
||||
.card { margin-bottom: 16px; }
|
||||
.proj-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.proj-name { font-size: 18px; font-weight: 700; margin-right: 16px; }
|
||||
.proj-id { color: #888; margin-right: 12px; }
|
||||
.times { color: #999; font-size: 13px; }
|
||||
.proj-meta { margin-top: 12px; display: flex; gap: 24px; align-items: center; color: #555; }
|
||||
.proj-meta .edit { margin-left: auto; color: #b4232a; cursor: pointer; }
|
||||
.spaces-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.spaces-head .title { font-weight: 600; }
|
||||
.actions { text-align: center; padding: 24px 0; }
|
||||
.over { color: #b4232a; font-weight: 600; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<a-card title="项目模板库">
|
||||
<a-tabs v-model:activeKey="scope" @change="onScopeChange">
|
||||
<a-tab-pane key="public" tab="公共库" />
|
||||
<a-tab-pane key="self" tab="自建库" />
|
||||
</a-tabs>
|
||||
|
||||
<a-form layout="inline" class="filters">
|
||||
<a-form-item label="模板ID"><a-input v-model:value="q.id" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<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-input v-model:value="q.city" allow-clear @pressEnter="reload" /></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 === 'action'">
|
||||
<a @click="showDetail(record)">详情</a>
|
||||
<template v-if="scope === 'self'">
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该模板?" @confirm="onDelete(record)">
|
||||
<a style="color: #b4232a">删除</a>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'favorite'">
|
||||
<a @click="toggleFav(record)">{{ record.favorited ? '取消' : '收藏' }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="detailOpen" title="模板详情" :footer="null" width="720px">
|
||||
<template v-if="detail">
|
||||
<a-descriptions bordered :column="2" size="small">
|
||||
<a-descriptions-item label="模板ID">{{ detail.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="工程名称">{{ detail.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="项目类型">{{ detail.type }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所在城市">{{ detail.province }}/{{ detail.city }}</a-descriptions-item>
|
||||
<a-descriptions-item label="建筑面积">{{ detail.area }}m²</a-descriptions-item>
|
||||
<a-descriptions-item label="空间数">{{ detail.spaces?.length || 0 }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="detail-sub">包含空间</div>
|
||||
<a-table
|
||||
:columns="spaceCols"
|
||||
:data-source="detail.spaces || []"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PROJECT_TYPES } from '@airpredict/shared';
|
||||
import { listTemplates, getTemplate, deleteTemplate, type TemplateRow } from '../api/templates';
|
||||
import type { Paged as P } from '../api/materials';
|
||||
import { toggleFavorite } from '../api/favorites';
|
||||
|
||||
const projectTypes = PROJECT_TYPES;
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const loading = ref(false);
|
||||
const data = ref<P<TemplateRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||
const q = reactive<any>({ id: '', name: '', type: undefined, city: '' });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '模板ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '工程名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '项目类型', dataIndex: 'type', key: 'type' },
|
||||
{ title: '所在城市', key: 'city' },
|
||||
{ title: '建筑面积', key: 'area' },
|
||||
{ title: '空间数', dataIndex: 'spaceCount', key: 'spaceCount' },
|
||||
{ title: '最近更新时间', dataIndex: 'updatedAt', key: 'updatedAt', customRender: ({ text }: any) => new Date(text).toLocaleString() },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
{ title: '收藏', key: 'favorite', width: 70 },
|
||||
];
|
||||
|
||||
const spaceCols = [
|
||||
{ title: '空间名称', dataIndex: 'name' },
|
||||
{ title: '空间类型', dataIndex: 'type' },
|
||||
{ title: '面积', dataIndex: 'area', customRender: ({ text }: any) => `${text}m²` },
|
||||
{ title: '温度', dataIndex: 'temperature', customRender: ({ text }: any) => `${text}℃` },
|
||||
{ title: '湿度', dataIndex: 'humidity', customRender: ({ text }: any) => `${text}%rh` },
|
||||
{ title: '材料数', dataIndex: 'materials', customRender: ({ text }: any) => (text || []).length },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: data.value.page,
|
||||
pageSize: data.value.pageSize,
|
||||
total: data.value.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
}));
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listTemplates({ ...q, scope: scope.value, page: page.value, pageSize: 10 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScopeChange() { page.value = 1; reload(); }
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
function reset() { Object.keys(q).forEach((k) => (q[k] = undefined)); page.value = 1; reload(); }
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
async function showDetail(r: TemplateRow) {
|
||||
detail.value = await getTemplate(r.id);
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
async function toggleFav(r: TemplateRow) {
|
||||
const res = await toggleFavorite('template', r.id);
|
||||
r.favorited = res.favorited;
|
||||
message.success(res.favorited ? '已收藏' : '已取消收藏');
|
||||
}
|
||||
|
||||
async function onDelete(r: TemplateRow) {
|
||||
await deleteTemplate(r.id);
|
||||
message.success('已删除');
|
||||
reload();
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters { margin-bottom: 16px; }
|
||||
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
|
||||
.detail-sub { font-weight: 600; color: #b4232a; margin: 16px 0 8px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('../pages/Login.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/AppLayout.vue'),
|
||||
redirect: '/home',
|
||||
children: [
|
||||
{ path: 'home', name: 'home', component: () => import('../pages/Home.vue') },
|
||||
{ path: 'template', name: 'template', component: () => import('../pages/TemplateLibrary.vue') },
|
||||
{ path: 'material', name: 'material', component: () => import('../pages/MaterialLibrary.vue') },
|
||||
{ path: 'history-project', name: 'history', component: () => import('../pages/History.vue') },
|
||||
{ path: 'drafts', name: 'drafts', component: () => import('../pages/Drafts.vue') },
|
||||
{ path: 'predict/:id', name: 'predict', component: () => import('../pages/ProjectConfig.vue') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (to.path !== '/login' && !token) return '/login';
|
||||
if (to.path === '/login' && token) return '/home';
|
||||
return true;
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { http } from '../api/http';
|
||||
|
||||
export interface Org {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const org = ref<Org | null>(null);
|
||||
const token = ref<string | null>(localStorage.getItem('token'));
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const res = await http.post<any, { token: string; org: Org }>('/auth/login', { username, password });
|
||||
token.value = res.token;
|
||||
org.value = res.org;
|
||||
localStorage.setItem('token', res.token);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
org.value = null;
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
return { org, token, login, logout };
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
html, body, #app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #f0f2f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "airpredict",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "室内装修工程污染物预测系统 (Indoor Renovation Pollutant Prediction System)",
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"dev:api": "pnpm --filter @airpredict/api dev",
|
||||
"dev:web": "pnpm --filter @airpredict/web dev",
|
||||
"build": "pnpm -r build",
|
||||
"db:generate": "pnpm --filter @airpredict/api prisma:generate",
|
||||
"db:migrate": "pnpm --filter @airpredict/api prisma:migrate",
|
||||
"db:seed": "pnpm --filter @airpredict/api prisma:seed",
|
||||
"db:studio": "pnpm --filter @airpredict/api prisma:studio"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.3"
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@airpredict/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** 项目类型 (project type) — 抓取自原系统下拉 */
|
||||
export const PROJECT_TYPES = ['住宅', '酒店', '办公楼', '医院', '学校', '养老院', '其他'] as const;
|
||||
export type ProjectType = (typeof PROJECT_TYPES)[number];
|
||||
|
||||
/** 空间类型 (space type) — 抓取自原系统下拉 */
|
||||
export const SPACE_TYPES = [
|
||||
'客厅', '卧室', '卫生间', '客房', '厨房', '书房', '茶室', '储藏室', '娱乐室', '电竞房',
|
||||
] as const;
|
||||
export type SpaceType = (typeof SPACE_TYPES)[number];
|
||||
|
||||
/** 空间户型:等高 / 非等高 */
|
||||
export type SpaceLayout = 'uniform' | 'non-uniform';
|
||||
|
||||
/** 环保等级 */
|
||||
export const ENV_GRADES = ['E0', 'E1', 'E2'] as const;
|
||||
export type EnvGrade = (typeof ENV_GRADES)[number];
|
||||
|
||||
/** 预测评级 */
|
||||
export const PREDICTION_RATINGS = ['A', 'B', 'C', 'D'] as const;
|
||||
export type PredictionRating = (typeof PREDICTION_RATINGS)[number];
|
||||
|
||||
/** 项目状态 */
|
||||
export type ProjectStatus = 'draft' | 'configuring' | 'report_generated';
|
||||
|
||||
/** 库可见性:公共库 / 自建库 */
|
||||
export type LibraryScope = 'public' | 'self';
|
||||
|
||||
/** 材料类别(常见装修材料,可按需扩充)。原系统用 "大类/小类" 形式。 */
|
||||
export const MATERIAL_CATEGORIES = [
|
||||
'人造板/胶合板',
|
||||
'人造板/阻燃胶合板',
|
||||
'人造板/细木工板',
|
||||
'人造板/刨花板',
|
||||
'人造板/纤维板',
|
||||
'木地板/实木地板',
|
||||
'木地板/强化地板',
|
||||
'涂料/墙面漆',
|
||||
'涂料/木器漆',
|
||||
'胶粘剂',
|
||||
'壁纸',
|
||||
'石材瓷砖',
|
||||
'纺织品/窗帘',
|
||||
'家具',
|
||||
'其他',
|
||||
] as const;
|
||||
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number];
|
||||
|
||||
/** 用量单位 */
|
||||
export const USAGE_UNITS = ['m²', 'm³', 'm', 'kg', 'L', '件'] as const;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from './pollutants.js';
|
||||
export * from './enums.js';
|
||||
export * from './prediction.js';
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 五项受控污染物。原系统对每个空间预测这五项的浓度。
|
||||
* The five controlled pollutants the system predicts per space.
|
||||
*/
|
||||
export const POLLUTANTS = ['hcho', 'tvoc', 'benzene', 'toluene', 'xylene'] as const;
|
||||
export type Pollutant = (typeof POLLUTANTS)[number];
|
||||
|
||||
export const POLLUTANT_LABELS: Record<Pollutant, { zh: string; en: string }> = {
|
||||
hcho: { zh: '甲醛', en: 'Formaldehyde' },
|
||||
tvoc: { zh: 'TVOC', en: 'TVOC' },
|
||||
benzene: { zh: '苯', en: 'Benzene' },
|
||||
toluene: { zh: '甲苯', en: 'Toluene' },
|
||||
xylene: { zh: '二甲苯', en: 'Xylene' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 污染物浓度限值标准。原系统下拉提供三套国标。
|
||||
* Pollutant concentration limit standards (national standards).
|
||||
* 单位 mg/m³。
|
||||
*/
|
||||
export type StandardCode = 'GB39126-2020' | 'GB50325-2020' | 'GB/T18883-2022';
|
||||
|
||||
export const STANDARD_LIMITS: Record<StandardCode, Record<Pollutant, number>> = {
|
||||
// 抓取自原系统 GB50325-2020 限值
|
||||
'GB50325-2020': { hcho: 0.07, tvoc: 0.45, benzene: 0.06, toluene: 0.15, xylene: 0.2 },
|
||||
// 以下两套为占位/草拟值,落地前请按官方标准核对
|
||||
'GB39126-2020': { hcho: 0.08, tvoc: 0.5, benzene: 0.06, toluene: 0.2, xylene: 0.2 },
|
||||
'GB/T18883-2022': { hcho: 0.08, tvoc: 0.6, benzene: 0.03, toluene: 0.2, xylene: 0.2 },
|
||||
};
|
||||
|
||||
export const STANDARD_CODES: StandardCode[] = ['GB39126-2020', 'GB50325-2020', 'GB/T18883-2022'];
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { POLLUTANTS, Pollutant, StandardCode, STANDARD_LIMITS } from './pollutants.js';
|
||||
import { PredictionRating } from './enums.js';
|
||||
|
||||
/** 单一污染物的散发模型参数 (per material, per pollutant) */
|
||||
export interface EmissionParams {
|
||||
/** 最低平衡释放量 Y0 (mg/m²) */
|
||||
y0: number;
|
||||
/** 平衡释放量范围 Yp (mg/m²) */
|
||||
yp: number;
|
||||
/** 平衡释放量变化率 B (m²/m³) */
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** 一种材料在某空间的用量 + 各污染物散发参数 */
|
||||
export interface SpaceMaterialInput {
|
||||
materialId: string;
|
||||
/** 使用量(配合用量单位,通常为面积 m² 或其它) */
|
||||
usageAmount: number;
|
||||
/** 每项污染物的散发参数 */
|
||||
params: Record<Pollutant, EmissionParams>;
|
||||
}
|
||||
|
||||
/** 空间环境条件 */
|
||||
export interface SpaceConditions {
|
||||
/** 体积 m³ */
|
||||
volume: number;
|
||||
/** 温度 ℃ */
|
||||
temperature: number;
|
||||
/** 湿度 %rh */
|
||||
humidity: number;
|
||||
/** 通风换气率 次/小时 (ACH) */
|
||||
ventilationRate: number;
|
||||
standard: StandardCode;
|
||||
}
|
||||
|
||||
export interface MaterialContribution {
|
||||
materialId: string;
|
||||
/** 每项污染物对最终浓度的贡献 (mg/m³) */
|
||||
contribution: Record<Pollutant, number>;
|
||||
/** 每项污染物的贡献率 0~1 */
|
||||
contributionRate: Record<Pollutant, number>;
|
||||
}
|
||||
|
||||
export interface SpacePredictionResult {
|
||||
/** 各污染物预测浓度 mg/m³ */
|
||||
concentration: Record<Pollutant, number>;
|
||||
/** 各污染物是否超标 */
|
||||
exceeded: Record<Pollutant, boolean>;
|
||||
/** 各材料贡献 */
|
||||
contributions: MaterialContribution[];
|
||||
rating: PredictionRating;
|
||||
}
|
||||
|
||||
const zero = (): Record<Pollutant, number> =>
|
||||
Object.fromEntries(POLLUTANTS.map((p) => [p, 0])) as Record<Pollutant, number>;
|
||||
|
||||
/**
|
||||
* ⚠️ 核心散发速率公式 —— 把你已标定的公式放这里。
|
||||
* 当前为占位实现:稳态面积散发速率随温度/湿度做简单修正。
|
||||
*
|
||||
* 返回某材料某污染物的稳态面积散发速率 (mg/m²·h)。
|
||||
*
|
||||
* @param p 散发参数 Y0/Yp/B
|
||||
* @param cond 空间环境(温湿度等)
|
||||
*/
|
||||
export function emissionRate(p: EmissionParams, cond: SpaceConditions): number {
|
||||
// —— PLACEHOLDER:替换为你的标定公式 ——
|
||||
// 占位:以 Y0 为基线,Yp 受温度偏离 25℃ 影响,B 作为湿度/装载修正系数。
|
||||
const tempFactor = 1 + 0.02 * (cond.temperature - 25); // 每偏离1℃修正2%
|
||||
const humFactor = 1 + 0.005 * (cond.humidity - 50); // 每偏离50%rh修正0.5%
|
||||
const rate = (p.y0 + p.yp * Math.max(0, tempFactor - 1) + p.b * humFactor * 0.0) * tempFactor * humFactor;
|
||||
return Math.max(0, rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预测单个空间的污染物浓度(质量平衡稳态模型)。
|
||||
*
|
||||
* 稳态:生成速率 G = 去除速率 = ACH × V × C => C = G / (ACH × V)
|
||||
* G_pollutant = Σ_material ( emissionRate × usageAmount )
|
||||
*/
|
||||
export function predictSpace(
|
||||
materials: SpaceMaterialInput[],
|
||||
cond: SpaceConditions,
|
||||
): SpacePredictionResult {
|
||||
const limits = STANDARD_LIMITS[cond.standard];
|
||||
const concentration = zero();
|
||||
const denom = Math.max(1e-6, cond.ventilationRate * cond.volume);
|
||||
|
||||
// 累计各材料各污染物的生成量
|
||||
const rawContrib: Record<string, Record<Pollutant, number>> = {};
|
||||
for (const m of materials) {
|
||||
rawContrib[m.materialId] = zero();
|
||||
for (const pol of POLLUTANTS) {
|
||||
const G = emissionRate(m.params[pol], cond) * m.usageAmount;
|
||||
const c = G / denom;
|
||||
rawContrib[m.materialId][pol] = c;
|
||||
concentration[pol] += c;
|
||||
}
|
||||
}
|
||||
|
||||
// 贡献率
|
||||
const contributions: MaterialContribution[] = materials.map((m) => {
|
||||
const contribution = rawContrib[m.materialId];
|
||||
const contributionRate = zero();
|
||||
for (const pol of POLLUTANTS) {
|
||||
contributionRate[pol] = concentration[pol] > 0 ? contribution[pol] / concentration[pol] : 0;
|
||||
}
|
||||
return { materialId: m.materialId, contribution, contributionRate };
|
||||
});
|
||||
|
||||
const exceeded = Object.fromEntries(
|
||||
POLLUTANTS.map((p) => [p, concentration[p] > limits[p]]),
|
||||
) as Record<Pollutant, boolean>;
|
||||
|
||||
return { concentration, exceeded, contributions, rating: ratingFor(concentration, cond.standard) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 由各污染物浓度相对限值的比值给出评级。
|
||||
* A: 全部 ≤ 60% 限值; B: ≤ 100%; C: ≤ 150%; D: 超过。
|
||||
*/
|
||||
export function ratingFor(
|
||||
concentration: Record<Pollutant, number>,
|
||||
standard: StandardCode,
|
||||
): PredictionRating {
|
||||
const limits = STANDARD_LIMITS[standard];
|
||||
let worst = 0;
|
||||
for (const p of POLLUTANTS) {
|
||||
worst = Math.max(worst, limits[p] > 0 ? concentration[p] / limits[p] : 0);
|
||||
}
|
||||
if (worst <= 0.6) return 'A';
|
||||
if (worst <= 1.0) return 'B';
|
||||
if (worst <= 1.5) return 'C';
|
||||
return 'D';
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,21 @@
|
|||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
|
||||
allowBuilds:
|
||||
'@nestjs/core': set this to true or false
|
||||
'@prisma/client': set this to true or false
|
||||
'@prisma/engines': set this to true or false
|
||||
core-js: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
prisma: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
- '@prisma/client'
|
||||
- '@prisma/engines'
|
||||
- core-js
|
||||
- esbuild
|
||||
- prisma
|
||||
- vue-demi
|
||||
Loading…
Reference in New Issue