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:
zty 2026-06-11 13:58:15 +08:00
commit f79f0a1249
85 changed files with 8792 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
node_modules/
dist/
build/
.env
.env.local
*.log
.DS_Store
coverage/
.vite/
.turbo/
# research/ 是对原站的探查脚本(含原站登录凭据)+截图属本地scratch不入库
research/
prisma/*.db

62
README.md Normal file
View File

@ -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 已按原站抓取)。

9
apps/api/.env.example Normal file
View File

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

8
apps/api/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

44
apps/api/package.json Normal file
View File

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

View File

@ -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 '',
"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 '',
"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;

View File

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

View File

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

110
apps/api/prisma/seed.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
username!: string;
@IsString()
@MinLength(1)
password!: string;
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

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

View File

@ -0,0 +1,9 @@
import { IsIn, IsString } from 'class-validator';
export class ToggleFavoriteDto {
@IsIn(['material', 'template'])
targetType!: 'material' | 'template';
@IsString()
targetId!: string;
}

View File

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

View File

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

View File

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

14
apps/api/src/main.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PredictionService } from './prediction.service';
@Module({
providers: [PredictionService],
exports: [PredictionService],
})
export class PredictionModule {}

View File

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

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

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

View File

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

View File

@ -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_generatedhistory 用 report_generated */
@IsOptional() @IsString() status?: string;
/** 仅未生成报告的草稿(继续配置预测用) */
@IsOptional() @IsIn(['true', 'false']) unfinished?: string;
@IsOptional() @Type(() => Number) page?: number = 1;
@IsOptional() @Type(() => Number) pageSize?: number = 10;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

22
apps/api/tsconfig.json Normal file
View File

@ -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"]
}

12
apps/web/index.html Normal file
View File

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

27
apps/web/package.json Normal file
View File

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

9
apps/web/src/App.vue Normal file
View File

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

View File

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

23
apps/web/src/api/http.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/)</th>
<th>平衡释放量范围 Yp (mg/)</th>
<th>平衡释放量变化率 B (/)</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>

View File

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

View File

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

View File

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

View File

@ -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('新疆维吾尔自治区', ['乌鲁木齐市', '克拉玛依市', '吐鲁番市', '哈密市']),
];

View File

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

9
apps/web/src/main.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,7 @@
<template>
<a-card title="历史预测记录">
<a-empty description="历史记录将在阶段 5 实现" />
</a-card>
</template>
<script setup lang="ts"></script>

View File

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

View File

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

View File

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

View File

@ -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) }} &nbsp; 最近更新{{ fmt(project.updatedAt) }}
</div>
</div>
<div class="proj-meta">
<span>项目类型{{ project.type }}</span>
<span>所在城市{{ project.province }}/{{ project.city }}</span>
<span>建筑面积{{ project.area }}</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}` },
{ 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>

View File

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

View File

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

5
apps/web/src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

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

6
apps/web/src/styles.css Normal file
View File

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

17
apps/web/tsconfig.json Normal file
View File

@ -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"]
}

15
apps/web/vite.config.ts Normal file
View File

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

20
package.json Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './pollutants.js';
export * from './enums.js';
export * from './prediction.js';

View File

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

View File

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

View File

@ -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"]
}

4627
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

21
pnpm-workspace.yaml Normal file
View File

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