Compare commits
No commits in common. "main" and "feature/source-charts" have entirely different histories.
main
...
feature/so
|
|
@ -1,13 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vite/
|
||||
.turbo/
|
||||
# research/ 是对原站的探查脚本(含原站登录凭据)+截图,属本地scratch,不入库
|
||||
research/
|
||||
prisma/*.db
|
||||
62
README.md
62
README.md
|
|
@ -1,62 +0,0 @@
|
|||
# 室内装修工程污染物预测系统(复刻版)
|
||||
|
||||
基于原 [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 已按原站抓取)。
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "organizations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "materials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"brand" TEXT,
|
||||
"manufacturer" TEXT,
|
||||
"spec" TEXT,
|
||||
"envGrade" TEXT,
|
||||
"usageUnit" TEXT NOT NULL DEFAULT 'm²',
|
||||
"emissionParams" JSONB NOT NULL,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerOrgId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "materials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projects" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"province" TEXT NOT NULL,
|
||||
"city" TEXT NOT NULL,
|
||||
"area" DOUBLE PRECISION NOT NULL,
|
||||
"rating" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"isTemplate" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerOrgId" TEXT NOT NULL,
|
||||
"reportGeneratedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "spaces" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"layout" TEXT NOT NULL DEFAULT 'uniform',
|
||||
"height" DOUBLE PRECISION,
|
||||
"area" DOUBLE PRECISION NOT NULL,
|
||||
"volume" DOUBLE PRECISION NOT NULL,
|
||||
"temperature" DOUBLE PRECISION NOT NULL,
|
||||
"humidity" DOUBLE PRECISION NOT NULL,
|
||||
"ventilationRate" DOUBLE PRECISION NOT NULL,
|
||||
"standard" TEXT NOT NULL DEFAULT 'GB50325-2020',
|
||||
"predictedConc" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "spaces_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "space_materials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spaceId" TEXT NOT NULL,
|
||||
"materialId" TEXT NOT NULL,
|
||||
"usageUnit" TEXT NOT NULL DEFAULT 'm²',
|
||||
"usageAmount" DOUBLE PRECISION NOT NULL,
|
||||
"contribution" JSONB,
|
||||
"contributionRate" JSONB,
|
||||
|
||||
CONSTRAINT "space_materials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "favorites" (
|
||||
"id" TEXT NOT NULL,
|
||||
"orgId" TEXT NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reports" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"rating" TEXT,
|
||||
"payload" JSONB NOT NULL,
|
||||
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "organizations_username_key" ON "organizations"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_category_idx" ON "materials"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_brand_idx" ON "materials"("brand");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_envGrade_idx" ON "materials"("envGrade");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_isPublic_idx" ON "materials"("isPublic");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_ownerOrgId_idx" ON "projects"("ownerOrgId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_isTemplate_isPublic_idx" ON "projects"("isTemplate", "isPublic");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "spaces_projectId_idx" ON "spaces"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "space_materials_spaceId_idx" ON "space_materials"("spaceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "space_materials_materialId_idx" ON "space_materials"("materialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "favorites_orgId_targetType_targetId_key" ON "favorites"("orgId", "targetType", "targetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reports_projectId_idx" ON "reports"("projectId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "materials" ADD CONSTRAINT "materials_ownerOrgId_fkey" FOREIGN KEY ("ownerOrgId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_ownerOrgId_fkey" FOREIGN KEY ("ownerOrgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "spaces" ADD CONSTRAINT "spaces_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "space_materials" ADD CONSTRAINT "space_materials_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "space_materials" ADD CONSTRAINT "space_materials_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reports" ADD CONSTRAINT "reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "materials" ADD COLUMN "healthGrade" TEXT,
|
||||
ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_healthGrade_idx" ON "materials"("healthGrade");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "materials_sortOrder_idx" ON "materials"("sortOrder");
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// 室内装修工程污染物预测系统 — 数据模型
|
||||
// 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 @default("") // 手机号注册的访客无密码
|
||||
phone String? @unique // 手机号(访客注册)
|
||||
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(甲醛释放量分级)
|
||||
healthGrade String? // 健康等级 A/B/C(综合健康评级,独立于环保等级)
|
||||
usageUnit String @default("m²") // 用量单位
|
||||
sortOrder Int @default(0) // 手动排序权重(越小越靠前,为厂商竞价排名预留)
|
||||
|
||||
/// 污染物散发参数 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([healthGrade])
|
||||
@@index([isPublic])
|
||||
@@index([sortOrder])
|
||||
@@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")
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
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 (let i = 0; i < MATERIALS.length; i++) {
|
||||
const m = MATERIALS[i];
|
||||
const healthGrade = ['A', 'B', 'C'][i % 3]; // 示例健康等级
|
||||
const sortOrder = (i + 1) * 10; // 预留竞价排名(越小越靠前)
|
||||
await prisma.material.upsert({
|
||||
where: { id: m.id },
|
||||
update: { healthGrade, sortOrder }, // 回填已存在的材料
|
||||
create: {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
category: m.category,
|
||||
brand: m.brand,
|
||||
manufacturer: m.manufacturer,
|
||||
spec: m.spec ?? undefined,
|
||||
envGrade: m.envGrade ?? undefined,
|
||||
healthGrade,
|
||||
sortOrder,
|
||||
usageUnit: 'm²',
|
||||
emissionParams: ep(m.y0),
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`);
|
||||
|
||||
// 官方算例 6 种材料(真实 Y0/Yp/B,5 污染物),供 C 端样板间 + 复现算例
|
||||
const ep5 = (
|
||||
hcho: number[], tvoc: number[], benzene: number[], toluene: number[], xylene: number[],
|
||||
) => ({
|
||||
hcho: { y0: hcho[0], yp: hcho[1], b: hcho[2] },
|
||||
tvoc: { y0: tvoc[0], yp: tvoc[1], b: tvoc[2] },
|
||||
benzene: { y0: benzene[0], yp: benzene[1], b: benzene[2] },
|
||||
toluene: { y0: toluene[0], yp: toluene[1], b: toluene[2] },
|
||||
xylene: { y0: xylene[0], yp: xylene[1], b: xylene[2] },
|
||||
});
|
||||
const REAL_MATERIALS = [
|
||||
{ id: 'PM20000001', name: '多层实木复合地板', category: '木地板/实木地板', brand: '示例', healthGrade: 'B', ep: ep5([0.09, 0.4, 0.47], [0.074, 0.7, 0.205], [0.03, 0.186, 0.265], [0, 0, 0], [0, 0, 0]) },
|
||||
{ id: 'PM20000002', name: '踢脚线', category: '其他', brand: '示例', healthGrade: 'C', ep: ep5([0.38, 2.3, 0.2], [0.25, 1.69, 0.113], [0.053, 0.446, 0.09], [0.05, 0.229, 0.1], [0.009, 0.35, 0.085]) },
|
||||
{ id: 'PM20000003', name: '吸音板', category: '其他', brand: '示例', healthGrade: 'B', ep: ep5([0, 0, 0], [0.24, 1.71, 0.09], [0, 0, 0], [0, 0, 0], [0, 0, 0]) },
|
||||
{ id: 'PM20000004', name: '乳胶漆涂料', category: '涂料/墙面漆', brand: '示例', healthGrade: 'A', ep: ep5([0, 0, 0], [0.04, 0.337, 0.288], [0, 0, 0], [0, 0, 0], [0, 0, 0]) },
|
||||
{ id: 'PM20000005', name: '免漆木门', category: '其他', brand: '示例', healthGrade: 'C', ep: ep5([0, 0, 0], [0.46, 3.05, 0.132], [0.07, 0.53, 0.27], [0.05, 0.41, 0.29], [0.194, 1.24, 0.223]) },
|
||||
{ id: 'PM20000006', name: '人造板家具', category: '家具', brand: '示例', healthGrade: 'C', ep: ep5([0.45, 2.63, 0.446], [0.14, 0.88, 0.36], [0, 0, 0], [0.08, 0.5, 0.39], [0, 0, 0]) },
|
||||
];
|
||||
for (let i = 0; i < REAL_MATERIALS.length; i++) {
|
||||
const m = REAL_MATERIALS[i];
|
||||
await prisma.material.upsert({
|
||||
where: { id: m.id },
|
||||
update: { emissionParams: m.ep, healthGrade: m.healthGrade },
|
||||
create: {
|
||||
id: m.id, name: m.name, category: m.category, brand: m.brand,
|
||||
healthGrade: m.healthGrade, sortOrder: 1000 + i, usageUnit: 'm²',
|
||||
emissionParams: m.ep, isPublic: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`已导入 ${REAL_MATERIALS.length} 条官方算例真实材料(PM2000000x)`);
|
||||
|
||||
// 一条公共项目模板(含 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());
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SmsService } from './sms.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { SendSmsDto, VerifySmsDto } from './dto/sms.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentOrg, OrgPayload } from './current-org.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private auth: AuthService,
|
||||
private sms: SmsService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto.username, dto.password);
|
||||
}
|
||||
|
||||
@Post('sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.sms.send(dto.phone);
|
||||
}
|
||||
|
||||
@Post('sms/verify')
|
||||
verifySms(@Body() dto: VerifySmsDto) {
|
||||
return this.sms.verify(dto.phone, dto.code);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentOrg() org: OrgPayload) {
|
||||
return org;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SmsService } from './sms.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, SmsService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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 } };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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;
|
||||
},
|
||||
);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { IsString, Matches, Length } from 'class-validator';
|
||||
|
||||
export class SendSmsDto {
|
||||
@Matches(/^1\d{10}$/, { message: '手机号格式不正确' })
|
||||
phone!: string;
|
||||
}
|
||||
|
||||
export class VerifySmsDto {
|
||||
@Matches(/^1\d{10}$/, { message: '手机号格式不正确' })
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 6)
|
||||
code!: string;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface CodeEntry {
|
||||
code: string;
|
||||
expireAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号验证码(开发模式)。验证码存内存、5 分钟有效。
|
||||
* 生产环境应接入真实短信网关并改为持久化存储。
|
||||
*/
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private store = new Map<string, CodeEntry>();
|
||||
private readonly DEV = true; // 开发模式:发送接口直接返回验证码
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
) {}
|
||||
|
||||
send(phone: string) {
|
||||
if (!/^1\d{10}$/.test(phone)) throw new BadRequestException('手机号格式不正确');
|
||||
// 6 位验证码(开发模式固定算法,便于测试可读)
|
||||
const code = String(Math.floor(100000 + (Date.now() % 900000)));
|
||||
this.store.set(phone, { code, expireAt: Date.now() + 5 * 60 * 1000 });
|
||||
// 真实环境此处调用短信网关;开发模式直接回传
|
||||
return this.DEV ? { sent: true, devCode: code } : { sent: true };
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string) {
|
||||
const entry = this.store.get(phone);
|
||||
if (!entry || entry.expireAt < Date.now()) throw new BadRequestException('验证码已过期,请重新获取');
|
||||
if (entry.code !== code) throw new BadRequestException('验证码不正确');
|
||||
this.store.delete(phone);
|
||||
|
||||
// 找到或创建该手机号对应的访客组织
|
||||
let org = await this.prisma.organization.findUnique({ where: { phone } });
|
||||
if (!org) {
|
||||
org = await this.prisma.organization.create({
|
||||
data: {
|
||||
username: 'U' + phone,
|
||||
name: phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||||
phone,
|
||||
passwordHash: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
const token = await this.jwt.signAsync({ sub: org.id, username: org.username });
|
||||
return { token, org: { id: org.id, username: org.username, name: org.name, phone } };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { IsIn, IsString } from 'class-validator';
|
||||
|
||||
export class ToggleFavoriteDto {
|
||||
@IsIn(['material', 'template'])
|
||||
targetType!: 'material' | 'template';
|
||||
|
||||
@IsString()
|
||||
targetId!: string;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
|
||||
import { CreateMaterialDto } from './create-material.dto';
|
||||
|
||||
export class BulkCreateMaterialsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(2000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateMaterialDto)
|
||||
items!: CreateMaterialDto[];
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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() healthGrade?: string;
|
||||
@IsOptional() @IsString() usageUnit?: string;
|
||||
@IsOptional() @IsNumber() sortOrder?: number;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EmissionParamsDto)
|
||||
emissionParams!: EmissionParamsDto;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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;
|
||||
@IsOptional() @IsString() healthGrade?: 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
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, 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() healthGrade?: string;
|
||||
@IsOptional() @IsString() usageUnit?: string;
|
||||
@IsOptional() @IsNumber() sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EmissionParamsDto)
|
||||
emissionParams?: EmissionParamsDto;
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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 { BulkCreateMaterialsDto } from './dto/bulk-create-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);
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
bulk(@CurrentOrg() org: OrgPayload, @Body() dto: BulkCreateMaterialsDto) {
|
||||
return this.materials.createMany(org.id, dto.items);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
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.healthGrade) where.healthGrade = q.healthGrade;
|
||||
|
||||
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,
|
||||
healthGrade: dto.healthGrade,
|
||||
usageUnit: dto.usageUnit ?? 'm²',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
|
||||
isPublic: false,
|
||||
ownerOrgId: orgId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量入库(自建库)。返回成功条数。 */
|
||||
async createMany(orgId: string, items: CreateMaterialDto[]) {
|
||||
const data = items.map((dto) => ({
|
||||
id: this.genId(),
|
||||
name: dto.name,
|
||||
category: dto.category,
|
||||
brand: dto.brand,
|
||||
manufacturer: dto.manufacturer,
|
||||
spec: dto.spec,
|
||||
envGrade: dto.envGrade,
|
||||
healthGrade: dto.healthGrade,
|
||||
usageUnit: dto.usageUnit ?? 'm²',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
emissionParams: dto.emissionParams as unknown as Prisma.InputJsonValue,
|
||||
isPublic: false,
|
||||
ownerOrgId: orgId,
|
||||
}));
|
||||
const res = await this.prisma.material.createMany({ data });
|
||||
return { created: res.count };
|
||||
}
|
||||
|
||||
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 | Prisma.MaterialOrderByWithRelationInput[] {
|
||||
// 默认按手动排序权重(竞价排名)升序,其次最近更新
|
||||
if (!sort) return [{ sortOrder: 'asc' }, { updatedAt: 'desc' }];
|
||||
const [field, dir] = sort.split(':');
|
||||
const allowed = ['updatedAt', 'name', 'id', 'envGrade', 'healthGrade', 'sortOrder'];
|
||||
if (!allowed.includes(field)) return [{ sortOrder: 'asc' }, { updatedAt: 'desc' }];
|
||||
return { [field]: dir === 'asc' ? 'asc' : 'desc' };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PredictionService } from './prediction.service';
|
||||
|
||||
@Module({
|
||||
providers: [PredictionService],
|
||||
exports: [PredictionService],
|
||||
})
|
||||
export class PredictionModule {}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class QueryProjectsDto {
|
||||
@IsOptional() @IsString() id?: string;
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
@IsOptional() @IsString() city?: string;
|
||||
@IsOptional() @IsString() rating?: string;
|
||||
|
||||
/** draft|configuring|report_generated;history 用 report_generated */
|
||||
@IsOptional() @IsString() status?: string;
|
||||
/** 仅未生成报告的草稿(继续配置预测用) */
|
||||
@IsOptional() @IsIn(['true', 'false']) unfinished?: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) page?: number = 1;
|
||||
@IsOptional() @Type(() => Number) pageSize?: number = 10;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
||||
@Post(':id/duplicate')
|
||||
duplicate(@CurrentOrg() org: OrgPayload, @Param('id') id: string) {
|
||||
return this.projects.duplicate(org.id, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
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 duplicate(orgId: string, id: string) {
|
||||
const src = await this.prisma.project.findUnique({
|
||||
where: { id },
|
||||
include: { spaces: { include: { materials: true } } },
|
||||
});
|
||||
if (!src) throw new NotFoundException('项目不存在');
|
||||
if (src.ownerOrgId !== orgId && !src.isPublic) throw new ForbiddenException('无权复用');
|
||||
|
||||
return this.prisma.project.create({
|
||||
data: {
|
||||
id: this.genId('P'),
|
||||
name: src.name + ' (复用)',
|
||||
type: src.type,
|
||||
province: src.province,
|
||||
city: src.city,
|
||||
area: src.area,
|
||||
status: 'configuring',
|
||||
ownerOrgId: orgId,
|
||||
spaces: {
|
||||
create: src.spaces.map((s) => ({
|
||||
id: this.genId('S'),
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
layout: s.layout,
|
||||
height: s.height,
|
||||
area: s.area,
|
||||
volume: s.volume,
|
||||
temperature: s.temperature,
|
||||
humidity: s.humidity,
|
||||
ventilationRate: s.ventilationRate,
|
||||
standard: s.standard,
|
||||
materials: {
|
||||
create: s.materials.map((m) => ({
|
||||
materialId: m.materialId,
|
||||
usageUnit: m.usageUnit,
|
||||
usageAmount: m.usageAmount,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(orgId: string, q: QueryProjectsDto) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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[];
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"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",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.9",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
/* charts.js — hand-built SVG chart library for the pollution dashboard.
|
||||
Every builder returns an SVG markup string. Colors come from a palette
|
||||
object `p` so the same chart adapts to each theme.
|
||||
window.CHARTS = { ... } */
|
||||
(function () {
|
||||
const TAU = Math.PI * 2;
|
||||
const fmt = (n, d = 2) => Number(n).toFixed(d).replace(/\.?0+$/, m => m.includes('.') ? '' : m);
|
||||
const pol = (cx, cy, r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||
// describe an SVG arc from angle a0 to a1 (radians), radius r, center cx,cy
|
||||
function arcPath(cx, cy, r, a0, a1) {
|
||||
const [x0, y0] = pol(cx, cy, r, a0);
|
||||
const [x1, y1] = pol(cx, cy, r, a1);
|
||||
const large = (a1 - a0) % TAU > Math.PI ? 1 : 0;
|
||||
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
|
||||
}
|
||||
const status = (ratio, p) => ratio >= 1 ? p.bad : ratio >= 0.85 ? p.warn : p.good;
|
||||
|
||||
/* ── ringGauge: circular progress vs the national-standard limit.
|
||||
100% of the ring = the GB/T limit. Overshoot (>limit) paints the
|
||||
full ring in the "bad" colour. Center shows the value. ── */
|
||||
function ringGauge({ value, limit, unit, name, en, p, size = 104 }) {
|
||||
const ratio = value / limit;
|
||||
const col = status(ratio, p);
|
||||
const r = size / 2 - 9, cx = size / 2, cy = size / 2, C = TAU * r;
|
||||
const frac = Math.min(ratio, 1);
|
||||
const start = -Math.PI / 2;
|
||||
const prog = arcPath(cx, cy, r, start, start + frac * TAU - 0.0001);
|
||||
const over = ratio > 1;
|
||||
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" class="ch-ring">
|
||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${p.track}" stroke-width="8"/>
|
||||
${over
|
||||
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"/>`
|
||||
: `<path d="${prog}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>`}
|
||||
<text x="${cx}" y="${cy - 2}" text-anchor="middle" font-size="20" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(value, value >= 10 ? 0 : 3)}</text>
|
||||
<text x="${cx}" y="${cy + 14}" text-anchor="middle" font-size="9" fill="${p.sub}">${unit}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ── donut: multi-segment compliance pie with big centre stat ── */
|
||||
function donut({ segments, centerNum, centerLabel, p, size = 210 }) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
const r = size / 2 - 16, cx = size / 2, cy = size / 2;
|
||||
let a = -Math.PI / 2, paths = '';
|
||||
segments.forEach(s => {
|
||||
const a1 = a + (s.value / total) * TAU;
|
||||
paths += `<path d="${arcPath(cx, cy, r, a + 0.012, a1 - 0.012)}" fill="none" stroke="${s.color}" stroke-width="22" stroke-linecap="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>`;
|
||||
a = a1;
|
||||
});
|
||||
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${p.track}" stroke-width="22"/>
|
||||
${paths}
|
||||
<text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="40" font-weight="800" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${centerNum}</text>
|
||||
<text x="${cx}" y="${cy + 20}" text-anchor="middle" font-size="13" fill="${p.sub}" letter-spacing="1">${centerLabel}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ── columns: vertical bar chart of per-room concentration, bars coloured
|
||||
by status, with two dashed national-standard limit lines. The hero
|
||||
"clearly shows exceedance" chart. ── */
|
||||
function columns({ items, limit, limit2, unit, p, w = 720, h = 300 }) {
|
||||
const padL = 46, padR = 18, padT = 26, padB = 46;
|
||||
const iw = w - padL - padR, ih = h - padT - padB;
|
||||
const max = Math.max(limit, limit2 || 0, ...items.map(d => d.value)) * 1.22;
|
||||
const y = v => padT + ih - (v / max) * ih;
|
||||
const bw = Math.min(54, (iw / items.length) * 0.56);
|
||||
const step = iw / items.length;
|
||||
let bars = '', labels = '';
|
||||
items.forEach((d, i) => {
|
||||
const cx = padL + step * i + step / 2;
|
||||
const ratio = d.value / limit;
|
||||
const col = status(ratio, p);
|
||||
const by = y(d.value), bh = padT + ih - by;
|
||||
bars += `<rect x="${(cx - bw / 2).toFixed(1)}" y="${by.toFixed(1)}" width="${bw}" height="${bh.toFixed(1)}" rx="4" fill="${col}"${p.glow ? ` filter="url(#chGlowSoft)"` : ''}/>
|
||||
<text x="${cx.toFixed(1)}" y="${(by - 7).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(d.value, 3)}</text>`;
|
||||
labels += `<text x="${cx.toFixed(1)}" y="${h - padB + 18}" text-anchor="middle" font-size="11" fill="${p.sub}">${d.name}</text>`;
|
||||
});
|
||||
// gridlines
|
||||
let grid = '';
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const gy = padT + (ih / 4) * g;
|
||||
grid += `<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${w - padR}" y2="${gy.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>
|
||||
<text x="${padL - 8}" y="${(gy + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="${p.faint}" style="font-variant-numeric:tabular-nums">${fmt(max - (max / 4) * g, 2)}</text>`;
|
||||
}
|
||||
const ly = y(limit), ly2 = limit2 ? y(limit2) : null;
|
||||
const limLine = `<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${w - padR}" y2="${ly.toFixed(1)}" stroke="${p.bad}" stroke-width="1.5" stroke-dasharray="6 4"/>
|
||||
<rect x="${w - padR - 150}" y="${(ly - 17).toFixed(1)}" width="150" height="15" rx="3" fill="${p.badSoft}"/>
|
||||
<text x="${w - padR - 6}" y="${(ly - 6).toFixed(1)}" text-anchor="end" font-size="9.5" font-weight="600" fill="${p.bad}">GB/T 18883 限值 ${fmt(limit, 2)}</text>`;
|
||||
const lim2Line = limit2 ? `<line x1="${padL}" y1="${ly2.toFixed(1)}" x2="${w - padR}" y2="${ly2.toFixed(1)}" stroke="${p.warn}" stroke-width="1.2" stroke-dasharray="3 4"/>
|
||||
<text x="${w - padR - 6}" y="${(ly2 + 12).toFixed(1)}" text-anchor="end" font-size="9.5" font-weight="600" fill="${p.warn}">GB 50325-I 限值 ${fmt(limit2, 2)}</text>` : '';
|
||||
return `<svg viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="xMidYMid meet">
|
||||
${grid}${bars}${lim2Line}${limLine}${labels}</svg>`;
|
||||
}
|
||||
|
||||
/* ── hbars: horizontal ranking (material pollution contribution) ── */
|
||||
function hbars({ items, p, w = 360, rowH = 38 }) {
|
||||
const max = Math.max(...items.map(d => d.value));
|
||||
const labW = 0, barX = 150, barW = w - barX - 56;
|
||||
let rows = '';
|
||||
items.forEach((d, i) => {
|
||||
const yy = i * rowH;
|
||||
const bw = Math.max(4, (d.value / max) * barW);
|
||||
rows += `<g transform="translate(0 ${yy})">
|
||||
<text x="0" y="${rowH / 2 + 4}" font-size="12" fill="${p.ink}">${d.name}</text>
|
||||
<rect x="${barX}" y="${rowH / 2 - 8}" width="${barW}" height="16" rx="5" fill="${p.track}"/>
|
||||
<rect x="${barX}" y="${rowH / 2 - 8}" width="${bw.toFixed(1)}" height="16" rx="5" fill="${d.color || p.accent}"${p.glow ? ` filter="url(#chGlowSoft)"` : ''}/>
|
||||
<text x="${w}" y="${rowH / 2 + 4}" text-anchor="end" font-size="11" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(d.value, 3)}</text>
|
||||
</g>`;
|
||||
});
|
||||
return `<svg viewBox="0 0 ${w} ${items.length * rowH}" width="100%">${rows}</svg>`;
|
||||
}
|
||||
|
||||
/* ── radar: 6-axis pollutant chart. Ring at ratio 1.0 = the limit. ── */
|
||||
function radar({ axes, p, size = 280 }) {
|
||||
const cx = size / 2, cy = size / 2 + 4, R = size / 2 - 46;
|
||||
const n = axes.length;
|
||||
const ang = i => -Math.PI / 2 + (i / n) * TAU;
|
||||
// scale: value/limit, ring max 1.6
|
||||
const RMAX = 1.6;
|
||||
const rr = v => (Math.min(v, RMAX) / RMAX) * R;
|
||||
let grid = '';
|
||||
[0.5, 1.0, 1.5].forEach(g => {
|
||||
const pts = axes.map((_, i) => pol(cx, cy, rr(g), ang(i)).map(x => x.toFixed(1)).join(',')).join(' ');
|
||||
grid += `<polygon points="${pts}" fill="none" stroke="${g === 1 ? p.bad : p.grid}" stroke-width="${g === 1 ? 1.3 : 1}" ${g === 1 ? 'stroke-dasharray="5 4"' : ''}/>`;
|
||||
});
|
||||
let spokes = '', labels = '';
|
||||
axes.forEach((a, i) => {
|
||||
const [x, y] = pol(cx, cy, R, ang(i));
|
||||
spokes += `<line x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>`;
|
||||
const [lx, ly] = pol(cx, cy, R + 20, ang(i));
|
||||
const anchor = Math.abs(lx - cx) < 8 ? 'middle' : lx > cx ? 'start' : 'end';
|
||||
labels += `<text x="${lx.toFixed(1)}" y="${(ly + 4).toFixed(1)}" text-anchor="${anchor}" font-size="11" font-weight="600" fill="${p.sub}">${a.name}</text>`;
|
||||
});
|
||||
const vpts = axes.map((a, i) => pol(cx, cy, rr(a.value / a.limit), ang(i)).map(x => x.toFixed(1)).join(',')).join(' ');
|
||||
const dots = axes.map((a, i) => {
|
||||
const [x, y] = pol(cx, cy, rr(a.value / a.limit), ang(i));
|
||||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${p.accent}"/>`;
|
||||
}).join('');
|
||||
return `<svg viewBox="0 0 ${size} ${size}" width="100%">
|
||||
${grid}${spokes}
|
||||
<polygon points="${vpts}" fill="${p.accent}" fill-opacity="0.18" stroke="${p.accent}" stroke-width="2"${p.glow ? ` filter="url(#chGlow)"` : ''}/>
|
||||
${dots}${labels}</svg>`;
|
||||
}
|
||||
|
||||
/* ── decayArea: concentration decay over ventilation days ── */
|
||||
function decayArea({ points, limit, unit, p, w = 360, h = 200 }) {
|
||||
const padL = 38, padR = 14, padT = 16, padB = 28;
|
||||
const iw = w - padL - padR, ih = h - padT - padB;
|
||||
const xs = points.map(d => d.day), maxX = Math.max(...xs);
|
||||
const maxY = Math.max(limit, ...points.map(d => d.v)) * 1.15;
|
||||
const X = d => padL + (d / maxX) * iw;
|
||||
const Y = v => padT + ih - (v / maxY) * ih;
|
||||
let grid = '';
|
||||
for (let g = 0; g <= 3; g++) {
|
||||
const gy = padT + (ih / 3) * g;
|
||||
grid += `<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${w - padR}" y2="${gy.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>`;
|
||||
}
|
||||
const line = points.map((d, i) => `${i ? 'L' : 'M'}${X(d.day).toFixed(1)} ${Y(d.v).toFixed(1)}`).join(' ');
|
||||
const area = `${line} L${X(maxX).toFixed(1)} ${padT + ih} L${padL} ${padT + ih} Z`;
|
||||
const dots = points.map(d => `<circle cx="${X(d.day).toFixed(1)}" cy="${Y(d.v).toFixed(1)}" r="2.6" fill="${p.accent}"/>`).join('');
|
||||
const xlab = points.filter((_, i) => i % 2 === 0).map(d => `<text x="${X(d.day).toFixed(1)}" y="${h - 8}" text-anchor="middle" font-size="9" fill="${p.faint}">${d.day}天</text>`).join('');
|
||||
const ly = Y(limit);
|
||||
return `<svg viewBox="0 0 ${w} ${h}" width="100%">
|
||||
<defs><linearGradient id="${p.gid}_dk" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="${p.accent}" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="${p.accent}" stop-opacity="0.02"/></linearGradient></defs>
|
||||
${grid}
|
||||
<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${w - padR}" y2="${ly.toFixed(1)}" stroke="${p.bad}" stroke-width="1.2" stroke-dasharray="5 4"/>
|
||||
<text x="${w - padR}" y="${(ly - 5).toFixed(1)}" text-anchor="end" font-size="9" fill="${p.bad}">限值 ${fmt(limit, 2)}</text>
|
||||
<path d="${area}" fill="url(#${p.gid}_dk)"/>
|
||||
<path d="${line}" fill="none" stroke="${p.accent}" stroke-width="2.4" stroke-linejoin="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>
|
||||
${dots}${xlab}</svg>`;
|
||||
}
|
||||
|
||||
/* defs for optional glow filters — inject once per dashboard root */
|
||||
function defs(p) {
|
||||
if (!p.glow) return '';
|
||||
return `<svg width="0" height="0" style="position:absolute"><defs>
|
||||
<filter id="chGlow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
<filter id="chGlowSoft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs></svg>`;
|
||||
}
|
||||
|
||||
window.CHARTS = { ringGauge, donut, columns, hbars, radar, decayArea, defs, status, fmt };
|
||||
})();
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
/* dashboard.js — themes + renderDashboard(themeKey, opts) -> HTML string.
|
||||
Pure markup; charts are SVG from window.CHARTS. window.renderDashboard */
|
||||
(function () {
|
||||
const C = window.CHARTS;
|
||||
const D = window.DASH_DATA;
|
||||
const SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',system-ui,sans-serif";
|
||||
const SERIF = "'Songti SC','STSong',Georgia,'Times New Roman',serif";
|
||||
|
||||
const ICON = {
|
||||
grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
|
||||
predict: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l5-6 4 3 6-8"/><path d="M3 21h18"/></svg>',
|
||||
flask: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8a2 2 0 0 0 1.7 3h10.6A2 2 0 0 0 19 17l-5-8V3"/><path d="M7 14h10"/></svg>',
|
||||
source: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></svg>',
|
||||
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
||||
report: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
||||
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7 7 0 0 0-1.7 1l-2.3-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.6c.1-.3.1-.7.1-1z" stroke-linejoin="round"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
|
||||
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>',
|
||||
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 16H3z"/><path d="M12 9v5M12 17.5v.01"/></svg>',
|
||||
bldg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 21V5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v16M14 21V9h5a1 1 0 0 1 1 1v11M7 8h2M7 12h2M7 16h2"/></svg>',
|
||||
leaf: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20c10 2 16-4 16-14 0 0-8-2-12 2-3 3-3 7-1 9 3-4 6-6 9-7"/></svg>',
|
||||
up: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14l6-6 6 6"/></svg>',
|
||||
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 10l6 6 6-6"/></svg>',
|
||||
};
|
||||
|
||||
const THEMES = {
|
||||
dark: {
|
||||
cls: 'v-dark',
|
||||
vars: {
|
||||
'--bg': '#0a101e', '--side-bg': '#070c17', '--side-border': 'rgba(255,255,255,.06)',
|
||||
'--side-ink': '#e7eef9', '--side-sub': '#7589a6', '--side-hover': 'rgba(255,255,255,.05)',
|
||||
'--border': 'rgba(255,255,255,.08)', '--panel': '#111b2e', '--panel2': '#0d1626',
|
||||
'--ink': '#e8eff9', '--sub': '#8ba0bd', '--faint': '#5d6f8c',
|
||||
'--accent': '#2dd4bf', '--accent2': '#38bdf8', '--accent-soft': 'rgba(45,212,191,.14)', '--accent-on': '#5eead4',
|
||||
'--good': '#34d399', '--good-soft': 'rgba(52,211,153,.15)', '--warn': '#fbbf24', '--warn-soft': 'rgba(251,191,36,.15)',
|
||||
'--bad': '#f87171', '--bad-soft': 'rgba(248,113,113,.15)', '--track': 'rgba(255,255,255,.07)',
|
||||
'--radius': '16px', '--shadow': '0 10px 34px rgba(0,0,0,.45)', '--topbar-bg': 'rgba(7,12,23,.5)',
|
||||
'--logo-glow': '0 0 18px rgba(45,212,191,.5)', '--font': SANS, '--display': SANS,
|
||||
},
|
||||
chart: { good: '#34d399', warn: '#fbbf24', bad: '#f87171', badSoft: 'rgba(248,113,113,.2)', accent: '#2dd4bf', grid: 'rgba(255,255,255,.08)', ink: '#e8eff9', sub: '#8ba0bd', faint: '#5d6f8c', track: 'rgba(255,255,255,.09)', glow: true, gid: 'dk' },
|
||||
},
|
||||
light: {
|
||||
cls: 'v-light',
|
||||
vars: {
|
||||
'--bg': '#eef1f7', '--side-bg': '#ffffff', '--side-border': '#e7ebf2',
|
||||
'--side-ink': '#16212f', '--side-sub': '#6a788b', '--side-hover': 'rgba(20,87,214,.06)',
|
||||
'--border': '#e7ebf2', '--panel': '#ffffff', '--panel2': '#f4f6fb',
|
||||
'--ink': '#15212e', '--sub': '#5b6b7d', '--faint': '#98a4b3',
|
||||
'--accent': '#1457d6', '--accent2': '#3b82f6', '--accent-soft': 'rgba(20,87,214,.10)', '--accent-on': '#1457d6',
|
||||
'--good': '#16a34a', '--good-soft': 'rgba(22,163,74,.11)', '--warn': '#e08600', '--warn-soft': 'rgba(224,134,0,.13)',
|
||||
'--bad': '#dc2626', '--bad-soft': 'rgba(220,38,38,.10)', '--track': '#eaeef4',
|
||||
'--radius': '14px', '--shadow': '0 1px 2px rgba(20,30,50,.05),0 6px 18px rgba(20,30,50,.05)', '--topbar-bg': 'rgba(255,255,255,.7)',
|
||||
'--logo-glow': 'none', '--font': SANS, '--display': SANS,
|
||||
},
|
||||
chart: { good: '#16a34a', warn: '#e08600', bad: '#dc2626', badSoft: 'rgba(220,38,38,.1)', accent: '#1457d6', grid: '#eaeef4', ink: '#15212e', sub: '#5b6b7d', faint: '#98a4b3', track: '#eaeef4', glow: false, gid: 'lt' },
|
||||
},
|
||||
warm: {
|
||||
cls: 'v-warm',
|
||||
vars: {
|
||||
'--bg': '#f4f0e7', '--side-bg': '#fffdf8', '--side-border': '#e8e0d0',
|
||||
'--side-ink': '#241e15', '--side-sub': '#897f6c', '--side-hover': 'rgba(31,122,90,.08)',
|
||||
'--border': '#e9e1d2', '--panel': '#fffdf8', '--panel2': '#f4efe3',
|
||||
'--ink': '#221d15', '--sub': '#6c6353', '--faint': '#a89c86',
|
||||
'--accent': '#1f7a5a', '--accent2': '#2f9e74', '--accent-soft': 'rgba(31,122,90,.12)', '--accent-on': '#1f7a5a',
|
||||
'--good': '#2f8f5b', '--good-soft': 'rgba(47,143,91,.13)', '--warn': '#ca8326', '--warn-soft': 'rgba(202,131,38,.15)',
|
||||
'--bad': '#bf4a30', '--bad-soft': 'rgba(191,74,48,.12)', '--track': '#ebe3d4',
|
||||
'--radius': '16px', '--shadow': '0 1px 2px rgba(60,50,30,.05)', '--topbar-bg': 'rgba(255,253,248,.7)',
|
||||
'--logo-glow': 'none', '--font': SANS, '--display': SERIF,
|
||||
},
|
||||
chart: { good: '#2f8f5b', warn: '#ca8326', bad: '#bf4a30', badSoft: 'rgba(191,74,48,.14)', accent: '#1f7a5a', grid: '#ece4d5', ink: '#221d15', sub: '#6c6353', faint: '#a89c86', track: '#ebe3d4', glow: false, gid: 'wm' },
|
||||
},
|
||||
};
|
||||
|
||||
const pct = (v, t) => Math.round((v / t) * 1000) / 10;
|
||||
const statusKey = r => r >= 1 ? 'bad' : r >= 0.85 ? 'warn' : 'good';
|
||||
const cnLevel = { bad: '超标', warn: '临界', good: '达标' };
|
||||
|
||||
function nav(active) {
|
||||
const items = [
|
||||
['grid', '总览看板', 0], ['predict', '污染物预测', 0], ['flask', '材料库', 0],
|
||||
['source', '污染源识别', 19], ['folder', '案例库 / 项目', 0], ['report', '检测报告', 0],
|
||||
];
|
||||
return `<div class="d-navgrp">主菜单</div><div class="d-nav">` +
|
||||
items.map((it, i) => `<div class="d-nav-i${i === active ? ' on' : ''}">${ICON[it[0]]}<span>${it[1]}</span>${it[2] ? `<span class="d-badge">${it[2]}</span>` : ''}</div>`).join('') +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function kpiCard(lab, icon, iconBg, iconCol, num, unit, trend) {
|
||||
return `<div class="d-kpi">
|
||||
<div class="d-kpi-lab"><span class="d-kpi-ic" style="background:${iconBg};color:${iconCol}">${ICON[icon]}</span>${lab}</div>
|
||||
<div class="d-kpi-row">
|
||||
<div><span class="d-kpi-num">${num}</span>${unit ? `<span class="d-kpi-unit">${unit}</span>` : ''}</div>
|
||||
${trend || ''}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
// pollutant ring gauges or bars
|
||||
function pollutantViz(mode, p) {
|
||||
if (mode === 'bar') {
|
||||
const w = 470, rowH = 34, padR = 64, barX = 92, barW = w - barX - padR;
|
||||
let rows = '';
|
||||
D.pollutants.forEach((d, i) => {
|
||||
const ratio = d.value / d.limit, col = C.status(ratio, p);
|
||||
const yy = i * rowH;
|
||||
const limX = barX + barW; // 100% = limit
|
||||
const bw = Math.max(4, Math.min(ratio, 1.45) / 1.45 * barW);
|
||||
const limMark = barX + (1 / 1.45) * barW;
|
||||
rows += `<g transform="translate(0 ${yy})">
|
||||
<text x="0" y="${rowH / 2 + 4}" font-size="12" font-weight="700" fill="${p.ink}">${d.name}</text>
|
||||
<rect x="${barX}" y="${rowH / 2 - 8}" width="${barW}" height="16" rx="5" fill="${p.track}"/>
|
||||
<rect x="${barX}" y="${rowH / 2 - 8}" width="${bw.toFixed(1)}" height="16" rx="5" fill="${col}"/>
|
||||
<line x1="${limMark.toFixed(1)}" y1="${rowH / 2 - 12}" x2="${limMark.toFixed(1)}" y2="${rowH / 2 + 12}" stroke="${p.bad}" stroke-width="1.4" stroke-dasharray="3 3"/>
|
||||
<text x="${w}" y="${rowH / 2 + 4}" text-anchor="end" font-size="11" font-weight="700" fill="${col}" style="font-variant-numeric:tabular-nums">${C.fmt(d.value, d.value >= 10 ? 0 : 3)}</text>
|
||||
</g>`;
|
||||
});
|
||||
return `<div style="padding-top:4px"><svg viewBox="0 0 ${w} ${D.pollutants.length * rowH + 6}" width="100%">${rows}
|
||||
<text x="${barX + (1 / 1.45) * barW}" y="${D.pollutants.length * rowH + 2}" text-anchor="middle" font-size="9" fill="${p.bad}">国标限值</text></svg></div>`;
|
||||
}
|
||||
return `<div class="d-gauges">` + D.pollutants.map(d => {
|
||||
const ratio = d.value / d.limit, sk = statusKey(ratio);
|
||||
return `<div class="d-g">${C.ringGauge({ value: d.value, limit: d.limit, unit: d.unit, name: d.name, en: d.en, p })}
|
||||
<div class="d-g-nm">${d.name}</div><div class="d-g-en">${d.en} · 限值 ${C.fmt(d.limit, 2)}</div>
|
||||
<div class="d-g-pill pill-${sk}">${cnLevel[sk]} ${Math.round(ratio * 100)}%</div></div>`;
|
||||
}).join('') + `</div>`;
|
||||
}
|
||||
|
||||
// compliance donut or stacked bar
|
||||
function complianceViz(mode, p, total) {
|
||||
const segs = D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] }));
|
||||
const legend = `<div class="d-legend">` + segs.map(s =>
|
||||
`<div class="d-leg"><span class="dot" style="background:${s.color}"></span><span class="nm">${s.label}房间</span><span class="vl">${s.value}</span><span class="pc">${pct(s.value, total)}%</span></div>`
|
||||
).join('') + `</div>`;
|
||||
if (mode === 'bar') {
|
||||
let x = 0; const w = 100;
|
||||
const segbar = segs.map(s => { const wpc = s.value / total * w; const r = `<div style="width:${wpc}%;background:${s.color}"></div>`; x += wpc; return r; }).join('');
|
||||
return `<div style="display:flex;height:18px;border-radius:6px;overflow:hidden;gap:2px;margin:6px 0 16px">${segbar}</div>
|
||||
<div style="font-family:var(--display);font-size:34px;font-weight:800;letter-spacing:-.5px">${D.kpis.compliance}<span style="font-size:16px;color:var(--faint)">%</span></div>
|
||||
<div style="font-size:12px;color:var(--sub);margin:2px 0 4px">总体房间达标率</div>${legend}`;
|
||||
}
|
||||
return `<div style="display:flex;justify-content:center;margin:4px 0 10px">${C.donut({ segments: segs, centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p })}</div>${legend}`;
|
||||
}
|
||||
|
||||
window.renderDashboard = function (themeKey, opts) {
|
||||
opts = opts || {};
|
||||
const T = THEMES[themeKey] || THEMES.dark;
|
||||
const p = T.chart;
|
||||
const styleVars = Object.entries(T.vars).map(([k, v]) => `${k}:${v}`).join(';');
|
||||
const warm = themeKey === 'warm';
|
||||
|
||||
// worst pollutant flag for latest prediction (主卧 甲醛)
|
||||
const hcho = D.pollutants[0];
|
||||
const over = Math.round((hcho.value / hcho.limit - 1) * 100);
|
||||
|
||||
const top = `<div class="d-top">
|
||||
<div><div class="d-top-tt">工程污染概览</div><div class="d-top-crumb">${D.project.name} · ${D.project.area}㎡ · ${D.project.type}</div></div>
|
||||
<div class="d-spacer"></div>
|
||||
<div class="d-search">${ICON.search}<span>搜索项目 / 房间 / 材料</span></div>
|
||||
<div class="d-std"><b class="on">GB/T 18883</b><b>GB 50325</b></div>
|
||||
<div class="d-iconbtn">${ICON.bell}<span class="d-dot"></span></div>
|
||||
</div>`;
|
||||
|
||||
const side = `<div class="d-side">
|
||||
<div class="d-brand"><div class="d-logo">${warm ? ICON.leaf : ICON.bldg}</div>
|
||||
<div><div class="d-brand-tt">污染物预测系统</div><div class="d-brand-sub">INDOOR · AIR</div></div></div>
|
||||
${nav(0)}
|
||||
<div class="d-side-foot">
|
||||
<div class="d-user"><div class="d-ava">陈</div>
|
||||
<div><div class="d-user-nm">陈工 · 环境工程师</div><span class="d-pro">★ 专业版</span></div></div>
|
||||
</div></div>`;
|
||||
|
||||
const kpis = `<div class="d-kpis">
|
||||
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', `<span class="d-kpi-tr d-up">${ICON.up}本周 +3</span>`)}
|
||||
${kpiCard('房间达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}%</span>`)}
|
||||
${kpiCard('当前超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', `<span class="d-kpi-tr d-up">${ICON.down}${Math.abs(D.kpis.trend.exceed)}</span>`)}
|
||||
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', `<span class="d-kpi-tr" style="color:var(--accent-on);background:var(--accent-soft)">${ICON.up}活跃</span>`)}
|
||||
</div>`;
|
||||
|
||||
const totalRooms = D.compliance.reduce((s, x) => s + x.value, 0);
|
||||
|
||||
const colCard = `<div class="card sp8">
|
||||
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>各房间 · 甲醛预测浓度</div><div class="card-sub">柱状图按状态着色 · 红=超标 / 橙=临界 / 绿=达标,对照国标限值</div></div>
|
||||
<span class="card-tag">单位 mg/m³</span></div>
|
||||
${C.columns({ items: D.rooms, limit: D.roomLimit, limit2: D.roomLimit2, unit: 'mg/m³', p, w: 760, h: 268 })}
|
||||
</div>`;
|
||||
|
||||
const donutCard = `<div class="card sp4">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>项目达标率</div><span class="card-tag">${totalRooms} 间房</span></div>
|
||||
${complianceViz(opts.compliance || 'donut', p, totalRooms)}
|
||||
</div>`;
|
||||
|
||||
const gaugeCard = `<div class="card sp5">
|
||||
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>最近一次预测 · 主卧</div><div class="card-sub">${D.project.updated} · 6 项污染物 vs 国标限值</div></div></div>
|
||||
<div class="d-alert">${ICON.alert}<span>甲醛 ${hcho.value} mg/m³ · 超出 GB/T 18883 限值 ${over}%,建议加强通风并核查人造板材料</span></div>
|
||||
${pollutantViz(opts.pollutant || 'ring', p)}
|
||||
</div>`;
|
||||
|
||||
const radarCard = `<div class="card sp4">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>污染物雷达</div><span class="card-tag">主卧</span></div>
|
||||
<div class="card-sub" style="margin:-8px 0 2px">虚线红环 = 国标限值(比值 1.0)</div>
|
||||
${C.radar({ axes: D.pollutants.map(d => ({ name: d.name, value: d.value, limit: d.limit })), p, size: 250 })}
|
||||
</div>`;
|
||||
|
||||
const decayCard = `<div class="card sp3">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>甲醛衰减</div></div>
|
||||
<div class="card-sub" style="margin:-8px 0 8px">随通风天数</div>
|
||||
${C.decayArea({ points: D.decay, limit: D.roomLimit, unit: 'mg/m³', p, w: 300, h: 176 })}
|
||||
</div>`;
|
||||
|
||||
const tableRows = D.exceed.map(r => {
|
||||
const ratio = r.value / r.limit;
|
||||
return `<tr><td><div class="rm">${r.room}</div><div class="pj">${r.project}</div></td>
|
||||
<td><span class="d-pol">${r.pollutant}</span></td>
|
||||
<td class="vn" style="color:${C.status(ratio, p)}">${C.fmt(r.value, r.value >= 10 ? 0 : 3)}</td>
|
||||
<td class="lm">${C.fmt(r.limit, 2)}</td>
|
||||
<td><span class="d-chip chip-${r.level}">${cnLevel[r.level]} ${Math.round(ratio * 100)}%</span></td></tr>`;
|
||||
}).join('');
|
||||
const tableCard = `<div class="card sp8">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>超标房间清单</div><span class="card-tag">${ICON.alert ? '' : ''}共 ${D.exceed.length} 条 · 按超标率排序</span></div>
|
||||
<table class="d-tbl"><thead><tr><th>房间 / 项目</th><th>污染物</th><th>预测浓度</th><th>限值</th><th>状态</th></tr></thead>
|
||||
<tbody>${tableRows}</tbody></table></div>`;
|
||||
|
||||
const matCard = `<div class="card sp4">
|
||||
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>污染源识别 · 材料贡献</div><div class="card-sub">主卧甲醛 · 公式溯源排行</div></div></div>
|
||||
${C.hbars({ items: D.materials.map((m, i) => ({ ...m, color: i === 0 ? p.bad : i === 1 ? p.warn : p.accent })), p, w: 340, rowH: 40 })}
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
if (warm) {
|
||||
const heroComp = `<div class="d-hero"><div class="d-hero-l">
|
||||
${C.donut({ segments: D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] })), centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p, size: 150 })}
|
||||
<div class="d-hero-txt"><div class="t">在管 ${D.kpis.projects} 个项目 · ${totalRooms} 间房</div>
|
||||
<div class="n">${D.compliance[2].value} 间超标 · ${D.compliance[1].value} 间临界</div>
|
||||
<div class="t" style="margin-top:8px">最近更新 ${D.project.updated}</div></div>
|
||||
</div>
|
||||
<div class="d-kpis" style="margin:0">
|
||||
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', '')}
|
||||
${kpiCard('达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}</span>`)}
|
||||
${kpiCard('超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', '')}
|
||||
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', '')}
|
||||
</div></div>`;
|
||||
body = heroComp + `<div class="d-grid">
|
||||
${colCard}
|
||||
${gaugeCard.replace('sp5','sp4')}
|
||||
${radarCard}
|
||||
${decayCard.replace('sp3','sp4')}
|
||||
${matCard}
|
||||
${tableCard.replace('sp8','sp12')}
|
||||
</div>`;
|
||||
} else {
|
||||
body = kpis + `<div class="d-grid">
|
||||
${colCard}${donutCard}
|
||||
${gaugeCard}${radarCard}${decayCard}
|
||||
${tableCard}${matCard}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="dash ${T.cls}" style="${styleVars}">${C.defs(p)}${side}
|
||||
<div class="d-main">${top}<div class="d-scroll">${body}</div></div></div>`;
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/* data.js — shared realistic dataset for the dashboard.
|
||||
Limits per GB/T 18883-2022 (室内空气质量标准) and GB 50325-2020
|
||||
(民用建筑工程室内环境污染控制标准, I类民用建筑). window.DASH_DATA */
|
||||
window.DASH_DATA = {
|
||||
project: { name: '锦绣华庭 · 18栋 2单元 1602', area: 118, type: 'I类民用建筑(住宅)', updated: '2026-06-11 14:20' },
|
||||
|
||||
kpis: {
|
||||
projects: 28, // 在管项目
|
||||
compliance: 82.1, // 达标率 %
|
||||
exceedRooms: 19, // 当前超标房间
|
||||
weekPredictions: 64, // 本周预测次数
|
||||
trend: { compliance: +3.4, exceed: -5 },
|
||||
},
|
||||
|
||||
// 项目达标率 pie — 房间层级统计
|
||||
compliance: [
|
||||
{ label: '达标', value: 213, key: 'good' },
|
||||
{ label: '临界', value: 34, key: 'warn' },
|
||||
{ label: '超标', value: 19, key: 'bad' },
|
||||
],
|
||||
|
||||
// 6 监测污染物 — 最近一次预测(主卧),value 为预测浓度
|
||||
pollutants: [
|
||||
{ key: 'HCHO', name: '甲醛', en: 'HCHO', unit: 'mg/m³', value: 0.131, limit: 0.10, limit2: 0.07 },
|
||||
{ key: 'C6H6', name: '苯', en: 'Benzene', unit: 'mg/m³', value: 0.021, limit: 0.03, limit2: 0.06 },
|
||||
{ key: 'TVOC', name: 'TVOC', en: 'TVOC', unit: 'mg/m³', value: 0.582, limit: 0.60, limit2: 0.45 },
|
||||
{ key: 'NH3', name: '氨', en: 'NH₃', unit: 'mg/m³', value: 0.112, limit: 0.20, limit2: 0.15 },
|
||||
{ key: 'Rn', name: '氡', en: 'Radon', unit: 'Bq/m³', value: 208, limit: 300, limit2: 150 },
|
||||
{ key: 'VOC', name: '总VOC', en: 'VOC', unit: 'mg/m³', value: 0.486, limit: 0.60, limit2: 0.50 },
|
||||
],
|
||||
|
||||
// 各房间 甲醛预测浓度(hero 柱状图)
|
||||
rooms: [
|
||||
{ name: '主卧', value: 0.131 },
|
||||
{ name: '次卧', value: 0.092 },
|
||||
{ name: '客厅', value: 0.078 },
|
||||
{ name: '书房', value: 0.118 },
|
||||
{ name: '儿童房', value: 0.142 },
|
||||
{ name: '厨房', value: 0.064 },
|
||||
{ name: '餐厅', value: 0.071 },
|
||||
{ name: '卫生间', value: 0.055 },
|
||||
],
|
||||
roomLimit: 0.10, roomLimit2: 0.07,
|
||||
|
||||
// 各材料 甲醛 污染贡献排行(主卧)
|
||||
materials: [
|
||||
{ name: '多层实木复合地板', value: 0.052 },
|
||||
{ name: '人造板衣柜', value: 0.041 },
|
||||
{ name: '木器漆 · 饰面', value: 0.018 },
|
||||
{ name: '壁纸及基膜', value: 0.012 },
|
||||
{ name: '布艺沙发软装', value: 0.008 },
|
||||
],
|
||||
|
||||
// 甲醛浓度随通风天数衰减(主卧)
|
||||
decay: [
|
||||
{ day: 0, v: 0.182 }, { day: 7, v: 0.158 }, { day: 14, v: 0.141 },
|
||||
{ day: 21, v: 0.131 }, { day: 30, v: 0.117 }, { day: 45, v: 0.101 },
|
||||
{ day: 60, v: 0.089 }, { day: 90, v: 0.072 },
|
||||
],
|
||||
|
||||
// 超标房间清单
|
||||
exceed: [
|
||||
{ project: '锦绣华庭 18-2-1602', room: '儿童房', pollutant: '甲醛', value: 0.142, limit: 0.10, level: 'bad' },
|
||||
{ project: '锦绣华庭 18-2-1602', room: '主卧', pollutant: '甲醛', value: 0.131, limit: 0.10, level: 'bad' },
|
||||
{ project: '翠湖天地 6-1-803', room: '书房', pollutant: '甲醛', value: 0.118, limit: 0.10, level: 'bad' },
|
||||
{ project: '锦绣华庭 18-2-1602', room: '客厅', pollutant: 'TVOC', value: 0.582, limit: 0.60, level: 'warn' },
|
||||
{ project: '万科城 9-3-2201', room: '主卧', pollutant: '苯', value: 0.034, limit: 0.03, level: 'bad' },
|
||||
{ project: '翠湖天地 6-1-803', room: '次卧', pollutant: 'TVOC', value: 0.561, limit: 0.60, level: 'warn' },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { http } from './http';
|
||||
|
||||
export function toggleFavorite(targetType: 'material' | 'template', targetId: string) {
|
||||
return http.post<any, { favorited: boolean }>('/favorites/toggle', { targetType, targetId });
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
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;
|
||||
healthGrade?: string;
|
||||
usageUnit: string;
|
||||
sortOrder: number;
|
||||
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;
|
||||
healthGrade?: 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;
|
||||
healthGrade?: string;
|
||||
usageUnit?: string;
|
||||
sortOrder?: number;
|
||||
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 bulkCreateMaterials(items: MaterialInput[]) {
|
||||
return http.post<any, { created: number }>('/materials/bulk', { items });
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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; name?: string; type?: string; rating?: string; page?: number; pageSize?: number }) {
|
||||
return http.get<any, Paged<ProjectRow>>('/projects', { params });
|
||||
}
|
||||
export function duplicateProject(id: string) {
|
||||
return http.post<any, ProjectDetail>(`/projects/${id}/duplicate`, {});
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { http } from './http';
|
||||
import type { Org } from '../stores/auth';
|
||||
|
||||
export function sendSms(phone: string) {
|
||||
return http.post<any, { sent: boolean; devCode?: string }>('/auth/sms/send', { phone });
|
||||
}
|
||||
|
||||
export function verifySms(phone: string, code: string) {
|
||||
return http.post<any, { token: string; org: Org & { phone: string } }>('/auth/sms/verify', { phone, code });
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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}`);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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}`);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<template>
|
||||
<a-modal :open="open" title="快速导入项目 · 选择模板" :footer="null" width="860px" @cancel="emit('cancel')">
|
||||
<a-tabs v-model:activeKey="scope" @change="reload">
|
||||
<a-tab-pane key="public" tab="公共模板" />
|
||||
<a-tab-pane key="self" tab="自建模板" />
|
||||
</a-tabs>
|
||||
<a-table :columns="columns" :data-source="data.items" :loading="loading" :pagination="pagination" row-key="id" size="small" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
|
||||
<template v-else-if="column.key === 'area'">{{ record.area }}m²</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a-button type="link" size="small" :loading="usingId === record.id" @click="useTemplate(record)">使用此模板</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { listTemplates, type TemplateRow } from '../api/templates';
|
||||
import { createProject } from '../api/projects';
|
||||
import type { Paged } from '../api/materials';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'created', id: string): void; (e: 'cancel'): void }>();
|
||||
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const loading = ref(false);
|
||||
const usingId = ref('');
|
||||
const data = ref<Paged<TemplateRow>>({ total: 0, page: 1, pageSize: 8, items: [] });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '模板ID', dataIndex: 'id' },
|
||||
{ title: '工程名称', dataIndex: 'name' },
|
||||
{ title: '项目类型', dataIndex: 'type' },
|
||||
{ title: '所在城市', key: 'city' },
|
||||
{ title: '建筑面积', key: 'area' },
|
||||
{ title: '空间数', dataIndex: 'spaceCount' },
|
||||
{ title: '操作', key: 'op', width: 120 },
|
||||
];
|
||||
const pagination = computed(() => ({ current: data.value.page, pageSize: data.value.pageSize, total: data.value.total }));
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listTemplates({ scope: scope.value, page: page.value, pageSize: 8 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
|
||||
async function useTemplate(r: TemplateRow) {
|
||||
usingId.value = r.id;
|
||||
try {
|
||||
const p = await createProject({
|
||||
name: r.name, type: r.type, province: r.province, city: r.city, area: r.area, fromTemplateId: r.id,
|
||||
});
|
||||
message.success('已按模板创建项目');
|
||||
emit('created', p.id);
|
||||
} finally {
|
||||
usingId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (o) => { if (o) { page.value = 1; reload(); } });
|
||||
</script>
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
<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="6">
|
||||
<a-form-item label="材料规格"><a-input v-model:value="form.spec" placeholder="请输入" /></a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<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="6">
|
||||
<a-form-item label="健康等级">
|
||||
<a-select v-model:value="form.healthGrade" allow-clear placeholder="请选择">
|
||||
<a-select-option v-for="g in healthGrades" :key="g" :value="g">{{ g }} 级</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item label="用量单位">
|
||||
<a-select v-model:value="form.usageUnit">
|
||||
<a-select-option v-for="u in units" :key="u" :value="u">{{ u }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="section-title">污染物释放参数</div>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>污染物</th>
|
||||
<th>最低平衡释放量 Y0 (mg/m²)</th>
|
||||
<th>平衡释放量范围 Yp (mg/m²)</th>
|
||||
<th>平衡释放量变化率 B (m²/m³)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pollutants" :key="p">
|
||||
<td class="pol">{{ labels[p].zh }}</td>
|
||||
<td><a-input-number v-model:value="form.emissionParams[p].y0" :min="0" :step="0.001" style="width: 100%" /></td>
|
||||
<td><a-input-number v-model:value="form.emissionParams[p].yp" :min="0" :step="0.001" style="width: 100%" /></td>
|
||||
<td><a-input-number v-model:value="form.emissionParams[p].b" :min="0" :step="0.001" style="width: 100%" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
POLLUTANTS,
|
||||
POLLUTANT_LABELS,
|
||||
MATERIAL_CATEGORIES,
|
||||
ENV_GRADES,
|
||||
HEALTH_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 healthGrades = HEALTH_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,
|
||||
healthGrade: 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,
|
||||
healthGrade: props.material.healthGrade,
|
||||
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, healthGrade: 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>
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
<template>
|
||||
<a-modal :open="open" title="Excel 批量入库材料" width="720px" :confirm-loading="saving" @cancel="emit('cancel')">
|
||||
<template #footer>
|
||||
<a-button @click="emit('cancel')">取消</a-button>
|
||||
<a-button @click="downloadTemplate">下载模板</a-button>
|
||||
<a-button type="primary" :disabled="!validRows.length || saving" :loading="saving" @click="submit">
|
||||
导入 {{ validRows.length }} 条
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 14px"
|
||||
message="先「下载模板」按列填好,再选文件导入。必填:材料名称、材料类别。15 个散发参数列(甲醛/TVOC/苯/甲苯/二甲苯 各 Y0/Yp/B),不释放填 0。导入的材料进自建库。"
|
||||
/>
|
||||
|
||||
<a-upload-dragger
|
||||
:before-upload="onFile"
|
||||
:show-upload-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
:disabled="saving"
|
||||
>
|
||||
<p class="ant-upload-drag-icon" style="margin-bottom: 6px"><inbox-outlined style="font-size: 32px; color: #b4232a" /></p>
|
||||
<p>点击或拖拽 Excel 文件到此处</p>
|
||||
<p style="color: #999; font-size: 12px">支持 .xlsx / .xls</p>
|
||||
</a-upload-dragger>
|
||||
|
||||
<div v-if="fileName" class="parse-result">
|
||||
<div class="pr-line">
|
||||
已解析 <b>{{ fileName }}</b>:共 {{ rows.length }} 行,
|
||||
<span style="color: #2f8f5b">有效 {{ validRows.length }}</span>
|
||||
<span v-if="errors.length" style="color: #b4232a">,错误 {{ errors.length }}</span>
|
||||
</div>
|
||||
<div v-if="errors.length" class="errs">
|
||||
<div v-for="(e, i) in errors.slice(0, 8)" :key="i">第 {{ e.row }} 行:{{ e.msg }}</div>
|
||||
<div v-if="errors.length > 8">… 其余 {{ errors.length - 8 }} 条错误</div>
|
||||
</div>
|
||||
<a-table
|
||||
v-if="validRows.length"
|
||||
:columns="previewCols"
|
||||
:data-source="validRows.slice(0, 6)"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
row-key="name"
|
||||
style="margin-top: 10px"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { InboxOutlined } from '@ant-design/icons-vue';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, type Pollutant } from '@airpredict/shared';
|
||||
import { bulkCreateMaterials, type MaterialInput } from '../api/materials';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'ok', n: number): void; (e: 'cancel'): void }>();
|
||||
|
||||
const saving = ref(false);
|
||||
const fileName = ref('');
|
||||
const rows = ref<any[]>([]);
|
||||
const validRows = ref<MaterialInput[]>([]);
|
||||
const errors = ref<{ row: number; msg: string }[]>([]);
|
||||
|
||||
const previewCols = [
|
||||
{ title: '材料名称', dataIndex: 'name' },
|
||||
{ title: '类别', dataIndex: 'category' },
|
||||
{ title: '品牌', dataIndex: 'brand' },
|
||||
{ title: '甲醛Y0', customRender: ({ record }: any) => record.emissionParams.hcho.y0 },
|
||||
{ title: 'TVOC Y0', customRender: ({ record }: any) => record.emissionParams.tvoc.y0 },
|
||||
];
|
||||
|
||||
// 列头:基础 + 5污染物×3
|
||||
const COLS = ['材料名称', '材料类别', '材料品牌', '材料厂家', '材料规格', '环保等级', '健康等级', '用量单位', '排序权重'];
|
||||
const PARAM_COLS: { col: string; p: Pollutant; k: 'y0' | 'yp' | 'b' }[] = [];
|
||||
for (const p of POLLUTANTS) {
|
||||
for (const k of ['y0', 'yp', 'b'] as const) {
|
||||
const suffix = k === 'y0' ? 'Y0' : k === 'yp' ? 'Yp' : 'B';
|
||||
PARAM_COLS.push({ col: `${POLLUTANT_LABELS[p].zh}${suffix}`, p, k });
|
||||
}
|
||||
}
|
||||
|
||||
function num(v: any) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function onFile(file: File) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const wb = XLSX.read(e.target!.result, { type: 'array' });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const json = XLSX.utils.sheet_to_json<any>(ws, { defval: '' });
|
||||
parse(file.name, json);
|
||||
} catch (err: any) {
|
||||
message.error('解析失败:' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
return false; // 阻止 antd 自动上传
|
||||
}
|
||||
|
||||
function parse(name: string, json: any[]) {
|
||||
fileName.value = name;
|
||||
rows.value = json;
|
||||
const valid: MaterialInput[] = [];
|
||||
const errs: { row: number; msg: string }[] = [];
|
||||
json.forEach((r, i) => {
|
||||
const rowNo = i + 2; // 含表头
|
||||
const matName = String(r['材料名称'] ?? '').trim();
|
||||
const category = String(r['材料类别'] ?? '').trim();
|
||||
if (!matName) { errs.push({ row: rowNo, msg: '材料名称为空' }); return; }
|
||||
if (!category) { errs.push({ row: rowNo, msg: '材料类别为空' }); return; }
|
||||
const emissionParams: any = {};
|
||||
for (const p of POLLUTANTS) emissionParams[p] = { y0: 0, yp: 0, b: 0 };
|
||||
for (const pc of PARAM_COLS) emissionParams[pc.p][pc.k] = num(r[pc.col]);
|
||||
valid.push({
|
||||
name: matName,
|
||||
category,
|
||||
brand: String(r['材料品牌'] ?? '').trim() || undefined,
|
||||
manufacturer: String(r['材料厂家'] ?? '').trim() || undefined,
|
||||
spec: String(r['材料规格'] ?? '').trim() || undefined,
|
||||
envGrade: String(r['环保等级'] ?? '').trim() || undefined,
|
||||
healthGrade: String(r['健康等级'] ?? '').trim() || undefined,
|
||||
usageUnit: String(r['用量单位'] ?? '').trim() || 'm²',
|
||||
sortOrder: r['排序权重'] !== '' ? num(r['排序权重']) : 0,
|
||||
emissionParams,
|
||||
});
|
||||
});
|
||||
validRows.value = valid;
|
||||
errors.value = errs;
|
||||
if (!valid.length) message.warning('没有可导入的有效行');
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const headers = [...COLS, ...PARAM_COLS.map((c) => c.col)];
|
||||
const example: any = {
|
||||
材料名称: '示例·多层实木复合地板', 材料类别: '木地板/实木地板', 材料品牌: '某品牌',
|
||||
材料厂家: '某厂家', 材料规格: '12mm', 环保等级: 'E1', 健康等级: 'B', 用量单位: 'm²', 排序权重: 100,
|
||||
};
|
||||
PARAM_COLS.forEach((c) => (example[c.col] = 0));
|
||||
example['甲醛Y0'] = 0.09; example['甲醛Yp'] = 0.4; example['甲醛B'] = 0.47;
|
||||
const ws = XLSX.utils.json_to_sheet([example], { header: headers });
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '材料');
|
||||
XLSX.writeFile(wb, '材料批量导入模板.xlsx');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validRows.value.length) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await bulkCreateMaterials(validRows.value);
|
||||
message.success(`已导入 ${res.created} 条材料`);
|
||||
emit('ok', res.created);
|
||||
reset();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
function reset() { fileName.value = ''; rows.value = []; validRows.value = []; errors.value = []; }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parse-result { margin-top: 14px; }
|
||||
.pr-line { font-size: 13px; }
|
||||
.errs { margin-top: 8px; background: #fff7f5; border: 1px solid #f0d0c8; border-radius: 6px; padding: 8px 12px; font-size: 12px; color: #b4232a; max-height: 120px; overflow: auto; }
|
||||
</style>
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
<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="filters">
|
||||
<span class="lbl">材料库:</span>
|
||||
<a-radio-group v-model:value="scope" size="small" button-style="solid" @change="reload">
|
||||
<a-radio-button value="public">公共库</a-radio-button>
|
||||
<a-radio-button value="self">自建库</a-radio-button>
|
||||
</a-radio-group>
|
||||
<span class="lbl" style="margin-left: 20px">健康等级:</span>
|
||||
<a-select v-model:value="healthGrade" size="small" style="width: 110px" allow-clear placeholder="全部" :get-popup-container="popupContainer" @change="reload">
|
||||
<a-select-option v-for="g in healthGrades" :key="g" :value="g">{{ g }} 级</a-select-option>
|
||||
</a-select>
|
||||
<span class="lbl" style="margin-left: 20px">环保等级:</span>
|
||||
<a-select v-model:value="envGrade" size="small" style="width: 100px" allow-clear placeholder="全部" :get-popup-container="popupContainer" @change="reload">
|
||||
<a-select-option v-for="g in envGrades" :key="g" :value="g">{{ g }}</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<!-- 级联:大类 -->
|
||||
<div class="cascade-row">
|
||||
<span class="cascade-lbl">大类</span>
|
||||
<a-tag
|
||||
v-for="g in tree"
|
||||
:key="g.major"
|
||||
:color="major === g.major ? '#b4232a' : 'default'"
|
||||
class="cas-tag"
|
||||
@click="selectMajor(g.major)"
|
||||
>{{ g.major }}</a-tag>
|
||||
</div>
|
||||
<!-- 级联:子类(可多选组合) -->
|
||||
<div class="cascade-row" v-if="currentSubs.length">
|
||||
<span class="cascade-lbl">子类</span>
|
||||
<a-tag
|
||||
:color="!subs.length ? '#b4232a' : 'default'"
|
||||
class="cas-tag"
|
||||
@click="clearSubs"
|
||||
>全部</a-tag>
|
||||
<a-tag
|
||||
v-for="s in currentSubs"
|
||||
:key="s"
|
||||
:color="subs.includes(s) ? '#b4232a' : 'default'"
|
||||
class="cas-tag"
|
||||
@click="toggleSub(s)"
|
||||
>
|
||||
<CheckOutlined v-if="subs.includes(s)" /> {{ s }}
|
||||
</a-tag>
|
||||
<span v-if="subs.length" class="multi-tip">已选 {{ subs.length }} 个子类(组合)</span>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 10px 0" />
|
||||
|
||||
<!-- 当前类别材料 -->
|
||||
<div class="list-head">
|
||||
<span>{{ major }}<template v-if="subs.length"> / {{ subs.join('、') }}</template> · 共 {{ list.length }} 种</span>
|
||||
<a-button type="primary" size="small" :disabled="checkedCount === 0" @click="addSelected">
|
||||
批量添加所选({{ checkedCount }})
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 380 }"
|
||||
>
|
||||
<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 === 'healthGrade'">
|
||||
<a-tag v-if="record.healthGrade" :color="healthColor(record.healthGrade)">{{ record.healthGrade }}</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>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CheckOutlined } from '@ant-design/icons-vue';
|
||||
import { MATERIAL_CATEGORIES, HEALTH_GRADES, ENV_GRADES } 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 healthGrades = HEALTH_GRADES;
|
||||
const envGrades = ENV_GRADES;
|
||||
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const healthGrade = ref<string | undefined>(undefined);
|
||||
const envGrade = ref<string | undefined>(undefined);
|
||||
const major = ref<string>('');
|
||||
const subs = ref<string[]>([]); // 多选子类(组合筛选)
|
||||
const fullList = ref<Material[]>([]); // 当前大类全部材料(服务端按健康/环保筛过)
|
||||
const loading = ref(false);
|
||||
|
||||
// 按已选子类做客户端组合过滤;未选=显示整个大类
|
||||
const list = computed(() => {
|
||||
if (!subs.value.length) return fullList.value;
|
||||
const set = new Set(subs.value.map((s) => `${major.value}/${s}`));
|
||||
return fullList.value.filter((m) => set.has(m.category));
|
||||
});
|
||||
const rowState = reactive<Record<string, { checked: boolean; area: number | null }>>({});
|
||||
|
||||
const picked = computed(() => new Set(props.existingIds));
|
||||
|
||||
// 让下拉渲染进弹窗内部,避免被弹窗 z-index 盖住(否则下拉看起来是空的)
|
||||
const popupContainer = (trigger: HTMLElement) =>
|
||||
(trigger.closest('.ant-modal-content') as HTMLElement) || document.body;
|
||||
|
||||
// 大类 -> 子类[]
|
||||
const tree = computed(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const c of MATERIAL_CATEGORIES) {
|
||||
const [m, s] = c.split('/');
|
||||
if (!map.has(m)) map.set(m, []);
|
||||
if (s && !map.get(m)!.includes(s)) map.get(m)!.push(s);
|
||||
}
|
||||
return [...map.entries()].map(([m, subs]) => ({ major: m, subs }));
|
||||
});
|
||||
const currentSubs = computed(() => tree.value.find((g) => g.major === major.value)?.subs || []);
|
||||
|
||||
const columns = [
|
||||
{ title: '', key: 'check', width: 40 },
|
||||
{ title: '材料ID', dataIndex: 'id' },
|
||||
{ title: '材料名称', dataIndex: 'name' },
|
||||
{ title: '品牌', dataIndex: 'brand' },
|
||||
{ title: '规格', dataIndex: 'spec' },
|
||||
{ title: '环保', key: 'envGrade', width: 64 },
|
||||
{ title: '健康', key: 'healthGrade', width: 64 },
|
||||
{ title: '使用量(面积)', key: 'area', width: 150 },
|
||||
];
|
||||
|
||||
const checkedCount = computed(() => Object.values(rowState).filter((r) => r.checked).length);
|
||||
|
||||
function healthColor(g: string) {
|
||||
return { A: 'green', B: 'blue', C: 'orange' }[g] || 'default';
|
||||
}
|
||||
|
||||
function selectMajor(m: string) {
|
||||
major.value = m;
|
||||
subs.value = [];
|
||||
reload();
|
||||
}
|
||||
function toggleSub(s: string) {
|
||||
const i = subs.value.indexOf(s);
|
||||
if (i >= 0) subs.value.splice(i, 1);
|
||||
else subs.value.push(s);
|
||||
}
|
||||
function clearSubs() {
|
||||
subs.value = [];
|
||||
}
|
||||
|
||||
// 切大类/健康/环保/库时重新拉整个大类的材料(子类组合在客户端过滤)
|
||||
async function reload() {
|
||||
if (!major.value) return;
|
||||
loading.value = true;
|
||||
for (const k of Object.keys(rowState)) delete rowState[k];
|
||||
try {
|
||||
const res = await listMaterials({
|
||||
category: major.value,
|
||||
healthGrade: healthGrade.value,
|
||||
envGrade: envGrade.value,
|
||||
scope: scope.value,
|
||||
page: 1,
|
||||
pageSize: 300,
|
||||
});
|
||||
fullList.value = res.items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(id: string, checked: boolean) {
|
||||
rowState[id] = { checked, area: rowState[id]?.area ?? null };
|
||||
}
|
||||
function setArea(id: string, v: number | null) {
|
||||
rowState[id] = { checked: !!(v && v > 0) || !!rowState[id]?.checked, area: v };
|
||||
}
|
||||
|
||||
function addSelected() {
|
||||
const items = list.value
|
||||
.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} 种材料`);
|
||||
for (const k of Object.keys(rowState)) delete rowState[k];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
if (!major.value) major.value = tree.value[0]?.major || '';
|
||||
subs.value = [];
|
||||
reload();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters { display: flex; align-items: center; margin-bottom: 12px; }
|
||||
.filters .lbl { color: #555; }
|
||||
.cascade-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.cascade-lbl { color: #999; font-size: 12px; width: 32px; flex-shrink: 0; }
|
||||
.cas-tag { cursor: pointer; user-select: none; margin: 0; }
|
||||
.multi-tip { color: #b4232a; font-size: 12px; margin-left: 8px; }
|
||||
.list-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; color: #555; }
|
||||
.added { color: #999; }
|
||||
</style>
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<template>
|
||||
<a-config-provider :theme="{ token: { colorPrimary: '#1f7a5a' } }">
|
||||
<a-modal :open="open" title="手机号快速预测" :footer="null" width="400px" @cancel="emit('cancel')">
|
||||
<p class="tip">登录后即可免费预测甲醛 / TVOC,无需密码,验证码登录</p>
|
||||
<a-form layout="vertical" @submit.prevent="onVerify">
|
||||
<a-form-item label="手机号">
|
||||
<a-input v-model:value="phone" size="large" placeholder="请输入手机号" :maxlength="11" />
|
||||
</a-form-item>
|
||||
<a-form-item label="验证码">
|
||||
<div class="code-row">
|
||||
<a-input v-model:value="code" size="large" placeholder="6 位验证码" :maxlength="6" />
|
||||
<a-button size="large" :disabled="countdown > 0 || !validPhone" :loading="sending" @click="onSend">
|
||||
{{ countdown > 0 ? countdown + 's' : '发送验证码' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-alert v-if="devCode" type="info" show-icon :message="`开发模式验证码:${devCode}`" style="margin-bottom: 12px" />
|
||||
<a-button type="primary" size="large" block :loading="verifying" :disabled="!validPhone || !code" @click="onVerify">
|
||||
验证并开始预测
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { sendSms, verifySms } from '../api/sms';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'ok'): void; (e: 'cancel'): void }>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const phone = ref('');
|
||||
const code = ref('');
|
||||
const devCode = ref('');
|
||||
const sending = ref(false);
|
||||
const verifying = ref(false);
|
||||
const countdown = ref(0);
|
||||
|
||||
const validPhone = computed(() => /^1\d{10}$/.test(phone.value));
|
||||
|
||||
async function onSend() {
|
||||
sending.value = true;
|
||||
try {
|
||||
const res = await sendSms(phone.value);
|
||||
if (res.devCode) {
|
||||
devCode.value = res.devCode;
|
||||
code.value = res.devCode; // 开发模式自动填充,方便测试
|
||||
}
|
||||
message.success('验证码已发送');
|
||||
countdown.value = 60;
|
||||
const t = setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) clearInterval(t);
|
||||
}, 1000);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onVerify() {
|
||||
if (!validPhone.value || !code.value) return;
|
||||
verifying.value = true;
|
||||
try {
|
||||
const res = await verifySms(phone.value, code.value);
|
||||
auth.setSession(res.token, res.org);
|
||||
message.success('登录成功');
|
||||
emit('ok');
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tip { color: #888; font-size: 13px; margin: 0 0 16px; }
|
||||
.code-row { display: flex; gap: 10px; }
|
||||
.code-row .ant-input { flex: 1; }
|
||||
</style>
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<template>
|
||||
<a-modal :open="open" :title="`污染源溯源 · ${space?.name || ''}`" :footer="null" width="760px" @cancel="emit('cancel')">
|
||||
<template v-if="space">
|
||||
<a-tabs v-model:activeKey="active">
|
||||
<a-tab-pane v-for="p in pollutants" :key="p">
|
||||
<template #tab>
|
||||
{{ labels[p].zh }}
|
||||
<a-tag v-if="exceeded(p)" color="red" style="margin-left:4px">超标</a-tag>
|
||||
<a-tag v-else color="green" style="margin-left:4px">达标</a-tag>
|
||||
</template>
|
||||
|
||||
<div class="conc-line">
|
||||
预测浓度
|
||||
<b :class="{ over: exceeded(p) }">{{ fmt(conc(p)) }} mg/m³</b>
|
||||
<span class="limit">限值 {{ limits[p] }} mg/m³({{ space.standard }})</span>
|
||||
</div>
|
||||
|
||||
<div class="bars">
|
||||
<div class="bar-row" v-for="(c, i) in ranked(p)" :key="c.id">
|
||||
<div class="nm">
|
||||
<span class="rk" :style="{ background: barColor(i) }">{{ i + 1 }}</span>{{ c.name }}
|
||||
<a-tag v-if="isSource(p, c.id)" color="red" size="small">污染源</a-tag>
|
||||
</div>
|
||||
<div class="track"><div class="fill" :style="{ width: Math.max(2, c.rate / maxRate(p) * 100) + '%', background: barColor(i) }" /></div>
|
||||
<div class="val">{{ (c.rate * 100).toFixed(1) }}%</div>
|
||||
</div>
|
||||
<div v-if="!ranked(p).length" class="muted">该污染物无材料释放</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exceeded(p)" class="sugg">
|
||||
💡 <b>{{ labels[p].zh }}超标</b>。主要污染源:<b>{{ sourceNames(p) }}</b>(累计贡献 {{ sourceCumPct(p) }}%)。建议优先更换/减少这些材料,或提高通风换气率。
|
||||
</div>
|
||||
<div v-else class="sugg ok">✓ {{ labels[p].zh }}达标,余量 {{ marginPct(p) }}%。</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||||
import type { SpaceRow } from '../api/projects';
|
||||
|
||||
const props = defineProps<{ open: boolean; space?: SpaceRow | null }>();
|
||||
const emit = defineEmits<{ (e: 'cancel'): void }>();
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const active = ref<Pollutant>('hcho');
|
||||
watch(() => props.open, (o) => { if (o) active.value = 'hcho'; });
|
||||
|
||||
const limits = computed(() => STANDARD_LIMITS[(props.space?.standard as StandardCode) || 'GB50325-2020']);
|
||||
const fmt = (n: number) => Number(n ?? 0).toFixed(3);
|
||||
const conc = (p: Pollutant) => props.space?.predictedConc?.[p] ?? 0;
|
||||
const exceeded = (p: Pollutant) => conc(p) > (limits.value[p] ?? Infinity);
|
||||
const marginPct = (p: Pollutant) => Math.max(0, Math.round((1 - conc(p) / (limits.value[p] || 1)) * 100));
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
function ranked(p: Pollutant): Row[] {
|
||||
const ms = props.space?.materials || [];
|
||||
return ms
|
||||
.map((m) => ({ id: m.materialId, name: m.material?.name || m.materialId, rate: m.contributionRate?.[p] ?? 0 }))
|
||||
.filter((r) => r.rate > 0)
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}
|
||||
const maxRate = (p: Pollutant) => (ranked(p)[0]?.rate || 1);
|
||||
function sources(p: Pollutant): Row[] {
|
||||
const r = ranked(p);
|
||||
const out: Row[] = [];
|
||||
let cum = 0;
|
||||
for (const x of r) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
|
||||
return out;
|
||||
}
|
||||
const isSource = (p: Pollutant, id: string) => exceeded(p) && sources(p).some((s) => s.id === id);
|
||||
const sourceNames = (p: Pollutant) => sources(p).map((s) => s.name).join('、');
|
||||
const sourceCumPct = (p: Pollutant) => Math.round(sources(p).reduce((s, x) => s + x.rate, 0) * 100);
|
||||
const barColor = (i: number) => (i === 0 ? '#b4232a' : i === 1 ? '#ca8326' : '#1f7a5a');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conc-line { margin: 4px 0 16px; color: #555; }
|
||||
.conc-line b { font-size: 18px; margin: 0 8px; }
|
||||
.conc-line b.over { color: #b4232a; }
|
||||
.conc-line .limit { color: #999; font-size: 13px; }
|
||||
.bars { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bar-row { display: grid; grid-template-columns: 220px 1fr 56px; align-items: center; gap: 12px; }
|
||||
.nm { font-size: 13px; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rk { display: inline-flex; width: 18px; height: 18px; border-radius: 5px; color: #fff; font-size: 11px; font-weight: 700; align-items: center; justify-content: center; }
|
||||
.track { height: 16px; background: #f0f0f0; border-radius: 5px; overflow: hidden; }
|
||||
.fill { height: 100%; border-radius: 5px; }
|
||||
.val { text-align: right; font-weight: 700; font-size: 13px; }
|
||||
.sugg { margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #fff7f5; border: 1px solid #f0d0c8; font-size: 13px; line-height: 1.6; }
|
||||
.sugg.ok { background: #f3faf6; border-color: #cce8d8; }
|
||||
.muted { color: #aaa; }
|
||||
</style>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// 中国省/市级联数据(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('新疆维吾尔自治区', ['乌鲁木齐市', '克拉玛依市', '吐鲁番市', '哈密市']),
|
||||
];
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
// C 端污染源识别的预设样板间。材料指向真实材料库(PM2000000x,官方算例真实参数)。
|
||||
export interface PresetMat {
|
||||
id: string;
|
||||
a: number; // 使用面积 m²
|
||||
}
|
||||
export interface PresetRoom {
|
||||
id: string;
|
||||
name: string;
|
||||
area: number;
|
||||
height: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
ventilationRate: number;
|
||||
materials: PresetMat[];
|
||||
}
|
||||
|
||||
export const PRESET_ROOMS: PresetRoom[] = [
|
||||
{
|
||||
id: 'demo',
|
||||
name: '标准间(官方算例)',
|
||||
area: 19.8,
|
||||
height: 3,
|
||||
temperature: 22,
|
||||
humidity: 45,
|
||||
ventilationRate: 0.5,
|
||||
materials: [
|
||||
{ id: 'PM20000001', a: 20 },
|
||||
{ id: 'PM20000002', a: 1.8 },
|
||||
{ id: 'PM20000003', a: 20 },
|
||||
{ id: 'PM20000004', a: 51.6 },
|
||||
{ id: 'PM20000005', a: 3.6 },
|
||||
{ id: 'PM20000006', a: 17.4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zw',
|
||||
name: '主卧',
|
||||
area: 18,
|
||||
height: 2.7,
|
||||
temperature: 26,
|
||||
humidity: 50,
|
||||
ventilationRate: 0.5,
|
||||
materials: [
|
||||
{ id: 'PM20000001', a: 18 },
|
||||
{ id: 'PM20000002', a: 1.6 },
|
||||
{ id: 'PM20000006', a: 25 },
|
||||
{ id: 'PM20000004', a: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kt',
|
||||
name: '客厅',
|
||||
area: 30,
|
||||
height: 2.85,
|
||||
temperature: 26,
|
||||
humidity: 50,
|
||||
ventilationRate: 0.6,
|
||||
materials: [
|
||||
{ id: 'PM20000001', a: 30 },
|
||||
{ id: 'PM20000004', a: 55 },
|
||||
{ id: 'PM20000005', a: 6 },
|
||||
{ id: 'PM20000006', a: 14 },
|
||||
{ id: 'PM20000003', a: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'etf',
|
||||
name: '儿童房',
|
||||
area: 14,
|
||||
height: 2.7,
|
||||
temperature: 26,
|
||||
humidity: 55,
|
||||
ventilationRate: 0.5,
|
||||
materials: [
|
||||
{ id: 'PM20000001', a: 14 },
|
||||
{ id: 'PM20000006', a: 32 },
|
||||
{ id: 'PM20000004', a: 30 },
|
||||
{ id: 'PM20000002', a: 1.4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
<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="dashboard">总览看板</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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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');
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<template>
|
||||
<div class="dash-page">
|
||||
<div class="dash-bar">
|
||||
<a @click="router.push('/home')">← 返回工作台</a>
|
||||
<span class="dash-bar-tt">专业看板 · 工程污染概览</span>
|
||||
<span class="dash-bar-note">演示数据(静态),接入后端聚合后实时</span>
|
||||
</div>
|
||||
<div class="dash-host"><div ref="host" style="width:100%;height:100%"></div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const host = ref<HTMLElement>();
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('load fail ' + src));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const w = window as any;
|
||||
if (!w.renderDashboard) {
|
||||
await loadScript('/dashboard/data.js');
|
||||
await loadScript('/dashboard/charts.js');
|
||||
await loadScript('/dashboard/dashboard.js');
|
||||
}
|
||||
if (host.value) host.value.innerHTML = w.renderDashboard('warm', { compliance: 'donut', pollutant: 'ring' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<style src="../styles/dashboard.css"></style>
|
||||
<style scoped>
|
||||
.dash-page { height: 100vh; display: flex; flex-direction: column; background: #f4f0e7; }
|
||||
.dash-bar { flex: 0 0 auto; display: flex; align-items: center; gap: 16px; padding: 10px 20px; background: #fffdf8; border-bottom: 1px solid #e8e0d0; }
|
||||
.dash-bar a { color: #1f7a5a; cursor: pointer; font-weight: 600; }
|
||||
.dash-bar-tt { font-weight: 700; }
|
||||
.dash-bar-note { color: #a89c86; font-size: 12px; margin-left: auto; }
|
||||
.dash-host { flex: 1; min-height: 0; overflow: auto; }
|
||||
.dash-host :deep(.dash) { min-width: 1280px; }
|
||||
</style>
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<template>
|
||||
<a-card title="继续配置预测">
|
||||
<template #extra><span class="hint">仅显示尚未生成预测报告的草稿项目</span></template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="data.items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
|
||||
<template v-else-if="column.key === 'area'">{{ record.area }}m²</template>
|
||||
<template v-else-if="column.key === 'updatedAt'">{{ fmt(record.updatedAt) }}</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="goConfig(record)">继续配置</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该草稿?" @confirm="onDelete(record)">
|
||||
<a style="color: #b4232a">删除</a>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { listProjects, deleteProject, type ProjectRow } from '../api/projects';
|
||||
import type { Paged } from '../api/materials';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const data = ref<Paged<ProjectRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '项目ID', dataIndex: 'id' },
|
||||
{ title: '工程名称', dataIndex: 'name' },
|
||||
{ title: '项目类型', dataIndex: 'type' },
|
||||
{ title: '所在城市', key: 'city' },
|
||||
{ title: '建筑面积', key: 'area' },
|
||||
{ title: '空间数', dataIndex: 'spaceCount' },
|
||||
{ title: '最近更新', key: 'updatedAt' },
|
||||
{ title: '操作', key: 'op', width: 160 },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: data.value.page,
|
||||
pageSize: data.value.pageSize,
|
||||
total: data.value.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
}));
|
||||
|
||||
function fmt(s: string) { return new Date(s).toLocaleString(); }
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listProjects({ unfinished: 'true', page: page.value, pageSize: 10 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
function goConfig(r: ProjectRow) { router.push({ name: 'predict', params: { id: r.id } }); }
|
||||
async function onDelete(r: ProjectRow) { await deleteProject(r.id); message.success('已删除'); reload(); }
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: #999; font-size: 13px; }
|
||||
</style>
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<template>
|
||||
<a-card title="历史预测记录">
|
||||
<a-form layout="inline" class="filters">
|
||||
<a-form-item label="工程名称"><a-input v-model:value="q.name" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="项目类型">
|
||||
<a-select v-model:value="q.type" allow-clear style="width: 120px" @change="reload">
|
||||
<a-select-option v-for="t in projectTypes" :key="t" :value="t">{{ t }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预测评级">
|
||||
<a-select v-model:value="q.rating" allow-clear style="width: 90px" @change="reload">
|
||||
<a-select-option v-for="r in ratings" :key="r" :value="r">{{ r }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="reset">重 置</a-button>
|
||||
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table :columns="columns" :data-source="data.items" :loading="loading" :pagination="pagination" row-key="id" size="middle" @change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
|
||||
<template v-else-if="column.key === 'area'">{{ record.area }}m²</template>
|
||||
<template v-else-if="column.key === 'rating'">
|
||||
<a-tag :color="ratingColor(record.rating)">{{ record.rating || '-' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'time'">{{ fmt(record.reportGeneratedAt) }}</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="goDetail(record)">详情</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="goReport(record)">查看报告</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="onReuse(record)">复用</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PROJECT_TYPES, PREDICTION_RATINGS } from '@airpredict/shared';
|
||||
import { listProjects, duplicateProject, type ProjectRow } from '../api/projects';
|
||||
import type { Paged } from '../api/materials';
|
||||
|
||||
const router = useRouter();
|
||||
const projectTypes = PROJECT_TYPES;
|
||||
const ratings = PREDICTION_RATINGS;
|
||||
const loading = ref(false);
|
||||
const data = ref<Paged<ProjectRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||
const q = reactive<any>({ name: '', type: undefined, rating: undefined });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '项目ID', dataIndex: 'id' },
|
||||
{ title: '工程名称', dataIndex: 'name' },
|
||||
{ title: '项目类型', dataIndex: 'type' },
|
||||
{ title: '所在城市', key: 'city' },
|
||||
{ title: '建筑面积', key: 'area' },
|
||||
{ title: '空间数', dataIndex: 'spaceCount' },
|
||||
{ title: '预测评级', key: 'rating' },
|
||||
{ title: '生成报告时间', key: 'time' },
|
||||
{ title: '操作', key: 'op', width: 180 },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: data.value.page, pageSize: data.value.pageSize, total: data.value.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
}));
|
||||
|
||||
function fmt(s?: string) { return s ? new Date(s).toLocaleString() : '-'; }
|
||||
function ratingColor(r?: string) { return ({ A: 'green', B: 'blue', C: 'orange', D: 'red' } as any)[r || ''] || 'default'; }
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listProjects({ status: 'report_generated', ...q, page: page.value, pageSize: 10 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
function reset() { Object.keys(q).forEach((k) => (q[k] = undefined)); page.value = 1; reload(); }
|
||||
|
||||
function goDetail(r: ProjectRow) { router.push({ name: 'predict', params: { id: r.id } }); }
|
||||
function goReport(r: ProjectRow) { router.push({ name: 'report', params: { id: r.id } }); }
|
||||
async function onReuse(r: ProjectRow) {
|
||||
const p = await duplicateProject(r.id);
|
||||
message.success('已复用为新草稿');
|
||||
router.push({ name: 'predict', params: { id: p.id } });
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters { margin-bottom: 16px; }
|
||||
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
|
||||
</style>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<template>
|
||||
<a-card>
|
||||
<div class="hi">👋 欢迎,{{ auth.org?.name || '一品健康空间' }}</div>
|
||||
<a-divider />
|
||||
<div class="group-title">预测</div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6" 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" />
|
||||
<ImportProjectModal :open="importOpen" @created="onImported" @cancel="importOpen = 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 ImportProjectModal from '../components/ImportProjectModal.vue';
|
||||
import type { ProjectDetail } from '../api/projects';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const createOpen = ref(false);
|
||||
const importOpen = ref(false);
|
||||
|
||||
const predictCards = [
|
||||
{ title: '新建项目预测', desc: '从头配置项目、空间、材料进行预测', action: () => (createOpen.value = true) },
|
||||
{ title: '快速导入项目', desc: '根据模板导入后调整配置预测', action: () => (importOpen.value = true) },
|
||||
{ title: '继续配置预测', desc: '继续已保存、未提交的配置', action: () => router.push({ name: 'drafts' }) },
|
||||
{ title: '污染源识别 · 快速溯源', desc: '单空间快速预测,溯源主要污染材料', action: () => router.push('/source') },
|
||||
];
|
||||
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 } });
|
||||
}
|
||||
function onImported(id: string) {
|
||||
importOpen.value = false;
|
||||
router.push({ name: 'predict', params: { 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>
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
<template>
|
||||
<div class="lp">
|
||||
<!-- NAV -->
|
||||
<header class="nav">
|
||||
<div class="nav-in">
|
||||
<a class="brand" @click="scrollTop">
|
||||
<span class="brand-logo"><LeafIcon /></span>
|
||||
<span><span class="brand-tt">污染物预测系统</span><br><span class="brand-sub">INDOOR AIR · 装修污染</span></span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a @click="scrollTo('news')">资讯科普</a>
|
||||
<a @click="scrollTo('cases')">治理案例</a>
|
||||
<a @click="scrollTo('how')">如何使用</a>
|
||||
<a @click="openPredict">污染源识别</a>
|
||||
<a @click="goPro">专业看板</a>
|
||||
</nav>
|
||||
<span class="nav-sp"></span>
|
||||
<a class="btn btn-primary" @click="openPredict">免费试算<ArrowIcon /></a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<span class="eyebrow"><span class="pip"></span>对照 GB/T 18883-2022 与 GB 50325-2020 双国标</span>
|
||||
<h1>装修住得安心,<br>从一次<em>污染预测</em>开始</h1>
|
||||
<p class="hero-lead">输入房间、环境与所用材料,系统用稳态质量平衡公式预测甲醛、苯、TVOC 等 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议。</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary btn-lg" @click="openPredict">免费预测甲醛 · TVOC<ArrowIcon /></a>
|
||||
<a class="btn btn-lg" @click="scrollTo('cases')">查看治理案例</a>
|
||||
</div>
|
||||
<div class="hero-tags">
|
||||
<span class="hero-tag"><CheckIcon />无需上门,先估后测</span>
|
||||
<span class="hero-tag"><CheckIcon />公式可溯源</span>
|
||||
<span class="hero-tag"><CheckIcon />材料数据库支撑</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<div class="img-ph hero-img">室内 / 装修实景照片</div>
|
||||
<div class="hero-badge"><b>6 项</b><span>污染物预测</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- STATS -->
|
||||
<section class="stats">
|
||||
<div class="stats-in">
|
||||
<div class="stat" v-for="s in stats" :key="s.t"><b>{{ s.n }}</b><span>{{ s.t }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NEWS CAROUSEL -->
|
||||
<section class="sec" id="news">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<div><div class="sec-tag">资讯 · 科普</div><h2 class="sec-h">读懂装修污染,先把知识装进脑子</h2></div>
|
||||
<a class="btn" @click="openPredict">全部文章</a>
|
||||
</div>
|
||||
<div class="carousel" @mouseenter="stop" @mouseleave="start">
|
||||
<div class="cviewport">
|
||||
<div class="ctrack" :style="{ transform: `translateX(${-cur * 100}%)` }">
|
||||
<article class="cslide" v-for="(n, i) in news" :key="i">
|
||||
<div class="cslide-img"><span class="cslide-tag">{{ n.tag }}</span><div class="img-ph">资讯配图</div></div>
|
||||
<div class="cslide-body">
|
||||
<div class="cslide-date">{{ n.date }}</div>
|
||||
<h3>{{ n.title }}</h3>
|
||||
<p>{{ n.desc }}</p>
|
||||
<span class="cslide-more">阅读全文<ArrowIcon /></span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<button class="cnav prev" @click="prev"><ChevronIcon dir="left" /></button>
|
||||
<button class="cnav next" @click="next"><ChevronIcon dir="right" /></button>
|
||||
</div>
|
||||
<div class="cdots">
|
||||
<button v-for="(n, i) in news" :key="i" class="cdot" :class="{ on: i === cur }" @click="go(i)"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CASES -->
|
||||
<section class="sec" id="cases" style="background:var(--bg2);">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<div><div class="sec-tag">治理案例</div><h2 class="sec-h">预测 → 溯源 → 整改,看得见的下降</h2><p class="sec-sub">真实流程演示:从预测超标,到锁定主要污染材料,再到整改复测达标。</p></div>
|
||||
</div>
|
||||
<div class="cases-grid">
|
||||
<article class="case" v-for="(c, i) in cases" :key="i">
|
||||
<div class="img-ph">案例实景图</div>
|
||||
<div class="case-b">
|
||||
<div class="case-type">{{ c.type }}</div>
|
||||
<h4>{{ c.name }}</h4>
|
||||
<div class="case-meta">{{ c.meta }}</div>
|
||||
<div class="ba">
|
||||
<div class="ba-row"><span class="k">治理前</span><div class="ba-track"><div class="ba-fill" :style="{ width: c.w1 + '%', background: 'var(--bad)' }"></div></div><span class="v" style="color:var(--bad)">{{ c.v1 }}</span></div>
|
||||
<div class="ba-row"><span class="k">治理后</span><div class="ba-track"><div class="ba-fill" :style="{ width: c.w2 + '%', background: 'var(--good)' }"></div></div><span class="v" style="color:var(--good)">{{ c.v2 }}</span></div>
|
||||
</div>
|
||||
<div class="case-foot"><span class="chip chip-good">{{ c.chip }}</span><span class="lk">查看溯源<ArrowIcon small /></span></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW -->
|
||||
<section class="sec" id="how">
|
||||
<div class="wrap">
|
||||
<div class="sec-head"><div><div class="sec-tag">如何使用</div><h2 class="sec-h">三步,得到一份可溯源的预测报告</h2></div></div>
|
||||
<div class="steps">
|
||||
<div class="step" v-for="(s, i) in steps" :key="i">
|
||||
<div class="step-n">{{ i + 1 }}</div><h4>{{ s.h }}</h4><p>{{ s.p }}</p>
|
||||
<div class="step-ar" v-if="i < steps.length - 1"><ArrowIcon /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="sec" style="padding-top:0;">
|
||||
<div class="wrap">
|
||||
<div class="ctaband">
|
||||
<div style="position:relative;z-index:2;">
|
||||
<h2>现在就免费预测<br>你家会不会甲醛超标</h2>
|
||||
<p>无需上门,输入材料即可估算。先估后测,把检测的钱花在刀刃上。</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;position:relative;z-index:2;">
|
||||
<a class="btn btn-primary btn-lg" @click="openPredict">免费开始预测</a>
|
||||
<a class="btn btn-lg" @click="goPro">查看专业看板</a>
|
||||
</div>
|
||||
<div class="deco"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="footer-in">
|
||||
<div class="brand">
|
||||
<span class="brand-logo"><LeafIcon /></span>
|
||||
<span class="brand-tt">室内装修工程污染物预测系统</span>
|
||||
</div>
|
||||
<div>依据 GB/T 18883-2022 · GB 50325-2020 · 预测结果仅供参考,以 CMA 检测为准</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<PhoneAuthModal :open="authOpen" @ok="onAuthed" @cancel="authOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PhoneAuthModal from '../components/PhoneAuthModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 内联图标
|
||||
const LeafIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M4 20c10 2 16-4 16-14 0 0-8-2-12 2-3 3-3 7-1 9 3-4 6-6 9-7' })]);
|
||||
const ArrowIcon = (props: any) => h('svg', { viewBox: '0 0 24 24', width: props.small ? 15 : undefined, height: props.small ? 15 : undefined, fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12h14M13 6l6 6-6 6' })]);
|
||||
const CheckIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M20 6L9 17l-5-5' })]);
|
||||
const ChevronIcon = (props: any) => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.4', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: props.dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7' })]);
|
||||
|
||||
const stats = [
|
||||
{ n: '28+', t: '在管装修项目' },
|
||||
{ n: '2,400+', t: '累计预测次数' },
|
||||
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
|
||||
{ n: '2 部', t: '国标依据 · 18883 / 50325' },
|
||||
];
|
||||
const news = [
|
||||
{ tag: '政策解读', date: '专栏 · 2026.05', title: '两大国标怎么读?GB/T 18883 与 GB 50325 的差别', desc: '一个是"住进去后"的室内空气质量标准,一个是"交工验收时"的工程控制标准——限值与采样条件并不相同,看懂它们才能判断房子到底达不达标。' },
|
||||
{ tag: '科普', date: '专栏 · 2026.04', title: '新装住宅的甲醛,为什么能持续释放 3–15 年?', desc: '人造板里的脲醛树脂胶会缓慢分解释放甲醛,释放周期长、受温湿度影响大。短期通风只能降一时浓度,真正的关键在源头材料的选择。' },
|
||||
{ tag: '指南', date: '专栏 · 2026.03', title: '夏天为什么更容易超标?温度与释放速率', desc: '温度每升高若干度,材料的甲醛释放速率会明显增大。这也是"冬天测达标、夏天又超标"的原因。预测时把环境温湿度纳入计算,结果才靠谱。' },
|
||||
{ tag: '方法', date: '专栏 · 2026.02', title: '先预测,再决定要不要做 CMA 检测', desc: '上门检测有成本。用本系统先做一次免费预测、定位高风险房间与主要污染材料,再有针对性地安排第三方 CMA 检测,省钱也更有的放矢。' },
|
||||
];
|
||||
const cases = [
|
||||
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' },
|
||||
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07', chip: '甲醛达标' },
|
||||
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46', chip: 'TVOC达标' },
|
||||
];
|
||||
const steps = [
|
||||
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
|
||||
{ h: '公式计算浓度', p: '按稳态质量平衡 C = Σ(EFᵢ·Aᵢ)/(n·V) 计算 6 项污染物浓度,对照国标判定达标。' },
|
||||
{ h: '溯源与整改建议', p: '若超标,公式溯源出贡献最大的材料并排序,给出通风或换材的可行整改方案。' },
|
||||
];
|
||||
|
||||
// 轮播
|
||||
const cur = ref(0);
|
||||
let timer: any = null;
|
||||
function go(n: number) { cur.value = (n + news.length) % news.length; }
|
||||
function next() { go(cur.value + 1); start(); }
|
||||
function prev() { go(cur.value - 1); start(); }
|
||||
function start() { stop(); timer = setInterval(() => go(cur.value + 1), 5500); }
|
||||
function stop() { if (timer) clearInterval(timer); timer = null; }
|
||||
onMounted(start);
|
||||
onBeforeUnmount(stop);
|
||||
|
||||
function scrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
|
||||
function scrollTo(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); }
|
||||
function goPro() { router.push(localStorage.getItem('token') ? '/dashboard' : '/login'); }
|
||||
|
||||
// 预测入口 → 手机注册
|
||||
const authOpen = ref(false);
|
||||
function openPredict() {
|
||||
if (localStorage.getItem('token')) router.push('/source');
|
||||
else authOpen.value = true;
|
||||
}
|
||||
function onAuthed() {
|
||||
authOpen.value = false;
|
||||
router.push('/source');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="../styles/landing.css"></style>
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
<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 label="健康等级">
|
||||
<a-select v-model:value="q.healthGrade" allow-clear style="width: 100px" placeholder="全部" @change="reload">
|
||||
<a-select-option value="A">A 级</a-select-option>
|
||||
<a-select-option value="B">B 级</a-select-option>
|
||||
<a-select-option value="C">C 级</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>
|
||||
<div v-if="scope === 'self'" style="display: flex; gap: 8px">
|
||||
<a-button @click="importOpen = true">📥 Excel 批量导入</a-button>
|
||||
<a-button type="primary" @click="openCreate">+ 新建材料</a-button>
|
||||
</div>
|
||||
</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 === 'healthGrade'">
|
||||
<a-tag v-if="record.healthGrade" :color="healthColor(record.healthGrade)">{{ record.healthGrade }} 级</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" />
|
||||
<MaterialImportModal :open="importOpen" @ok="onImported" @cancel="importOpen = 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';
|
||||
import MaterialImportModal from '../components/MaterialImportModal.vue';
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const healthColor = (g: string) => ({ A: 'green', B: 'blue', C: 'orange' } as Record<string, string>)[g] || 'default';
|
||||
|
||||
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, healthGrade: 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: 'healthGrade' },
|
||||
{ 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();
|
||||
}
|
||||
|
||||
const importOpen = ref(false);
|
||||
function onImported() {
|
||||
importOpen.value = false;
|
||||
scope.value = 'self';
|
||||
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>
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
<template>
|
||||
<div v-if="project">
|
||||
<a-page-header class="ph" @back="router.push('/home')">
|
||||
<template #title><a style="color: #b4232a">返回首页</a> / 预测项目配置</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 项目信息 -->
|
||||
<a-card class="card">
|
||||
<div class="proj-head">
|
||||
<div>
|
||||
<span class="proj-name">{{ project.name }}</span>
|
||||
<span class="proj-id">项目ID:{{ project.id }}</span>
|
||||
<a-tag :color="project.status === 'report_generated' ? 'green' : 'orange'">
|
||||
{{ project.status === 'report_generated' ? '已生成预测报告' : '未生成预测报告' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="times">
|
||||
创建时间:{{ fmt(project.createdAt) }} 最近更新:{{ fmt(project.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="proj-meta">
|
||||
<span>项目类型:{{ project.type }}</span>
|
||||
<span>所在城市:{{ project.province }}/{{ project.city }}</span>
|
||||
<span>建筑面积:{{ project.area }}m²</span>
|
||||
<span>预测评级:<a-tag v-if="project.rating" :color="ratingColor(project.rating)">{{ project.rating }}</a-tag><span v-else>-</span></span>
|
||||
<a class="edit" @click="editOpen = true">✎ 修改项目信息</a>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 空间 -->
|
||||
<a-card class="card">
|
||||
<div class="spaces-head">
|
||||
<span class="title">包含空间 ({{ project.spaces.length }})</span>
|
||||
<a-button type="link" @click="openAddSpace">+ 添加包含空间</a-button>
|
||||
</div>
|
||||
<a-table :columns="spaceColumns" :data-source="project.spaces" row-key="id" size="middle" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'ventilationRate'">{{ record.ventilationRate }}次/小时</template>
|
||||
<template v-else-if="column.key === 'matCount'">{{ record.materials.length }}</template>
|
||||
<template v-else-if="column.key?.startsWith('conc_')">
|
||||
<span :class="{ over: isOver(record, column.key.slice(5)) }">
|
||||
{{ concText(record, column.key.slice(5)) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="openTracing(record)">溯源</a>
|
||||
<a-divider type="vertical" />
|
||||
<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>
|
||||
<a-button v-if="project.status === 'report_generated'" size="large" style="margin-left: 12px" @click="router.push({ name: 'report', params: { id: project.id } })">查看报告</a-button>
|
||||
</div>
|
||||
|
||||
<NewProjectModal :open="editOpen" :project="project" @ok="onEdited" @cancel="editOpen = false" />
|
||||
<SpaceDrawer
|
||||
:open="drawerOpen"
|
||||
:project-id="project.id"
|
||||
:space="editingSpace"
|
||||
@ok="onSpaceSaved"
|
||||
@cancel="drawerOpen = false"
|
||||
/>
|
||||
<SpaceTracingModal :open="tracingOpen" :space="tracingSpace" @cancel="tracingOpen = 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';
|
||||
import SpaceTracingModal from '../components/SpaceTracingModal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const project = ref<ProjectDetail | null>(null);
|
||||
const editOpen = ref(false);
|
||||
const drawerOpen = ref(false);
|
||||
const editingSpace = ref<SpaceRow | null>(null);
|
||||
const generating = ref(false);
|
||||
|
||||
const spaceColumns = [
|
||||
{ title: '空间ID', dataIndex: 'id' },
|
||||
{ title: '空间名称', dataIndex: 'name' },
|
||||
{ title: '空间类型', dataIndex: 'type' },
|
||||
{ title: '面积', dataIndex: 'area', customRender: ({ text }: any) => `${text}m²` },
|
||||
{ title: '温度', dataIndex: 'temperature', customRender: ({ text }: any) => `${text}℃` },
|
||||
{ title: '湿度', dataIndex: 'humidity', customRender: ({ text }: any) => `${text}%rh` },
|
||||
{ title: '通风换气率', key: 'ventilationRate' },
|
||||
{ title: '材料数', key: 'matCount' },
|
||||
{ title: '限值标准', dataIndex: 'standard' },
|
||||
...POLLUTANTS.map((p) => ({ title: `${POLLUTANT_LABELS[p].zh}预测浓度`, key: `conc_${p}` })),
|
||||
{ title: '操作', key: 'op', width: 130 },
|
||||
];
|
||||
|
||||
function fmt(s?: string) { return s ? new Date(s).toLocaleString() : '-'; }
|
||||
function ratingColor(r: string) { return { A: 'green', B: 'blue', C: 'orange', D: 'red' }[r] || 'default'; }
|
||||
function concText(record: SpaceRow, pol: string) {
|
||||
const v = record.predictedConc?.[pol as Pollutant];
|
||||
return v == null ? '-' : v.toFixed(4) + 'mg/m³';
|
||||
}
|
||||
function isOver(record: SpaceRow, pol: string) {
|
||||
return false; // 颜色提示在报告页更完整,此处仅展示数值
|
||||
}
|
||||
|
||||
async function load() {
|
||||
project.value = await getProject(route.params.id as string);
|
||||
}
|
||||
|
||||
function openAddSpace() { editingSpace.value = null; drawerOpen.value = true; }
|
||||
function openEditSpace(s: SpaceRow) { editingSpace.value = s; drawerOpen.value = true; }
|
||||
|
||||
const tracingOpen = ref(false);
|
||||
const tracingSpace = ref<SpaceRow | null>(null);
|
||||
function openTracing(s: SpaceRow) { tracingSpace.value = s; tracingOpen.value = true; }
|
||||
function onSpaceSaved() { drawerOpen.value = false; load(); }
|
||||
function onEdited() { editOpen.value = false; load(); }
|
||||
async function onDeleteSpace(s: SpaceRow) { await deleteSpace(s.id); message.success('已删除'); load(); }
|
||||
|
||||
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>
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
<template>
|
||||
<div class="report" v-if="project">
|
||||
<div class="rpt-bar no-print">
|
||||
<a @click="router.back()">← 返回</a>
|
||||
<span class="rpt-title">预测报告</span>
|
||||
<a-button type="primary" size="small" @click="printReport">打印 / 导出 PDF</a-button>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<!-- 封面 / 项目信息 -->
|
||||
<div class="cover">
|
||||
<div class="cover-tag">室内装修工程污染物预测报告</div>
|
||||
<h1>{{ project.name }}</h1>
|
||||
<div class="cover-meta">
|
||||
<span>项目ID:{{ project.id }}</span>
|
||||
<span>项目类型:{{ project.type }}</span>
|
||||
<span>所在城市:{{ project.province }}/{{ project.city }}</span>
|
||||
<span>建筑面积:{{ project.area }}m²</span>
|
||||
</div>
|
||||
<div class="cover-rating">
|
||||
预测评级 <b :class="'r-' + (project.rating || 'A')">{{ project.rating || '-' }}</b>
|
||||
<span class="gen">生成时间:{{ fmt(project.reportGeneratedAt) }}</span>
|
||||
</div>
|
||||
<div class="cover-summary">
|
||||
预测结果:共 {{ project.spaces.length }} 个空间,其中
|
||||
<b :class="{ bad: overSpaces > 0 }">{{ overSpaces }}</b> 个存在污染物浓度超标。
|
||||
依据 {{ standardsUsed }}。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各空间 -->
|
||||
<div class="space-block" v-for="(s, idx) in project.spaces" :key="s.id">
|
||||
<div class="sb-head">
|
||||
<h2>空间 {{ idx + 1 }} · {{ s.name }}</h2>
|
||||
<span class="sb-meta">{{ s.type }} · {{ s.area }}m² · {{ s.temperature }}℃ · {{ s.humidity }}%rh · 通风 {{ s.ventilationRate }}次/h · {{ s.standard }}</span>
|
||||
</div>
|
||||
|
||||
<table class="conc-table">
|
||||
<thead><tr><th>污染物</th><th v-for="p in pollutants" :key="p">{{ labels[p].zh }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>限值 (mg/m³)</td><td v-for="p in pollutants" :key="p">{{ limitOf(s, p) }}</td></tr>
|
||||
<tr>
|
||||
<td>预测浓度 (mg/m³)</td>
|
||||
<td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">
|
||||
{{ fmt3(s.predictedConc?.[p]) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>判定</td><td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">{{ isOver(s, p) ? '超标' : '达标' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 污染源溯源(仅超标污染物) -->
|
||||
<div v-if="overPollutants(s).length" class="trace">
|
||||
<div class="trace-h">污染源溯源</div>
|
||||
<div v-for="p in overPollutants(s)" :key="p" class="trace-pol">
|
||||
<div class="tp-name">{{ labels[p].zh }}(超标)主要污染源:<b>{{ sourceNames(s, p) }}</b></div>
|
||||
<div class="bars">
|
||||
<div class="bar" v-for="(c, i) in ranked(s, p)" :key="c.id">
|
||||
<span class="bn">{{ c.name }}<em v-if="isSource(s, p, c.id)">(污染源)</em></span>
|
||||
<span class="bt"><span class="bf" :style="{ width: Math.max(2, c.rate / ranked(s,p)[0].rate * 100) + '%', background: i === 0 ? '#bf4a30' : i === 1 ? '#ca8326' : '#1f7a5a' }" /></span>
|
||||
<span class="bv">{{ (c.rate * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">依据 GB/T 18883-2022 · GB 50325-2020 · 预测结果仅供参考,以 CMA 检测为准</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||||
import { getProject, type ProjectDetail, type SpaceRow } from '../api/projects';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const project = ref<ProjectDetail | null>(null);
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
|
||||
const fmt = (s?: string) => (s ? new Date(s).toLocaleString() : '-');
|
||||
const fmt3 = (n?: number) => (n == null ? '-' : Number(n).toFixed(3));
|
||||
const limitsOf = (s: SpaceRow) => STANDARD_LIMITS[(s.standard as StandardCode) || 'GB50325-2020'];
|
||||
const limitOf = (s: SpaceRow, p: Pollutant) => limitsOf(s)[p];
|
||||
const isOver = (s: SpaceRow, p: Pollutant) => (s.predictedConc?.[p] ?? 0) > limitOf(s, p);
|
||||
const overPollutants = (s: SpaceRow) => POLLUTANTS.filter((p) => isOver(s, p));
|
||||
const overSpaces = computed(() => (project.value?.spaces || []).filter((s) => overPollutants(s).length).length);
|
||||
const standardsUsed = computed(() => [...new Set((project.value?.spaces || []).map((s) => s.standard))].join('、'));
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
function ranked(s: SpaceRow, p: Pollutant): Row[] {
|
||||
return s.materials
|
||||
.map((m) => ({ id: m.materialId, name: m.material?.name || m.materialId, rate: m.contributionRate?.[p] ?? 0 }))
|
||||
.filter((r) => r.rate > 0)
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}
|
||||
function sources(s: SpaceRow, p: Pollutant): Row[] {
|
||||
const out: Row[] = []; let cum = 0;
|
||||
for (const x of ranked(s, p)) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
|
||||
return out;
|
||||
}
|
||||
const isSource = (s: SpaceRow, p: Pollutant, id: string) => sources(s, p).some((x) => x.id === id);
|
||||
const sourceNames = (s: SpaceRow, p: Pollutant) => sources(s, p).map((x) => x.name).join('、');
|
||||
|
||||
function printReport() { window.print(); }
|
||||
|
||||
onMounted(async () => {
|
||||
project.value = await getProject(route.params.id as string);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report { background: #eef0f2; min-height: 100vh; padding-bottom: 40px; }
|
||||
.rpt-bar { display: flex; align-items: center; gap: 16px; padding: 12px 24px; background: #fff; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 5; }
|
||||
.rpt-bar a { color: #b4232a; cursor: pointer; }
|
||||
.rpt-title { flex: 1; font-weight: 600; }
|
||||
.sheet { max-width: 900px; margin: 20px auto; background: #fff; padding: 40px 48px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
|
||||
.cover { border-bottom: 2px solid #b4232a; padding-bottom: 20px; margin-bottom: 24px; }
|
||||
.cover-tag { color: #b4232a; font-size: 13px; font-weight: 600; letter-spacing: 1px; }
|
||||
.cover h1 { font-size: 28px; margin: 8px 0 16px; }
|
||||
.cover-meta { display: flex; flex-wrap: wrap; gap: 6px 24px; color: #555; font-size: 14px; }
|
||||
.cover-rating { margin-top: 16px; font-size: 15px; }
|
||||
.cover-rating b { font-size: 22px; margin: 0 8px; }
|
||||
.cover-rating b.r-A { color: #2f8f5b; } .cover-rating b.r-B { color: #2778c4; } .cover-rating b.r-C { color: #ca8326; } .cover-rating b.r-D { color: #bf4a30; }
|
||||
.cover-rating .gen { color: #999; font-size: 13px; margin-left: 16px; }
|
||||
.cover-summary { margin-top: 14px; color: #444; }
|
||||
.cover-summary b { color: #2f8f5b; } .cover-summary b.bad { color: #bf4a30; }
|
||||
.space-block { margin-bottom: 30px; page-break-inside: avoid; }
|
||||
.sb-head h2 { font-size: 18px; margin: 0; }
|
||||
.sb-meta { color: #888; font-size: 13px; }
|
||||
.conc-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.conc-table th, .conc-table td { border: 1px solid #e8e8e8; padding: 8px 10px; text-align: center; font-size: 13px; }
|
||||
.conc-table th { background: #fafafa; }
|
||||
.conc-table td:first-child, .conc-table th:first-child { text-align: left; color: #666; }
|
||||
.conc-table td.over { color: #bf4a30; font-weight: 700; }
|
||||
.trace { background: #fcf8f6; border: 1px solid #f0e0d8; border-radius: 8px; padding: 14px 16px; }
|
||||
.trace-h { font-weight: 600; color: #b4232a; margin-bottom: 10px; }
|
||||
.trace-pol { margin-bottom: 14px; }
|
||||
.tp-name { font-size: 13px; margin-bottom: 8px; }
|
||||
.bars { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bar { display: grid; grid-template-columns: 200px 1fr 52px; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.bar em { color: #bf4a30; font-style: normal; font-weight: 700; }
|
||||
.bt { height: 12px; background: #eee; border-radius: 4px; overflow: hidden; }
|
||||
.bf { display: block; height: 100%; }
|
||||
.bv { text-align: right; font-weight: 700; }
|
||||
.foot { color: #aaa; font-size: 12px; text-align: center; margin-top: 30px; border-top: 1px solid #eee; padding-top: 16px; }
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.report { background: #fff; }
|
||||
.sheet { box-shadow: none; margin: 0; max-width: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
<template>
|
||||
<div class="s-app">
|
||||
<div class="s-top">
|
||||
<a class="s-back" @click="router.push('/landing')"><ChevronL />返回首页</a>
|
||||
<div class="s-logo"><SourceIcon /></div>
|
||||
<div class="s-tt">污染源识别<small>SOURCE TRACING · 5 项污染物</small></div>
|
||||
<div class="s-spacer" />
|
||||
<a class="s-back" @click="router.push('/home')" style="margin-right:12px">进入专业系统 →</a>
|
||||
<span class="muted">{{ auth.org?.name || '访客' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="s-body">
|
||||
<div v-if="loading" class="muted" style="padding:40px;text-align:center">加载材料库…</div>
|
||||
<div v-else class="s-grid">
|
||||
<!-- 输入 -->
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />房间与材料输入</div><span class="card-step">输入</span></div>
|
||||
|
||||
<div class="fld">
|
||||
<div class="fld-lab">选择样板间</div>
|
||||
<div class="rooms">
|
||||
<div v-for="r in rooms" :key="r.id" class="room-b" :class="{ on: roomId === r.id }" @click="loadRoom(r.id)">{{ r.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<div class="row2">
|
||||
<div><div class="fld-lab">面积</div><div class="num"><input type="number" v-model.number="area" /><span class="unit">m²</span></div></div>
|
||||
<div><div class="fld-lab">层高</div><div class="num"><input type="number" step="0.1" v-model.number="height" /><span class="unit">m</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<div class="row2">
|
||||
<div><div class="fld-lab">温度</div><div class="num"><input type="number" v-model.number="temperature" /><span class="unit">℃</span></div></div>
|
||||
<div><div class="fld-lab">湿度</div><div class="num"><input type="number" v-model.number="humidity" /><span class="unit">%rh</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<div class="fld-lab">通风换气率 <span class="v">{{ (+ventilationRate).toFixed(1) }} 次/h</span></div>
|
||||
<input class="slider" type="range" min="0.3" max="3" step="0.1" v-model.number="ventilationRate" />
|
||||
<div class="slider-scale"><span>0.3 密闭</span><span>1.0 一般</span><span>3.0 强通风</span></div>
|
||||
</div>
|
||||
|
||||
<div class="fld" style="margin-bottom:0">
|
||||
<div class="fld-lab">装修材料 <span class="muted">体积 V={{ volume.toFixed(1) }} m³ · 勾选计入、填用量</span></div>
|
||||
<div class="mats">
|
||||
<div v-for="m in mats" :key="m.id" class="mat" :class="{ off: !m.on }">
|
||||
<div class="mat-chk" :class="{ on: m.on }" @click="m.on = !m.on"><CheckIcon v-if="m.on" /></div>
|
||||
<div class="mat-main">
|
||||
<div class="mat-nm">{{ m.name }}</div>
|
||||
<div class="mat-cat">{{ matMap[m.id]?.category }}</div>
|
||||
</div>
|
||||
<div class="mat-qty"><input type="number" v-model.number="m.area" /><span class="u">m²</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />识别结论 · 5 项污染物</div><span class="card-step">结果</span></div>
|
||||
<table class="res-table">
|
||||
<thead><tr><th>污染物</th><th>预测浓度</th><th>限值({{ standard }})</th><th>判定</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pollutants" :key="p">
|
||||
<td>{{ labels[p].zh }}</td>
|
||||
<td :class="{ over: r.exceeded[p] }">{{ fmt(r.concentration[p]) }} mg/m³</td>
|
||||
<td class="muted">{{ r.limits[p] }}</td>
|
||||
<td><span class="chip" :class="r.exceeded[p] ? 'chip-bad' : 'chip-good'">{{ r.exceeded[p] ? '超标' : '达标' }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<div class="card-t"><span class="bar" />公式溯源 · 各材料贡献</div>
|
||||
<div class="pol-seg">
|
||||
<b v-for="p in pollutants" :key="p" :class="{ on: pol === p }" @click="pol = p">{{ labels[p].zh }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contrib">
|
||||
<div class="cb" v-for="(c, i) in ranked" :key="c.id">
|
||||
<div class="cb-nm">
|
||||
<span class="rk" :style="{ background: col(i) }">{{ i + 1 }}</span>{{ c.name }}
|
||||
<span v-if="isSource(c.id)" style="color:var(--bad);font-weight:700"> (污染源)</span>
|
||||
</div>
|
||||
<div class="cb-track"><div class="cb-fill" :style="{ width: Math.max(3, c.rate / maxRate * 100) + '%', background: col(i) }" /></div>
|
||||
<div class="cb-val"><span class="c" :style="{ color: col(i) }">{{ (c.rate * 100).toFixed(1) }}%</span></div>
|
||||
</div>
|
||||
<div v-if="!ranked.length" class="muted">该污染物无材料释放</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />整改建议</div></div>
|
||||
<div class="sugg">
|
||||
<div class="sugg-ic"><BulbIcon /></div>
|
||||
<div style="flex:1">
|
||||
<div class="sugg-tt">{{ anyOver ? `${overNames} 超标` : '当前方案全部达标 ✓' }}</div>
|
||||
<div class="sugg-tx" v-if="anyOver">
|
||||
主要污染源:<b>{{ topSourceNames }}</b>。可将通风提升至 <b>{{ requiredACH.toFixed(1) }} 次/h</b>,或更换/减少这些材料。
|
||||
</div>
|
||||
<div class="sugg-tx" v-else>各污染物均低于 {{ standard }} 限值。建议入住前仍保持通风并复测确认。</div>
|
||||
<div class="sugg-act" v-if="anyOver">
|
||||
<button class="s-btn s-btn-primary" @click="ventilationRate = requiredACH">应用:通风至 {{ requiredACH.toFixed(1) }} 次/h</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
predictSpace, POLLUTANTS, POLLUTANT_LABELS,
|
||||
type Pollutant, type StandardCode, type EmissionParams,
|
||||
} from '@airpredict/shared';
|
||||
import { listMaterials, type Material } from '../api/materials';
|
||||
import { PRESET_ROOMS } from '../data/rooms';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const rooms = PRESET_ROOMS;
|
||||
|
||||
const ChevronL = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M15 5l-7 7 7 7' })]);
|
||||
const SourceIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('circle', { cx: 12, cy: 12, r: 3 }), h('path', { d: 'M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1' })]);
|
||||
const CheckIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12l5 5L20 6' })]);
|
||||
const BulbIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z' })]);
|
||||
|
||||
const loading = ref(true);
|
||||
const matMap = reactive<Record<string, Material>>({});
|
||||
|
||||
const roomId = ref('demo');
|
||||
const area = ref(19.8);
|
||||
const height = ref(3);
|
||||
const temperature = ref(22);
|
||||
const humidity = ref(45);
|
||||
const ventilationRate = ref(0.5);
|
||||
const standard = ref<StandardCode>('GB50325-2020');
|
||||
const mats = ref<{ id: string; name: string; area: number; on: boolean }[]>([]);
|
||||
const pol = ref<Pollutant>('hcho');
|
||||
|
||||
const volume = computed(() => +(area.value * height.value).toFixed(2));
|
||||
|
||||
function loadRoom(id: string) {
|
||||
const room = rooms.find((r) => r.id === id)!;
|
||||
roomId.value = id;
|
||||
area.value = room.area; height.value = room.height;
|
||||
temperature.value = room.temperature; humidity.value = room.humidity;
|
||||
ventilationRate.value = room.ventilationRate;
|
||||
mats.value = room.materials.map((m) => ({ id: m.id, name: matMap[m.id]?.name || m.id, area: m.a, on: true }));
|
||||
}
|
||||
|
||||
const r = computed(() => {
|
||||
const input = mats.value
|
||||
.filter((m) => m.on && matMap[m.id])
|
||||
.map((m) => ({
|
||||
materialId: m.id,
|
||||
usageAmount: m.area,
|
||||
params: matMap[m.id].emissionParams as Record<Pollutant, EmissionParams>,
|
||||
}));
|
||||
return predictSpace(input, {
|
||||
volume: volume.value, temperature: temperature.value, humidity: humidity.value,
|
||||
ventilationRate: ventilationRate.value, standard: standard.value,
|
||||
});
|
||||
});
|
||||
|
||||
const fmt = (n: number) => Number(n ?? 0).toFixed(3);
|
||||
const col = (i: number) => (i === 0 ? 'var(--bad)' : i === 1 ? 'var(--warn)' : 'var(--accent)');
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
const ranked = computed<Row[]>(() =>
|
||||
r.value.contributions
|
||||
.map((c) => ({ id: c.materialId, name: matMap[c.materialId]?.name || c.materialId, rate: c.contributionRate[pol.value] }))
|
||||
.filter((x) => x.rate > 0)
|
||||
.sort((a, b) => b.rate - a.rate),
|
||||
);
|
||||
const maxRate = computed(() => ranked.value[0]?.rate || 1);
|
||||
const isSource = (id: string) => r.value.sources[pol.value]?.includes(id);
|
||||
|
||||
const anyOver = computed(() => POLLUTANTS.some((p) => r.value.exceeded[p]));
|
||||
const overNames = computed(() => POLLUTANTS.filter((p) => r.value.exceeded[p]).map((p) => labels[p].zh).join('、'));
|
||||
const topSourceNames = computed(() => {
|
||||
const ids = new Set<string>();
|
||||
POLLUTANTS.forEach((p) => r.value.sources[p]?.forEach((id) => ids.add(id)));
|
||||
return [...ids].map((id) => matMap[id]?.name || id).join('、') || '—';
|
||||
});
|
||||
|
||||
// 找到能让全部达标的最小通风换气率
|
||||
const requiredACH = computed(() => {
|
||||
const input = mats.value.filter((m) => m.on && matMap[m.id]).map((m) => ({
|
||||
materialId: m.id, usageAmount: m.area, params: matMap[m.id].emissionParams as Record<Pollutant, EmissionParams>,
|
||||
}));
|
||||
for (let ach = ventilationRate.value; ach <= 3.001; ach += 0.1) {
|
||||
const res = predictSpace(input, { volume: volume.value, temperature: temperature.value, humidity: humidity.value, ventilationRate: ach, standard: standard.value });
|
||||
if (!POLLUTANTS.some((p) => res.exceeded[p])) return Math.round(ach * 10) / 10;
|
||||
}
|
||||
return 3;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await listMaterials({ scope: 'public', page: 1, pageSize: 300 });
|
||||
res.items.forEach((m) => (matMap[m.id] = m));
|
||||
loadRoom('demo');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style src="../styles/source.css"></style>
|
||||
<style scoped>
|
||||
.res-table { width: 100%; border-collapse: collapse; }
|
||||
.res-table th, .res-table td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; font-size: 13px; }
|
||||
.res-table th { color: var(--faint); font-weight: 600; font-size: 12px; }
|
||||
.res-table td.over { color: var(--bad); font-weight: 700; }
|
||||
.pol-seg { display: flex; gap: 2px; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
|
||||
.pol-seg b { font-size: 11.5px; font-weight: 600; padding: 5px 9px; border-radius: 6px; color: var(--sub); cursor: pointer; }
|
||||
.pol-seg b.on { background: var(--accent); color: #fff; }
|
||||
</style>
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
<template>
|
||||
<a-card title="项目模板库">
|
||||
<a-tabs v-model:activeKey="scope" @change="onScopeChange">
|
||||
<a-tab-pane key="public" tab="公共库" />
|
||||
<a-tab-pane key="self" tab="自建库" />
|
||||
</a-tabs>
|
||||
|
||||
<a-form layout="inline" class="filters">
|
||||
<a-form-item label="模板ID"><a-input v-model:value="q.id" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="工程名称"><a-input v-model:value="q.name" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item label="项目类型">
|
||||
<a-select v-model:value="q.type" allow-clear style="width: 120px" @change="reload">
|
||||
<a-select-option v-for="t in projectTypes" :key="t" :value="t">{{ t }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所在城市"><a-input v-model:value="q.city" allow-clear @pressEnter="reload" /></a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="reset">重 置</a-button>
|
||||
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="data.items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
|
||||
<template v-else-if="column.key === 'area'">{{ record.area }}m²</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a @click="showDetail(record)">详情</a>
|
||||
<template v-if="scope === 'self'">
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确认删除该模板?" @confirm="onDelete(record)">
|
||||
<a style="color: #b4232a">删除</a>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'favorite'">
|
||||
<a @click="toggleFav(record)">{{ record.favorited ? '取消' : '收藏' }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="detailOpen" title="模板详情" :footer="null" width="720px">
|
||||
<template v-if="detail">
|
||||
<a-descriptions bordered :column="2" size="small">
|
||||
<a-descriptions-item label="模板ID">{{ detail.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="工程名称">{{ detail.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="项目类型">{{ detail.type }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所在城市">{{ detail.province }}/{{ detail.city }}</a-descriptions-item>
|
||||
<a-descriptions-item label="建筑面积">{{ detail.area }}m²</a-descriptions-item>
|
||||
<a-descriptions-item label="空间数">{{ detail.spaces?.length || 0 }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="detail-sub">包含空间</div>
|
||||
<a-table
|
||||
:columns="spaceCols"
|
||||
:data-source="detail.spaces || []"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
/>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PROJECT_TYPES } from '@airpredict/shared';
|
||||
import { listTemplates, getTemplate, deleteTemplate, type TemplateRow } from '../api/templates';
|
||||
import type { Paged as P } from '../api/materials';
|
||||
import { toggleFavorite } from '../api/favorites';
|
||||
|
||||
const projectTypes = PROJECT_TYPES;
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const loading = ref(false);
|
||||
const data = ref<P<TemplateRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
|
||||
const q = reactive<any>({ id: '', name: '', type: undefined, city: '' });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '模板ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '工程名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '项目类型', dataIndex: 'type', key: 'type' },
|
||||
{ title: '所在城市', key: 'city' },
|
||||
{ title: '建筑面积', key: 'area' },
|
||||
{ title: '空间数', dataIndex: 'spaceCount', key: 'spaceCount' },
|
||||
{ title: '最近更新时间', dataIndex: 'updatedAt', key: 'updatedAt', customRender: ({ text }: any) => new Date(text).toLocaleString() },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
{ title: '收藏', key: 'favorite', width: 70 },
|
||||
];
|
||||
|
||||
const spaceCols = [
|
||||
{ title: '空间名称', dataIndex: 'name' },
|
||||
{ title: '空间类型', dataIndex: 'type' },
|
||||
{ title: '面积', dataIndex: 'area', customRender: ({ text }: any) => `${text}m²` },
|
||||
{ title: '温度', dataIndex: 'temperature', customRender: ({ text }: any) => `${text}℃` },
|
||||
{ title: '湿度', dataIndex: 'humidity', customRender: ({ text }: any) => `${text}%rh` },
|
||||
{ title: '材料数', dataIndex: 'materials', customRender: ({ text }: any) => (text || []).length },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: data.value.page,
|
||||
pageSize: data.value.pageSize,
|
||||
total: data.value.total,
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
}));
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listTemplates({ ...q, scope: scope.value, page: page.value, pageSize: 10 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScopeChange() { page.value = 1; reload(); }
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
function reset() { Object.keys(q).forEach((k) => (q[k] = undefined)); page.value = 1; reload(); }
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
async function showDetail(r: TemplateRow) {
|
||||
detail.value = await getTemplate(r.id);
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
async function toggleFav(r: TemplateRow) {
|
||||
const res = await toggleFavorite('template', r.id);
|
||||
r.favorited = res.favorited;
|
||||
message.success(res.favorited ? '已收藏' : '已取消收藏');
|
||||
}
|
||||
|
||||
async function onDelete(r: TemplateRow) {
|
||||
await deleteTemplate(r.id);
|
||||
message.success('已删除');
|
||||
reload();
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters { margin-bottom: 16px; }
|
||||
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
|
||||
.detail-sub { font-weight: 600; color: #b4232a; margin: 16px 0 8px; }
|
||||
</style>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('../pages/Login.vue') },
|
||||
{ path: '/landing', name: 'landing', component: () => import('../pages/Landing.vue') },
|
||||
{ path: '/source', name: 'source', component: () => import('../pages/SourceTracing.vue') },
|
||||
{ path: '/dashboard', name: 'dashboard', component: () => import('../pages/Dashboard.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/AppLayout.vue'),
|
||||
redirect: '/landing',
|
||||
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') },
|
||||
{ path: 'report/:id', name: 'report', component: () => import('../pages/Report.vue') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// 公开页面(游客可访问,无需登录)
|
||||
const PUBLIC = new Set(['/landing', '/login']);
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (PUBLIC.has(to.path)) {
|
||||
if (to.path === '/login' && token) return '/home';
|
||||
return true;
|
||||
}
|
||||
// 预测页未登录 → 回落地页(去注册手机号);其它受保护页 → 登录页
|
||||
if (!token) return to.path === '/source' ? '/landing' : '/login';
|
||||
return true;
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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 setSession(t: string, o: Org) {
|
||||
token.value = t;
|
||||
org.value = o;
|
||||
localStorage.setItem('token', t);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
org.value = null;
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
return { org, token, login, setSession, logout };
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
html, body, #app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #f0f2f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
/* dashboard.css — layout + components. All colours come from CSS custom
|
||||
properties set per-theme on the .dash root (see dashboard.js THEMES). */
|
||||
|
||||
.dash {
|
||||
--gap: 16px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0.1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dash * { box-sizing: border-box; }
|
||||
.dash svg text { font-family: var(--font); }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.d-side {
|
||||
width: 236px;
|
||||
flex: 0 0 236px;
|
||||
background: var(--side-bg);
|
||||
border-right: 1px solid var(--side-border, var(--border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 18px;
|
||||
color: var(--side-ink, var(--ink));
|
||||
}
|
||||
.d-brand { display: flex; align-items: center; gap: 12px; padding: 0 6px 22px; }
|
||||
.d-logo {
|
||||
width: 40px; height: 40px; border-radius: 11px; flex: 0 0 40px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); color: #fff;
|
||||
box-shadow: var(--logo-glow, none);
|
||||
}
|
||||
.d-logo svg { width: 22px; height: 22px; }
|
||||
.d-brand-tt { font-size: 14.5px; font-weight: 700; letter-spacing: 0.2px; color: var(--side-ink, var(--ink)); }
|
||||
.d-brand-sub { font-size: 10.5px; color: var(--side-sub, var(--sub)); letter-spacing: 1.4px; margin-top: 2px; text-transform: uppercase; }
|
||||
|
||||
.d-navgrp { font-size: 10px; letter-spacing: 1.5px; color: var(--side-sub, var(--faint)); text-transform: uppercase; padding: 16px 10px 8px; }
|
||||
.d-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
.d-nav-i {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 9px 11px; border-radius: 9px;
|
||||
color: var(--side-sub, var(--sub)); font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; position: relative;
|
||||
}
|
||||
.d-nav-i svg { width: 17px; height: 17px; opacity: 0.85; flex: 0 0 17px; }
|
||||
.d-nav-i:hover { background: var(--side-hover, rgba(0,0,0,.04)); }
|
||||
.d-nav-i.on { background: var(--accent-soft); color: var(--accent-on, var(--accent)); font-weight: 600; }
|
||||
.d-nav-i.on svg { opacity: 1; }
|
||||
.d-nav-i .d-badge { margin-left: auto; font-size: 10px; font-weight: 700; background: var(--bad); color: #fff; border-radius: 8px; padding: 1px 6px; }
|
||||
|
||||
.d-side-foot { margin-top: auto; }
|
||||
.d-user { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 11px; background: var(--side-hover, rgba(0,0,0,.03)); }
|
||||
.d-ava { width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent2, var(--accent))); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex: 0 0 34px; }
|
||||
.d-user-nm { font-size: 12.5px; font-weight: 600; color: var(--side-ink, var(--ink)); }
|
||||
.d-pro { display: inline-flex; align-items: center; gap: 3px; font-size: 9.5px; font-weight: 700; letter-spacing: .3px; color: var(--accent-on, var(--accent)); background: var(--accent-soft); border-radius: 6px; padding: 1px 6px; margin-top: 3px; }
|
||||
|
||||
/* ── Main ── */
|
||||
.d-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
.d-top {
|
||||
height: 66px; flex: 0 0 66px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 28px; background: var(--topbar-bg, transparent);
|
||||
}
|
||||
.d-top-tt { font-size: 18px; font-weight: 700; letter-spacing: 0.2px; }
|
||||
.d-top-crumb { font-size: 12px; color: var(--sub); margin-top: 1px; }
|
||||
.d-spacer { flex: 1; }
|
||||
.d-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--panel2); border: 1px solid var(--border);
|
||||
border-radius: 9px; padding: 8px 12px; width: 220px; color: var(--faint); font-size: 12.5px;
|
||||
}
|
||||
.d-search svg { width: 15px; height: 15px; }
|
||||
.d-std { display: flex; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; gap: 2px; }
|
||||
.d-std b { font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 6px; color: var(--sub); cursor: pointer; }
|
||||
.d-std b.on { background: var(--accent); color: #fff; }
|
||||
.d-iconbtn { width: 38px; height: 38px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel2); display: flex; align-items: center; justify-content: center; color: var(--sub); position: relative; }
|
||||
.d-iconbtn svg { width: 17px; height: 17px; }
|
||||
.d-iconbtn .d-dot { position: absolute; top: 8px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--bad); border: 2px solid var(--panel); }
|
||||
|
||||
.d-scroll { flex: 1; padding: 20px 24px 22px; overflow: hidden; }
|
||||
|
||||
/* ── KPI row ── */
|
||||
.d-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap); margin-bottom: var(--gap); }
|
||||
.d-kpi { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 15px 18px; box-shadow: var(--shadow); position: relative; overflow: hidden; }
|
||||
.d-kpi-lab { font-size: 12.5px; color: var(--sub); display: flex; align-items: center; gap: 8px; }
|
||||
.d-kpi-ic { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
|
||||
.d-kpi-ic svg { width: 16px; height: 16px; }
|
||||
.d-kpi-row { display: flex; align-items: flex-end; justify-content: space-between; margin-top: 12px; }
|
||||
.d-kpi-num { font-size: 29px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; font-variant-numeric: tabular-nums; font-family: var(--display); }
|
||||
.d-kpi-unit { font-size: 13px; color: var(--faint); font-weight: 600; margin-left: 3px; }
|
||||
.d-kpi-tr { font-size: 11.5px; font-weight: 700; display: inline-flex; align-items: center; gap: 3px; padding: 3px 7px; border-radius: 7px; }
|
||||
.d-up { color: var(--good); background: var(--good-soft); }
|
||||
.d-down { color: var(--good); background: var(--good-soft); }
|
||||
.d-up.bad { color: var(--bad); background: var(--bad-soft); }
|
||||
|
||||
/* ── Grid + cards ── */
|
||||
.d-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--gap); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 15px 18px; min-width: 0; }
|
||||
.card-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.card-t { font-size: 14.5px; font-weight: 700; letter-spacing: 0.2px; display: flex; align-items: center; gap: 8px; }
|
||||
.card-t .d-bar { width: 3px; height: 14px; border-radius: 2px; background: var(--accent); }
|
||||
.card-sub { font-size: 11px; color: var(--faint); margin-top: 2px; font-weight: 500; }
|
||||
.card-tag { font-size: 10.5px; font-weight: 700; color: var(--sub); background: var(--panel2); border: 1px solid var(--border); border-radius: 7px; padding: 3px 8px; }
|
||||
|
||||
.sp8 { grid-column: span 8; } .sp4 { grid-column: span 4; }
|
||||
.sp5 { grid-column: span 5; } .sp3 { grid-column: span 3; }
|
||||
.sp12 { grid-column: span 12; }
|
||||
|
||||
/* gauges grid */
|
||||
.d-gauges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 4px; }
|
||||
.d-g { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 4px 0; }
|
||||
.d-g-nm { font-size: 12px; font-weight: 700; margin-top: 4px; }
|
||||
.d-g-en { font-size: 9px; color: var(--faint); letter-spacing: .5px; }
|
||||
.d-g-pill { font-size: 9.5px; font-weight: 700; border-radius: 6px; padding: 1px 7px; margin-top: 4px; }
|
||||
.pill-good { color: var(--good); background: var(--good-soft); }
|
||||
.pill-warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.pill-bad { color: var(--bad); background: var(--bad-soft); }
|
||||
|
||||
/* donut legend */
|
||||
.d-legend { display: flex; flex-direction: column; gap: 9px; margin-top: 6px; }
|
||||
.d-leg { display: flex; align-items: center; gap: 9px; font-size: 12.5px; }
|
||||
.d-leg .dot { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 10px; }
|
||||
.d-leg .nm { color: var(--sub); }
|
||||
.d-leg .vl { margin-left: auto; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.d-leg .pc { color: var(--faint); font-size: 11px; width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* table */
|
||||
.d-tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.d-tbl th { text-align: left; font-size: 10.5px; letter-spacing: .5px; color: var(--faint); font-weight: 600; padding: 0 10px 10px; text-transform: uppercase; }
|
||||
.d-tbl td { padding: 9px 10px; border-top: 1px solid var(--border); }
|
||||
.d-tbl td:first-child, .d-tbl th:first-child { padding-left: 4px; }
|
||||
.d-tbl .rm { font-weight: 700; }
|
||||
.d-tbl .pj { color: var(--sub); font-size: 11.5px; }
|
||||
.d-tbl .vn { font-variant-numeric: tabular-nums; font-weight: 700; }
|
||||
.d-tbl .lm { color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
.d-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; border-radius: 7px; padding: 3px 9px; }
|
||||
.d-chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.chip-bad { color: var(--bad); background: var(--bad-soft); }
|
||||
.chip-warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.chip-good { color: var(--good); background: var(--good-soft); }
|
||||
.d-pol { font-weight: 700; }
|
||||
|
||||
/* alert strip on the latest-prediction card */
|
||||
.d-alert { display: flex; align-items: center; gap: 9px; font-size: 12px; font-weight: 600; color: var(--bad); background: var(--bad-soft); border-radius: 9px; padding: 9px 12px; margin-bottom: 14px; }
|
||||
.d-alert svg { width: 16px; height: 16px; flex: 0 0 16px; }
|
||||
|
||||
.d-flex { display: flex; gap: var(--gap); }
|
||||
|
||||
/* ── Warm variant: hero band + serif display ── */
|
||||
.dash.v-warm .d-top-tt { font-family: var(--display); font-size: 19px; }
|
||||
.dash.v-warm .card { box-shadow: none; }
|
||||
.dash.v-warm .d-kpi { box-shadow: none; }
|
||||
.d-hero { display: grid; grid-template-columns: auto 1fr; gap: 26px; align-items: center;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px 26px; margin-bottom: var(--gap); }
|
||||
.d-hero-l { display: flex; align-items: center; gap: 22px; }
|
||||
.d-hero-big { font-family: var(--display); font-size: 30px; font-weight: 800; letter-spacing: -.5px; }
|
||||
.d-hero-txt .t { font-size: 13px; color: var(--sub); }
|
||||
.d-hero-txt .n { font-family: var(--display); font-size: 15px; font-weight: 700; margin-top: 2px; }
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
/* source.css — 污染源识别工具(暖绿杂志风),变量挂在 .s-app 上。 */
|
||||
.s-app {
|
||||
--bg: #f4f0e7; --panel: #fffdf8; --panel2: #f4efe3; --panel3: #ede6d8;
|
||||
--border: #e9e1d2; --border2: #ded3bf;
|
||||
--ink: #221d15; --sub: #6c6353; --faint: #a89c86;
|
||||
--accent: #1f7a5a; --accent2: #2f9e74; --accent-soft: rgba(31,122,90,.12); --accent-deep: #165c43;
|
||||
--good: #2f8f5b; --good-soft: rgba(47,143,91,.14);
|
||||
--warn: #ca8326; --warn-soft: rgba(202,131,38,.16);
|
||||
--bad: #bf4a30; --bad-soft: rgba(191,74,48,.13);
|
||||
--track: #ebe3d4; --radius: 16px;
|
||||
--serif: 'Songti SC','STSong',Georgia,'Times New Roman',serif;
|
||||
background: var(--bg); color: var(--ink);
|
||||
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',system-ui,sans-serif;
|
||||
font-size: 14px; letter-spacing: .1px;
|
||||
height: 100vh; display: flex; flex-direction: column;
|
||||
}
|
||||
.s-app * { box-sizing: border-box; }
|
||||
|
||||
.s-top { flex: 0 0 64px; display: flex; align-items: center; gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--border); background: rgba(255,253,248,.85); }
|
||||
.s-back { display: flex; align-items: center; gap: 7px; color: var(--sub); font-size: 13px; font-weight: 500; cursor: pointer; padding: 7px 11px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); white-space: nowrap; }
|
||||
.s-back:hover { background: var(--panel2); }
|
||||
.s-back svg { width: 15px; height: 15px; }
|
||||
.s-logo { width: 34px; height: 34px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||
.s-logo svg { width: 19px; height: 19px; }
|
||||
.s-tt { font-family: var(--serif); font-size: 18px; font-weight: 700; }
|
||||
.s-tt small { font-size: 11px; color: var(--faint); font-weight: 500; letter-spacing: 1px; margin-left: 8px; }
|
||||
.s-spacer { flex: 1; }
|
||||
|
||||
.s-body { flex: 1; overflow-y: auto; padding: 22px 28px 30px; }
|
||||
.s-grid { display: grid; gap: 18px; align-items: start; grid-template-columns: 400px 1fr; }
|
||||
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
|
||||
.card-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.card-t { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
||||
.card-t .bar { width: 3px; height: 14px; border-radius: 2px; background: var(--accent); }
|
||||
.card-step { font-size: 10px; font-weight: 700; letter-spacing: .5px; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 2px 8px; }
|
||||
.muted { color: var(--faint); font-size: 11px; font-weight: 500; }
|
||||
|
||||
.fld { margin-bottom: 16px; }
|
||||
.fld-lab { font-size: 12px; font-weight: 600; color: var(--sub); margin-bottom: 7px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.fld-lab .v { font-family: var(--serif); font-weight: 700; color: var(--ink); font-size: 14px; }
|
||||
.rooms { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.room-b { font-size: 12.5px; font-weight: 600; padding: 8px 13px; border-radius: 9px; border: 1px solid var(--border2); background: var(--panel); color: var(--sub); cursor: pointer; transition: .12s; white-space: nowrap; }
|
||||
.room-b:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.room-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.num { display: flex; align-items: center; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
|
||||
.num input { flex: 1; min-width: 0; border: none; background: transparent; padding: 9px 11px; font-family: var(--serif); font-size: 15px; font-weight: 700; color: var(--ink); outline: none; }
|
||||
.num .unit { font-size: 11px; color: var(--faint); padding: 0 11px; font-weight: 600; }
|
||||
.slider { width: 100%; -webkit-appearance: none; appearance: none; height: 6px; border-radius: 4px; background: var(--track); outline: none; }
|
||||
.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.2); border: 3px solid #fff; }
|
||||
.slider-scale { display: flex; justify-content: space-between; font-size: 10px; color: var(--faint); margin-top: 4px; }
|
||||
|
||||
.mats { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mat { display: flex; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 11px; background: var(--panel2); }
|
||||
.mat.off { opacity: .42; }
|
||||
.mat-chk { width: 18px; height: 18px; border-radius: 6px; border: 1.5px solid var(--border2); flex: 0 0 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: var(--panel); }
|
||||
.mat-chk.on { background: var(--accent); border-color: var(--accent); }
|
||||
.mat-chk svg { width: 12px; height: 12px; color: #fff; }
|
||||
.mat-main { flex: 1; min-width: 0; }
|
||||
.mat-nm { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mat-cat { font-size: 10px; color: var(--faint); }
|
||||
.mat-qty { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||
.mat-qty input { width: 52px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); padding: 5px 7px; font-family: var(--serif); font-weight: 700; font-size: 13px; text-align: right; color: var(--ink); outline: none; }
|
||||
.mat-qty .u { font-size: 10px; color: var(--faint); }
|
||||
.add-mat { margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--accent); border: 1px dashed var(--border2); border-radius: 11px; padding: 9px; cursor: pointer; background: transparent; width: 100%; }
|
||||
.add-mat:hover { background: var(--accent-soft); }
|
||||
.add-mat svg { width: 15px; height: 15px; flex: 0 0 15px; }
|
||||
|
||||
.verdict { display: flex; align-items: center; gap: 22px; }
|
||||
.verd-num { font-family: var(--serif); font-weight: 800; letter-spacing: -1px; line-height: .9; }
|
||||
.verd-num .big { font-size: 56px; }
|
||||
.verd-num .u { font-size: 16px; color: var(--faint); margin-left: 4px; }
|
||||
.verd-meta { display: flex; flex-direction: column; gap: 8px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; border-radius: 9px; padding: 6px 13px; width: fit-content; }
|
||||
.chip::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.chip-bad { color: var(--bad); background: var(--bad-soft); }
|
||||
.chip-warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.chip-good { color: var(--good); background: var(--good-soft); }
|
||||
.verd-sub { font-size: 12.5px; color: var(--sub); }
|
||||
.verd-sub b { color: var(--ink); }
|
||||
|
||||
.gate { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; align-items: center; gap: 10px; font-size: 12px; margin: 14px 0 0; }
|
||||
.gate-step { text-align: center; padding: 9px 6px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel2); color: var(--sub); font-weight: 600; white-space: nowrap; font-size: 12px; }
|
||||
.gate-step.act { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.gate-step.bad { border-color: var(--bad); color: var(--bad); background: var(--bad-soft); }
|
||||
.gate-arrow { color: var(--faint); flex: 0 0 auto; display: flex; align-items: center; }
|
||||
|
||||
.formula { background: var(--panel2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; font-family: var(--serif); }
|
||||
.formula .eq { font-size: 17px; font-weight: 700; letter-spacing: .3px; }
|
||||
.formula .frac { display: inline-block; vertical-align: middle; }
|
||||
.formula .frac .top { display: block; border-bottom: 2px solid currentColor; padding: 0 8px; font-size: 14px; }
|
||||
.formula .frac .bot { display: block; padding: 2px 8px 0; font-size: 14px; text-align: center; }
|
||||
.formula .plug { font-family: var(--sans, sans-serif); font-size: 12px; color: var(--sub); margin-top: 8px; line-height: 1.7; }
|
||||
.formula .plug code { font-family: var(--serif); font-weight: 700; color: var(--accent-deep); background: var(--accent-soft); border-radius: 5px; padding: 1px 6px; }
|
||||
|
||||
.contrib { display: flex; flex-direction: column; gap: 11px; }
|
||||
.cb { display: grid; grid-template-columns: 140px 1fr 92px; align-items: center; gap: 12px; }
|
||||
.cb-nm { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cb-nm .rk { display: inline-block; width: 17px; height: 17px; border-radius: 5px; font-size: 10px; font-weight: 800; text-align: center; line-height: 17px; margin-right: 7px; color: #fff; }
|
||||
.cb-track { height: 18px; background: var(--track); border-radius: 6px; overflow: hidden; }
|
||||
.cb-fill { height: 100%; border-radius: 6px; transition: width .35s cubic-bezier(.3,.8,.3,1); }
|
||||
.cb-val { text-align: right; font-size: 12px; }
|
||||
.cb-val .c { font-family: var(--serif); font-weight: 800; }
|
||||
.cb-val .p { font-size: 10.5px; color: var(--faint); }
|
||||
|
||||
.sugg { display: flex; gap: 13px; padding: 15px 17px; border-radius: 13px; background: linear-gradient(180deg, var(--accent-soft), rgba(31,122,90,.04)); border: 1px solid rgba(31,122,90,.18); }
|
||||
.sugg-ic { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; }
|
||||
.sugg-ic svg { width: 19px; height: 19px; }
|
||||
.sugg-tt { font-size: 13px; font-weight: 700; font-family: var(--serif); margin-bottom: 4px; }
|
||||
.sugg-tx { font-size: 12.5px; color: var(--sub); line-height: 1.6; }
|
||||
.sugg-tx b { color: var(--accent-deep); }
|
||||
.sugg-act { display: flex; gap: 9px; margin-top: 11px; }
|
||||
.s-btn { font-size: 12.5px; font-weight: 600; border-radius: 9px; padding: 8px 14px; cursor: pointer; border: 1px solid var(--border2); background: var(--panel); color: var(--ink); white-space: nowrap; }
|
||||
.s-btn:hover { border-color: var(--accent); }
|
||||
.s-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.s-btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
|
||||
.stack { display: flex; flex-direction: column; gap: 18px; }
|
||||
.toast { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); background: var(--ink); color: #fff; font-size: 13px; font-weight: 600; padding: 12px 20px; border-radius: 11px; box-shadow: 0 10px 30px rgba(0,0,0,.25); z-index: 80; display: flex; align-items: center; gap: 9px; }
|
||||
.toast svg { width: 16px; height: 16px; color: var(--accent2); }
|
||||
|
||||
@media(max-width:880px){ .s-grid { grid-template-columns: 1fr; } }
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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
20
package.json
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/** 项目类型 (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 HEALTH_GRADES = ['A', 'B', 'C'] as const;
|
||||
export type HealthGrade = (typeof HEALTH_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;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './pollutants.js';
|
||||
export * from './enums.js';
|
||||
export * from './prediction.js';
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* 五项受控污染物。原系统对每个空间预测这五项的浓度。
|
||||
* 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'];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue