Compare commits

..

No commits in common. "main" and "feature/source-charts" have entirely different histories.

159 changed files with 22271 additions and 11328 deletions

13
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '',
"emissionParams" JSONB NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"ownerOrgId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "materials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"province" TEXT NOT NULL,
"city" TEXT NOT NULL,
"area" DOUBLE PRECISION NOT NULL,
"rating" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"isTemplate" BOOLEAN NOT NULL DEFAULT false,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"ownerOrgId" TEXT NOT NULL,
"reportGeneratedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "spaces" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"layout" TEXT NOT NULL DEFAULT 'uniform',
"height" DOUBLE PRECISION,
"area" DOUBLE PRECISION NOT NULL,
"volume" DOUBLE PRECISION NOT NULL,
"temperature" DOUBLE PRECISION NOT NULL,
"humidity" DOUBLE PRECISION NOT NULL,
"ventilationRate" DOUBLE PRECISION NOT NULL,
"standard" TEXT NOT NULL DEFAULT 'GB50325-2020',
"predictedConc" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "spaces_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "space_materials" (
"id" TEXT NOT NULL,
"spaceId" TEXT NOT NULL,
"materialId" TEXT NOT NULL,
"usageUnit" TEXT NOT NULL DEFAULT '',
"usageAmount" DOUBLE PRECISION NOT NULL,
"contribution" JSONB,
"contributionRate" JSONB,
CONSTRAINT "space_materials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "favorites" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "favorites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "reports" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"rating" TEXT,
"payload" JSONB NOT NULL,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_username_key" ON "organizations"("username");
-- CreateIndex
CREATE INDEX "materials_category_idx" ON "materials"("category");
-- CreateIndex
CREATE INDEX "materials_brand_idx" ON "materials"("brand");
-- CreateIndex
CREATE INDEX "materials_envGrade_idx" ON "materials"("envGrade");
-- CreateIndex
CREATE INDEX "materials_isPublic_idx" ON "materials"("isPublic");
-- CreateIndex
CREATE INDEX "projects_ownerOrgId_idx" ON "projects"("ownerOrgId");
-- CreateIndex
CREATE INDEX "projects_isTemplate_isPublic_idx" ON "projects"("isTemplate", "isPublic");
-- CreateIndex
CREATE INDEX "spaces_projectId_idx" ON "spaces"("projectId");
-- CreateIndex
CREATE INDEX "space_materials_spaceId_idx" ON "space_materials"("spaceId");
-- CreateIndex
CREATE INDEX "space_materials_materialId_idx" ON "space_materials"("materialId");
-- CreateIndex
CREATE UNIQUE INDEX "favorites_orgId_targetType_targetId_key" ON "favorites"("orgId", "targetType", "targetId");
-- CreateIndex
CREATE INDEX "reports_projectId_idx" ON "reports"("projectId");
-- AddForeignKey
ALTER TABLE "materials" ADD CONSTRAINT "materials_ownerOrgId_fkey" FOREIGN KEY ("ownerOrgId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_ownerOrgId_fkey" FOREIGN KEY ("ownerOrgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "spaces" ADD CONSTRAINT "spaces_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "space_materials" ADD CONSTRAINT "space_materials_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "space_materials" ADD CONSTRAINT "space_materials_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reports" ADD CONSTRAINT "reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

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

View File

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

View File

@ -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/B5 污染物),供 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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/</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/ · 超出 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>`;
};
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }}</template>
<template v-else-if="column.key === 'op'">
<a-button type="link" size="small" :loading="usingId === record.id" @click="useTemplate(record)">使用此模板</a-button>
</template>
</template>
</a-table>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { listTemplates, type TemplateRow } from '../api/templates';
import { createProject } from '../api/projects';
import type { Paged } from '../api/materials';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'created', id: string): void; (e: 'cancel'): void }>();
const scope = ref<'public' | 'self'>('public');
const loading = ref(false);
const usingId = ref('');
const data = ref<Paged<TemplateRow>>({ total: 0, page: 1, pageSize: 8, items: [] });
const page = ref(1);
const columns = [
{ title: '模板ID', dataIndex: 'id' },
{ title: '工程名称', dataIndex: 'name' },
{ title: '项目类型', dataIndex: 'type' },
{ title: '所在城市', key: 'city' },
{ title: '建筑面积', key: 'area' },
{ title: '空间数', dataIndex: 'spaceCount' },
{ title: '操作', key: 'op', width: 120 },
];
const pagination = computed(() => ({ current: data.value.page, pageSize: data.value.pageSize, total: data.value.total }));
async function reload() {
loading.value = true;
try {
data.value = await listTemplates({ scope: scope.value, page: page.value, pageSize: 8 });
} finally {
loading.value = false;
}
}
function onTableChange(pg: any) { page.value = pg.current; reload(); }
async function useTemplate(r: TemplateRow) {
usingId.value = r.id;
try {
const p = await createProject({
name: r.name, type: r.type, province: r.province, city: r.city, area: r.area, fromTemplateId: r.id,
});
message.success('已按模板创建项目');
emit('created', p.id);
} finally {
usingId.value = '';
}
}
watch(() => props.open, (o) => { if (o) { page.value = 1; reload(); } });
</script>

View File

@ -1,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/)</th>
<th>平衡释放量范围 Yp (mg/)</th>
<th>平衡释放量变化率 B (/)</th>
</tr>
</thead>
<tbody>
<tr v-for="p in pollutants" :key="p">
<td class="pol">{{ labels[p].zh }}</td>
<td><a-input-number v-model:value="form.emissionParams[p].y0" :min="0" :step="0.001" style="width: 100%" /></td>
<td><a-input-number v-model:value="form.emissionParams[p].yp" :min="0" :step="0.001" style="width: 100%" /></td>
<td><a-input-number v-model:value="form.emissionParams[p].b" :min="0" :step="0.001" style="width: 100%" /></td>
</tr>
</tbody>
</table>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
POLLUTANTS,
POLLUTANT_LABELS,
MATERIAL_CATEGORIES,
ENV_GRADES,
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/</b>
<span class="limit">限值 {{ limits[p] }} mg/{{ 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }}</template>
<template v-else-if="column.key === 'updatedAt'">{{ fmt(record.updatedAt) }}</template>
<template v-else-if="column.key === 'op'">
<a @click="goConfig(record)">继续配置</a>
<a-divider type="vertical" />
<a-popconfirm title="确认删除该草稿?" @confirm="onDelete(record)">
<a style="color: #b4232a">删除</a>
</a-popconfirm>
</template>
</template>
</a-table>
</a-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { listProjects, deleteProject, type ProjectRow } from '../api/projects';
import type { Paged } from '../api/materials';
const router = useRouter();
const loading = ref(false);
const data = ref<Paged<ProjectRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
const page = ref(1);
const columns = [
{ title: '项目ID', dataIndex: 'id' },
{ title: '工程名称', dataIndex: 'name' },
{ title: '项目类型', dataIndex: 'type' },
{ title: '所在城市', key: 'city' },
{ title: '建筑面积', key: 'area' },
{ title: '空间数', dataIndex: 'spaceCount' },
{ title: '最近更新', key: 'updatedAt' },
{ title: '操作', key: 'op', width: 160 },
];
const pagination = computed(() => ({
current: data.value.page,
pageSize: data.value.pageSize,
total: data.value.total,
showTotal: (t: number) => `${t}`,
}));
function fmt(s: string) { return new Date(s).toLocaleString(); }
async function reload() {
loading.value = true;
try {
data.value = await listProjects({ unfinished: 'true', page: page.value, pageSize: 10 });
} finally {
loading.value = false;
}
}
function onTableChange(pg: any) { page.value = pg.current; reload(); }
function goConfig(r: ProjectRow) { router.push({ name: 'predict', params: { id: r.id } }); }
async function onDelete(r: ProjectRow) { await deleteProject(r.id); message.success('已删除'); reload(); }
onMounted(reload);
</script>
<style scoped>
.hint { color: #999; font-size: 13px; }
</style>

View File

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

View File

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

View File

@ -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: '新装住宅的甲醛,为什么能持续释放 315 年?', 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>

View File

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

View File

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

View File

@ -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) }} &nbsp; 最近更新{{ fmt(project.updatedAt) }}
</div>
</div>
<div class="proj-meta">
<span>项目类型{{ project.type }}</span>
<span>所在城市{{ project.province }}/{{ project.city }}</span>
<span>建筑面积{{ project.area }}</span>
<span>预测评级<a-tag v-if="project.rating" :color="ratingColor(project.rating)">{{ project.rating }}</a-tag><span v-else>-</span></span>
<a class="edit" @click="editOpen = true"> 修改项目信息</a>
</div>
</a-card>
<!-- 空间 -->
<a-card class="card">
<div class="spaces-head">
<span class="title">包含空间 ({{ project.spaces.length }})</span>
<a-button type="link" @click="openAddSpace">+ 添加包含空间</a-button>
</div>
<a-table :columns="spaceColumns" :data-source="project.spaces" row-key="id" size="middle" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'ventilationRate'">{{ record.ventilationRate }}/小时</template>
<template v-else-if="column.key === 'matCount'">{{ record.materials.length }}</template>
<template v-else-if="column.key?.startsWith('conc_')">
<span :class="{ over: isOver(record, column.key.slice(5)) }">
{{ concText(record, column.key.slice(5)) }}
</span>
</template>
<template v-else-if="column.key === 'op'">
<a @click="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}` },
{ 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>

View File

@ -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 }}</span>
</div>
<div class="cover-rating">
预测评级 <b :class="'r-' + (project.rating || 'A')">{{ project.rating || '-' }}</b>
<span class="gen">生成时间{{ fmt(project.reportGeneratedAt) }}</span>
</div>
<div class="cover-summary">
预测结果 {{ project.spaces.length }} 个空间其中
<b :class="{ bad: overSpaces > 0 }">{{ overSpaces }}</b> 个存在污染物浓度超标
依据 {{ standardsUsed }}
</div>
</div>
<!-- 各空间 -->
<div class="space-block" v-for="(s, idx) in project.spaces" :key="s.id">
<div class="sb-head">
<h2>空间 {{ idx + 1 }} · {{ s.name }}</h2>
<span class="sb-meta">{{ s.type }} · {{ s.area }} · {{ s.temperature }} · {{ s.humidity }}%rh · 通风 {{ s.ventilationRate }}/h · {{ s.standard }}</span>
</div>
<table class="conc-table">
<thead><tr><th>污染物</th><th v-for="p in pollutants" :key="p">{{ labels[p].zh }}</th></tr></thead>
<tbody>
<tr><td>限值 (mg/)</td><td v-for="p in pollutants" :key="p">{{ limitOf(s, p) }}</td></tr>
<tr>
<td>预测浓度 (mg/)</td>
<td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">
{{ fmt3(s.predictedConc?.[p]) }}
</td>
</tr>
<tr><td>判定</td><td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">{{ isOver(s, p) ? '超标' : '达标' }}</td></tr>
</tbody>
</table>
<!-- 污染源溯源仅超标污染物 -->
<div v-if="overPollutants(s).length" class="trace">
<div class="trace-h">污染源溯源</div>
<div v-for="p in overPollutants(s)" :key="p" class="trace-pol">
<div class="tp-name">{{ labels[p].zh }}超标主要污染源:<b>{{ sourceNames(s, p) }}</b></div>
<div class="bars">
<div class="bar" v-for="(c, i) in ranked(s, p)" :key="c.id">
<span class="bn">{{ c.name }}<em v-if="isSource(s, p, c.id)">污染源</em></span>
<span class="bt"><span class="bf" :style="{ width: Math.max(2, c.rate / ranked(s,p)[0].rate * 100) + '%', background: i === 0 ? '#bf4a30' : i === 1 ? '#ca8326' : '#1f7a5a' }" /></span>
<span class="bv">{{ (c.rate * 100).toFixed(1) }}%</span>
</div>
</div>
</div>
</div>
</div>
<div class="foot">依据 GB/T 18883-2022 · GB 50325-2020 · 预测结果仅供参考 CMA 检测为准</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
import { getProject, type ProjectDetail, type SpaceRow } from '../api/projects';
const route = useRoute();
const router = useRouter();
const project = ref<ProjectDetail | null>(null);
const pollutants = POLLUTANTS;
const labels = POLLUTANT_LABELS;
const fmt = (s?: string) => (s ? new Date(s).toLocaleString() : '-');
const fmt3 = (n?: number) => (n == null ? '-' : Number(n).toFixed(3));
const limitsOf = (s: SpaceRow) => STANDARD_LIMITS[(s.standard as StandardCode) || 'GB50325-2020'];
const limitOf = (s: SpaceRow, p: Pollutant) => limitsOf(s)[p];
const isOver = (s: SpaceRow, p: Pollutant) => (s.predictedConc?.[p] ?? 0) > limitOf(s, p);
const overPollutants = (s: SpaceRow) => POLLUTANTS.filter((p) => isOver(s, p));
const overSpaces = computed(() => (project.value?.spaces || []).filter((s) => overPollutants(s).length).length);
const standardsUsed = computed(() => [...new Set((project.value?.spaces || []).map((s) => s.standard))].join('、'));
interface Row { id: string; name: string; rate: number }
function ranked(s: SpaceRow, p: Pollutant): Row[] {
return s.materials
.map((m) => ({ id: m.materialId, name: m.material?.name || m.materialId, rate: m.contributionRate?.[p] ?? 0 }))
.filter((r) => r.rate > 0)
.sort((a, b) => b.rate - a.rate);
}
function sources(s: SpaceRow, p: Pollutant): Row[] {
const out: Row[] = []; let cum = 0;
for (const x of ranked(s, p)) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
return out;
}
const isSource = (s: SpaceRow, p: Pollutant, id: string) => sources(s, p).some((x) => x.id === id);
const sourceNames = (s: SpaceRow, p: Pollutant) => sources(s, p).map((x) => x.name).join('、');
function printReport() { window.print(); }
onMounted(async () => {
project.value = await getProject(route.params.id as string);
});
</script>
<style scoped>
.report { background: #eef0f2; min-height: 100vh; padding-bottom: 40px; }
.rpt-bar { display: flex; align-items: center; gap: 16px; padding: 12px 24px; background: #fff; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 5; }
.rpt-bar a { color: #b4232a; cursor: pointer; }
.rpt-title { flex: 1; font-weight: 600; }
.sheet { max-width: 900px; margin: 20px auto; background: #fff; padding: 40px 48px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
.cover { border-bottom: 2px solid #b4232a; padding-bottom: 20px; margin-bottom: 24px; }
.cover-tag { color: #b4232a; font-size: 13px; font-weight: 600; letter-spacing: 1px; }
.cover h1 { font-size: 28px; margin: 8px 0 16px; }
.cover-meta { display: flex; flex-wrap: wrap; gap: 6px 24px; color: #555; font-size: 14px; }
.cover-rating { margin-top: 16px; font-size: 15px; }
.cover-rating b { font-size: 22px; margin: 0 8px; }
.cover-rating b.r-A { color: #2f8f5b; } .cover-rating b.r-B { color: #2778c4; } .cover-rating b.r-C { color: #ca8326; } .cover-rating b.r-D { color: #bf4a30; }
.cover-rating .gen { color: #999; font-size: 13px; margin-left: 16px; }
.cover-summary { margin-top: 14px; color: #444; }
.cover-summary b { color: #2f8f5b; } .cover-summary b.bad { color: #bf4a30; }
.space-block { margin-bottom: 30px; page-break-inside: avoid; }
.sb-head h2 { font-size: 18px; margin: 0; }
.sb-meta { color: #888; font-size: 13px; }
.conc-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.conc-table th, .conc-table td { border: 1px solid #e8e8e8; padding: 8px 10px; text-align: center; font-size: 13px; }
.conc-table th { background: #fafafa; }
.conc-table td:first-child, .conc-table th:first-child { text-align: left; color: #666; }
.conc-table td.over { color: #bf4a30; font-weight: 700; }
.trace { background: #fcf8f6; border: 1px solid #f0e0d8; border-radius: 8px; padding: 14px 16px; }
.trace-h { font-weight: 600; color: #b4232a; margin-bottom: 10px; }
.trace-pol { margin-bottom: 14px; }
.tp-name { font-size: 13px; margin-bottom: 8px; }
.bars { display: flex; flex-direction: column; gap: 6px; }
.bar { display: grid; grid-template-columns: 200px 1fr 52px; align-items: center; gap: 10px; font-size: 12px; }
.bar em { color: #bf4a30; font-style: normal; font-weight: 700; }
.bt { height: 12px; background: #eee; border-radius: 4px; overflow: hidden; }
.bf { display: block; height: 100%; }
.bv { text-align: right; font-weight: 700; }
.foot { color: #aaa; font-size: 12px; text-align: center; margin-top: 30px; border-top: 1px solid #eee; padding-top: 16px; }
@media print {
.no-print { display: none; }
.report { background: #fff; }
.sheet { box-shadow: none; margin: 0; max-width: none; }
}
</style>

View File

@ -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"></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) }} · 勾选计入填用量</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"></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/</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>

View File

@ -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 }}</template>
<template v-else-if="column.key === 'action'">
<a @click="showDetail(record)">详情</a>
<template v-if="scope === 'self'">
<a-divider type="vertical" />
<a-popconfirm title="确认删除该模板?" @confirm="onDelete(record)">
<a style="color: #b4232a">删除</a>
</a-popconfirm>
</template>
</template>
<template v-else-if="column.key === 'favorite'">
<a @click="toggleFav(record)">{{ record.favorited ? '取消' : '收藏' }}</a>
</template>
</template>
</a-table>
<a-modal v-model:open="detailOpen" title="模板详情" :footer="null" width="720px">
<template v-if="detail">
<a-descriptions bordered :column="2" size="small">
<a-descriptions-item label="模板ID">{{ detail.id }}</a-descriptions-item>
<a-descriptions-item label="工程名称">{{ detail.name }}</a-descriptions-item>
<a-descriptions-item label="项目类型">{{ detail.type }}</a-descriptions-item>
<a-descriptions-item label="所在城市">{{ detail.province }}/{{ detail.city }}</a-descriptions-item>
<a-descriptions-item label="建筑面积">{{ detail.area }}</a-descriptions-item>
<a-descriptions-item label="空间数">{{ detail.spaces?.length || 0 }}</a-descriptions-item>
</a-descriptions>
<div class="detail-sub">包含空间</div>
<a-table
:columns="spaceCols"
:data-source="detail.spaces || []"
row-key="id"
size="small"
:pagination="false"
/>
</template>
</a-modal>
</a-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { PROJECT_TYPES } from '@airpredict/shared';
import { listTemplates, getTemplate, deleteTemplate, type TemplateRow } from '../api/templates';
import type { Paged as P } from '../api/materials';
import { toggleFavorite } from '../api/favorites';
const projectTypes = PROJECT_TYPES;
const scope = ref<'public' | 'self'>('public');
const loading = ref(false);
const data = ref<P<TemplateRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
const q = reactive<any>({ id: '', name: '', type: undefined, city: '' });
const page = ref(1);
const columns = [
{ title: '模板ID', dataIndex: 'id', key: 'id' },
{ title: '工程名称', dataIndex: 'name', key: 'name' },
{ title: '项目类型', dataIndex: 'type', key: 'type' },
{ title: '所在城市', key: 'city' },
{ title: '建筑面积', key: 'area' },
{ title: '空间数', dataIndex: 'spaceCount', key: 'spaceCount' },
{ title: '最近更新时间', dataIndex: 'updatedAt', key: 'updatedAt', customRender: ({ text }: any) => new Date(text).toLocaleString() },
{ title: '操作', key: 'action', width: 140 },
{ title: '收藏', key: 'favorite', width: 70 },
];
const spaceCols = [
{ title: '空间名称', dataIndex: 'name' },
{ title: '空间类型', dataIndex: 'type' },
{ title: '面积', dataIndex: 'area', customRender: ({ text }: any) => `${text}` },
{ title: '温度', dataIndex: 'temperature', customRender: ({ text }: any) => `${text}` },
{ title: '湿度', dataIndex: 'humidity', customRender: ({ text }: any) => `${text}%rh` },
{ title: '材料数', dataIndex: 'materials', customRender: ({ text }: any) => (text || []).length },
];
const pagination = computed(() => ({
current: data.value.page,
pageSize: data.value.pageSize,
total: data.value.total,
showTotal: (t: number) => `${t}`,
}));
async function reload() {
loading.value = true;
try {
data.value = await listTemplates({ ...q, scope: scope.value, page: page.value, pageSize: 10 });
} finally {
loading.value = false;
}
}
function onScopeChange() { page.value = 1; reload(); }
function onTableChange(pg: any) { page.value = pg.current; reload(); }
function reset() { Object.keys(q).forEach((k) => (q[k] = undefined)); page.value = 1; reload(); }
const detailOpen = ref(false);
const detail = ref<any>(null);
async function showDetail(r: TemplateRow) {
detail.value = await getTemplate(r.id);
detailOpen.value = true;
}
async function toggleFav(r: TemplateRow) {
const res = await toggleFavorite('template', r.id);
r.favorited = res.favorited;
message.success(res.favorited ? '已收藏' : '已取消收藏');
}
async function onDelete(r: TemplateRow) {
await deleteTemplate(r.id);
message.success('已删除');
reload();
}
onMounted(reload);
</script>
<style scoped>
.filters { margin-bottom: 16px; }
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
.detail-sub { font-weight: 600; color: #b4232a; margin: 16px 0 8px; }
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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