Compare commits

...

10 Commits

Author SHA1 Message Date
zty d3abadf8eb feat: 材料 Excel 批量入库
- 后端 POST /materials/bulk(BulkCreateMaterialsDto,最多2000条)→createMany入自建库
- 前端 MaterialImportModal(自建库"Excel批量导入"):下载模板(xlsx,含表头+示例),
  拖拽上传→XLSX解析→按列映射5污染物×3参数→必填校验(标红错误行)→预览→批量导入
- 装 xlsx(SheetJS)前端解析,免后端文件处理
- 实测:3行(2有效1缺类别)→正确解析校验→导入2条到自建库

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:09:32 +08:00
zty d4f8bb3826 feat: 专业看板 Dashboard(还原设计稿暖绿主题)
复用设计包的 charts.js/dashboard.js/data.js(放 public/),Dashboard.vue
运行时加载并渲染 warm 主题看板:侧边栏+KPI+达标率环图+各房间甲醛柱状图+
6项污染物仪表盘+污染物雷达+甲醛衰减曲线+材料贡献+超标房间清单。
路由 /dashboard(需登录),落地页"专业看板"+顶栏"总览看板"接入。
注:当前为设计稿静态演示数据,后续可接后端聚合。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:36:11 +08:00
zty 325269c2fe feat: 历史记录页 + 快速导入项目 + 报告查看页
- 历史记录(History.vue): 列已生成报告项目,筛选,详情/查看报告/复用
- 复用: 后端 POST /projects/:id/duplicate 复制项目+空间+材料为新草稿
- 快速导入(ImportProjectModal): 首页"快速导入"→选模板→fromTemplateId建项目
- 报告查看(Report.vue, /report/:id): 封面+各空间5污染物预测+超标标红+
  污染源溯源,支持打印/导出PDF;配置页生成后/历史页"查看报告"跳此

注: 数据库 10.0.11.51 当前不可达,以上构建+类型检查通过,待DB恢复后实跑验证。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:19:37 +08:00
zty 3437a6d8f5 feat(phase4): C端污染源识别接真实材料库+统一引擎
- seed 官方算例6种真实材料(PM2000000x,真实Y0/Yp/B 5污染物)
- 预设样板间(rooms.ts)指向真实材料库;标准间复现官方算例
- SourceTracing 重写:从材料库拉真实参数,客户端调 predictSpace 统一引擎
  实时算5项污染物浓度/超标/各材料贡献溯源/污染源标红/整改建议
  (含按引擎反推的"提高通风至X次/h")
- 实测标准间复现算例:甲醛0.123(PDF0.12),家具贡献90.2%

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:50:18 +08:00
zty efb537e966 feat(phase4): 专业端空间污染源溯源视图
ProjectConfig 每个空间加"溯源"入口 → SpaceTracingModal:
5项污染物分tab,各显示预测浓度vs限值、达标/超标、各材料贡献率排序条、
超标时标红污染源材料(累计贡献>50%)并给整改建议。复用 generate 落库的
contributionRate,污染源前端按累计>50%算。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:34:54 +08:00
zty 022bd721ee feat(phase4): 接入真实预测计算模型(中国建材院)
替换占位公式,按模型精确实现 prediction.ts:
Ln=A/V → 筛Yp≥0.01 → Ln修正=Σ(Li·Ypi)/Ypn → Yn修正=Y0+Yp·e^(−Ln修正/B)
→ C密闭=Σ(Yi修正·Li) → E=0.51+8.74·C密闭/Σ(Li·Ypi) → U=1/(1+E·ACH)
→ C通风=U·C密闭 → WS=e^(0.102(T−23))·e^(1.2312(H−0.5)) → C=WS·C通风
贡献率 Gn=(Yn修正·Ln)/C密闭;污染源=超标污染物按Gn降序累计>50%。
结果新增 cClosed/limits/sources 字段。已用官方算例校验最终浓度全部±0.01通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:31:26 +08:00
zty 956954cf09 feat: C端污染源识别与专业端首页联动
- 专业首页"预测"区加"污染源识别·快速溯源"入口卡 → /source
- 落地页"专业看板"按钮:有token直达/home,否则去登录
- 污染源识别页顶栏加"进入专业系统 →"链接 → /home
- 两端共用同一套账号(手机号注册的用户也能进专业系统)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:14:12 +08:00
zty 2e87e4c0c2 feat: 引流版公开落地页 + 手机号验证码注册 + 污染源识别页
- 落地页(引流版主界面.html 还原): 暖绿杂志风,公开免登录,nav/hero/
  stats/资讯轮播/治理案例/三步/CTA/footer
- 手机号注册: 后端 /auth/sms/send(开发模式返回验证码) /auth/sms/verify
  (建/找手机号组织→发JWT); Organization 加 phone 字段
- 点"免费预测甲醛"等CTA → 手机注册弹窗 → 验证后跳 /source
- 污染源识别页(port source.jsx): 房间/材料输入→稳态质量平衡公式→
  超标判定→公式溯源各材料贡献→整改建议(应用通风/换E0板材)
- 路由: /landing /source 公开/半公开, 游客访问根路径落到 landing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:38:47 +08:00
zty e1dff63c59 fix: 选择材料弹窗内健康/环保下拉被遮挡(z-index)
弹窗 z-index 1100 高于 Ant 下拉默认 1050,导致下拉渲染在弹窗下方、
看起来是空的且选中不生效。给两个 select 加 getPopupContainer 渲染进
弹窗内部,下拉正常显示、选中与筛选恢复正常。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:13:25 +08:00
zty 7f03b11a95 feat: 选择材料-子类支持多选组合筛选
子类从单选改为多选,可同时勾选多个子类(带✓标记),下方材料列表
显示所选子类的合集(客户端组合过滤,切换即时生效)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:04:40 +08:00
40 changed files with 2704 additions and 90 deletions

View File

@ -15,7 +15,8 @@ model Organization {
id String @id @default(cuid())
username String @unique // 账号名,如 YPJKKJ
name String // 组织展示名,如 一品健康空间
passwordHash String
passwordHash String @default("") // 手机号注册的访客无密码
phone String? @unique // 手机号(访客注册)
createdAt DateTime @default(now())
materials Material[]

View File

@ -62,6 +62,38 @@ async function main() {
}
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({

View File

@ -1,18 +1,33 @@
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) {}
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) {

View File

@ -2,6 +2,7 @@ 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';
@ -13,7 +14,7 @@ import { JwtStrategy } from './jwt.strategy';
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}),
],
providers: [AuthService, JwtStrategy],
providers: [AuthService, SmsService, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}

View File

@ -0,0 +1,15 @@
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

@ -0,0 +1,54 @@
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

@ -0,0 +1,12 @@
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

@ -13,6 +13,7 @@ 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';
@ -36,6 +37,11 @@ export class MaterialsController {
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,

View File

@ -82,6 +82,27 @@ export class MaterialsService {
});
}
/** 批量入库(自建库)。返回成功条数。 */
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({

View File

@ -50,4 +50,9 @@ export class ProjectsController {
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

@ -63,6 +63,51 @@ export class ProjectsService {
});
}
/** 复用:把任意自有项目(或模板)复制成一个新草稿 */
async duplicate(orgId: string, id: string) {
const src = await this.prisma.project.findUnique({
where: { id },
include: { spaces: { include: { materials: true } } },
});
if (!src) throw new NotFoundException('项目不存在');
if (src.ownerOrgId !== orgId && !src.isPublic) throw new ForbiddenException('无权复用');
return this.prisma.project.create({
data: {
id: this.genId('P'),
name: src.name + ' (复用)',
type: src.type,
province: src.province,
city: src.city,
area: src.area,
status: 'configuring',
ownerOrgId: orgId,
spaces: {
create: src.spaces.map((s) => ({
id: this.genId('S'),
name: s.name,
type: s.type,
layout: s.layout,
height: s.height,
area: s.area,
volume: s.volume,
temperature: s.temperature,
humidity: s.humidity,
ventilationRate: s.ventilationRate,
standard: s.standard,
materials: {
create: s.materials.map((m) => ({
materialId: m.materialId,
usageUnit: m.usageUnit,
usageAmount: m.usageAmount,
})),
},
})),
},
},
});
}
async list(orgId: string, q: QueryProjectsDto) {
const where: Prisma.ProjectWhereInput = { ownerOrgId: orgId, isTemplate: false };
if (q.id) where.id = { contains: q.id, mode: 'insensitive' };

View File

@ -16,7 +16,8 @@
"axios": "^1.7.7",
"pinia": "^2.2.4",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
"vue-router": "^4.4.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",

View File

@ -0,0 +1,184 @@
/* 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

@ -0,0 +1,260 @@
/* 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

@ -0,0 +1,70 @@
/* 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

@ -67,6 +67,10 @@ 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);
}

View File

@ -82,6 +82,9 @@ export function deleteProject(id: string) {
export function generateReport(id: string) {
return http.post<any, ProjectDetail>(`/projects/${id}/generate`, {});
}
export function listProjects(params: { status?: string; unfinished?: string; page?: number; pageSize?: number }) {
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`, {});
}

10
apps/web/src/api/sms.ts Normal file
View File

@ -0,0 +1,10 @@
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

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

View File

@ -0,0 +1,173 @@
<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

@ -13,11 +13,11 @@
<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="全部" @change="reload">
<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="全部" @change="reload">
<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>
@ -33,28 +33,31 @@
@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="sub === null ? '#b4232a' : 'default'"
:color="!subs.length ? '#b4232a' : 'default'"
class="cas-tag"
@click="selectSub(null)"
@click="clearSubs"
>全部</a-tag>
<a-tag
v-for="s in currentSubs"
:key="s"
:color="sub === s ? '#b4232a' : 'default'"
:color="subs.includes(s) ? '#b4232a' : 'default'"
class="cas-tag"
@click="selectSub(s)"
>{{ s }}</a-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="sub"> / {{ sub }}</template> · {{ list.length }} </span>
<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>
@ -103,6 +106,7 @@
<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';
@ -119,13 +123,24 @@ const scope = ref<'public' | 'self'>('public');
const healthGrade = ref<string | undefined>(undefined);
const envGrade = ref<string | undefined>(undefined);
const major = ref<string>('');
const sub = ref<string | null>(null);
const list = ref<Material[]>([]);
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[]>();
@ -157,29 +172,33 @@ function healthColor(g: string) {
function selectMajor(m: string) {
major.value = m;
sub.value = null;
subs.value = [];
reload();
}
function selectSub(s: string | null) {
sub.value = s;
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 category = sub.value ? `${major.value}/${sub.value}` : major.value;
const res = await listMaterials({
category,
category: major.value,
healthGrade: healthGrade.value,
envGrade: envGrade.value,
scope: scope.value,
page: 1,
pageSize: 300,
});
list.value = res.items;
fullList.value = res.items;
} finally {
loading.value = false;
}
@ -207,7 +226,7 @@ watch(
(o) => {
if (o) {
if (!major.value) major.value = tree.value[0]?.major || '';
sub.value = null;
subs.value = [];
reload();
}
},
@ -220,6 +239,7 @@ watch(
.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

@ -0,0 +1,82 @@
<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

@ -0,0 +1,96 @@
<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

@ -0,0 +1,81 @@
// 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

@ -12,6 +12,7 @@
@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>

View File

@ -0,0 +1,50 @@
<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,7 +1,102 @@
<template>
<a-card title="历史预测记录">
<a-empty description="历史记录将在阶段 5 实现" />
<a-form layout="inline" class="filters">
<a-form-item label="工程名称"><a-input v-model:value="q.name" allow-clear @pressEnter="reload" /></a-form-item>
<a-form-item label="项目类型">
<a-select v-model:value="q.type" allow-clear style="width: 120px" @change="reload">
<a-select-option v-for="t in projectTypes" :key="t" :value="t">{{ t }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="预测评级">
<a-select v-model:value="q.rating" allow-clear style="width: 90px" @change="reload">
<a-select-option v-for="r in ratings" :key="r" :value="r">{{ r }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button @click="reset"> </a-button>
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
</a-form-item>
</a-form>
<a-table :columns="columns" :data-source="data.items" :loading="loading" :pagination="pagination" row-key="id" size="middle" @change="onTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'city'">{{ record.province }}/{{ record.city }}</template>
<template v-else-if="column.key === 'area'">{{ record.area }}</template>
<template v-else-if="column.key === 'rating'">
<a-tag :color="ratingColor(record.rating)">{{ record.rating || '-' }}</a-tag>
</template>
<template v-else-if="column.key === 'time'">{{ fmt(record.reportGeneratedAt) }}</template>
<template v-else-if="column.key === 'op'">
<a @click="goDetail(record)">详情</a>
<a-divider type="vertical" />
<a @click="goReport(record)">查看报告</a>
<a-divider type="vertical" />
<a @click="onReuse(record)">复用</a>
</template>
</template>
</a-table>
</a-card>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { PROJECT_TYPES, PREDICTION_RATINGS } from '@airpredict/shared';
import { listProjects, duplicateProject, type ProjectRow } from '../api/projects';
import type { Paged } from '../api/materials';
const router = useRouter();
const projectTypes = PROJECT_TYPES;
const ratings = PREDICTION_RATINGS;
const loading = ref(false);
const data = ref<Paged<ProjectRow>>({ total: 0, page: 1, pageSize: 10, items: [] });
const q = reactive<any>({ name: '', type: undefined, rating: undefined });
const page = ref(1);
const columns = [
{ title: '项目ID', dataIndex: 'id' },
{ title: '工程名称', dataIndex: 'name' },
{ title: '项目类型', dataIndex: 'type' },
{ title: '所在城市', key: 'city' },
{ title: '建筑面积', key: 'area' },
{ title: '空间数', dataIndex: 'spaceCount' },
{ title: '预测评级', key: 'rating' },
{ title: '生成报告时间', key: 'time' },
{ title: '操作', key: 'op', width: 180 },
];
const pagination = computed(() => ({
current: data.value.page, pageSize: data.value.pageSize, total: data.value.total,
showTotal: (t: number) => `${t}`,
}));
function fmt(s?: string) { return s ? new Date(s).toLocaleString() : '-'; }
function ratingColor(r?: string) { return ({ A: 'green', B: 'blue', C: 'orange', D: 'red' } as any)[r || ''] || 'default'; }
async function reload() {
loading.value = true;
try {
data.value = await listProjects({ status: 'report_generated', ...q, page: page.value, pageSize: 10 });
} finally {
loading.value = false;
}
}
function onTableChange(pg: any) { page.value = pg.current; reload(); }
function reset() { Object.keys(q).forEach((k) => (q[k] = undefined)); page.value = 1; reload(); }
function goDetail(r: ProjectRow) { router.push({ name: 'predict', params: { id: r.id } }); }
function goReport(r: ProjectRow) { router.push({ name: 'report', params: { id: r.id } }); }
async function onReuse(r: ProjectRow) {
const p = await duplicateProject(r.id);
message.success('已复用为新草稿');
router.push({ name: 'predict', params: { id: p.id } });
}
onMounted(reload);
</script>
<style scoped>
.filters { margin-bottom: 16px; }
.filters :deep(.ant-form-item) { margin-bottom: 12px; }
</style>

View File

@ -4,7 +4,7 @@
<a-divider />
<div class="group-title">预测</div>
<a-row :gutter="16">
<a-col :span="8" v-for="c in predictCards" :key="c.title">
<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>
@ -22,6 +22,7 @@
</a-row>
<NewProjectModal :open="createOpen" @ok="onCreated" @cancel="createOpen = false" />
<ImportProjectModal :open="importOpen" @created="onImported" @cancel="importOpen = false" />
</a-card>
</template>
@ -31,18 +32,20 @@ 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 todo = () => message.info('该功能将在后续阶段实现');
const createOpen = ref(false);
const importOpen = ref(false);
const predictCards = [
{ title: '新建项目预测', desc: '从头配置项目、空间、材料进行预测', action: () => (createOpen.value = true) },
{ title: '快速导入项目', desc: '根据模板或文件导入后调整配置预测', action: () => router.push({ name: 'template' }) },
{ title: '快速导入项目', desc: '根据模板导入后调整配置预测', action: () => (importOpen.value = true) },
{ title: '继续配置预测', desc: '继续已保存、未提交的配置', action: () => router.push({ name: 'drafts' }) },
{ title: '污染源识别 · 快速溯源', desc: '单空间快速预测,溯源主要污染材料', action: () => router.push('/source') },
];
const moreCards = [
{ title: '项目模板库', desc: '查看管理公共、自建的项目模板', action: () => router.push({ name: 'template' }) },
@ -54,6 +57,10 @@ 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>

View File

@ -0,0 +1,215 @@
<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

@ -30,7 +30,10 @@
<a-button type="primary" style="margin-left: 8px" @click="reload">查询</a-button>
</a-form-item>
</a-form>
<a-button v-if="scope === 'self'" type="primary" @click="openCreate">+ 新建材料</a-button>
<div 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
@ -95,6 +98,7 @@
</a-modal>
<MaterialFormModal :open="formOpen" :material="editing" @ok="onFormOk" @cancel="formOpen = false" />
<MaterialImportModal :open="importOpen" @ok="onImported" @cancel="importOpen = false" />
</a-card>
</template>
@ -105,6 +109,7 @@ 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;
@ -190,6 +195,13 @@ function onFormOk() {
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('已删除');

View File

@ -43,6 +43,8 @@
</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)">
@ -55,6 +57,7 @@
<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" />
@ -65,6 +68,7 @@
@ok="onSpaceSaved"
@cancel="drawerOpen = false"
/>
<SpaceTracingModal :open="tracingOpen" :space="tracingSpace" @cancel="tracingOpen = false" />
</div>
</template>
@ -77,6 +81,7 @@ import { getProject, generateReport, type ProjectDetail, type SpaceRow } from '.
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();
@ -116,6 +121,10 @@ async function load() {
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(); }

View File

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

View File

@ -0,0 +1,232 @@
<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

@ -2,10 +2,13 @@ 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: '/home',
redirect: '/landing',
children: [
{ path: 'home', name: 'home', component: () => import('../pages/Home.vue') },
{ path: 'template', name: 'template', component: () => import('../pages/TemplateLibrary.vue') },
@ -13,6 +16,7 @@ const routes = [
{ 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') },
],
},
];
@ -22,9 +26,16 @@ export const router = createRouter({
routes,
});
// 公开页面(游客可访问,无需登录)
const PUBLIC = new Set(['/landing', '/login']);
router.beforeEach((to) => {
const token = localStorage.getItem('token');
if (to.path !== '/login' && !token) return '/login';
if (to.path === '/login' && token) return '/home';
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

@ -19,11 +19,17 @@ export const useAuthStore = defineStore('auth', () => {
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, logout };
return { org, token, login, setSession, logout };
});

View File

@ -0,0 +1,166 @@
/* 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

@ -0,0 +1,142 @@
/* landing.css — 引流版主界面(面向游客)。暖色环保杂志风。变量挂在 .lp 上以隔离 Ant 主题。 */
.lp{
--bg:#f4f0e7; --bg2:#efe9dd; --panel:#fffdf8; --panel2:#f4efe3;
--border:#e9e1d2; --border2:#ded3bf;
--ink:#221d15; --sub:#6c6353; --faint:#a89c86;
--accent:#1f7a5a; --accent2:#2f9e74; --accent-deep:#165c43; --accent-soft:rgba(31,122,90,.12);
--good:#2f8f5b; --warn:#ca8326; --bad:#bf4a30;
--good-soft:rgba(47,143,91,.14); --bad-soft:rgba(191,74,48,.13);
--serif:'Songti SC','STSong',Georgia,'Times New Roman',serif;
--maxw:1180px;
background:var(--bg);color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',system-ui,sans-serif;
font-size:15px;line-height:1.6;letter-spacing:.1px;min-height:100vh;
}
.lp *{box-sizing:border-box;}
.lp a{color:inherit;text-decoration:none;cursor:pointer;}
.lp .wrap{max-width:var(--maxw);margin:0 auto;padding:0 28px;}
.lp .img-ph{display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(135deg,var(--panel2),var(--bg2));color:var(--faint);font-size:13px;border-radius:14px;padding:12px;}
/* nav */
.lp .nav{position:sticky;top:0;z-index:50;background:rgba(244,240,231,.9);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);}
.lp .nav-in{max-width:var(--maxw);margin:0 auto;padding:0 28px;height:68px;display:flex;align-items:center;gap:30px;}
.lp .brand{display:flex;align-items:center;gap:11px;}
.lp .brand-logo{width:38px;height:38px;border-radius:11px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;}
.lp .brand-logo svg{width:21px;height:21px;}
.lp .brand-tt{font-family:var(--serif);font-size:17px;font-weight:700;line-height:1.1;}
.lp .brand-sub{font-size:10px;letter-spacing:1.6px;color:var(--faint);text-transform:uppercase;}
.lp .nav-links{display:flex;gap:26px;margin-left:14px;}
.lp .nav-links a{font-size:14px;font-weight:500;color:var(--sub);}
.lp .nav-links a:hover{color:var(--accent);}
.lp .nav-sp{flex:1;}
.lp .btn{display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:600;border-radius:11px;padding:11px 20px;cursor:pointer;border:1px solid var(--border2);background:var(--panel);color:var(--ink);transition:.15s;white-space:nowrap;}
.lp .btn:hover{border-color:var(--accent);color:var(--accent);}
.lp .btn svg{width:16px;height:16px;}
.lp .btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 6px 18px rgba(31,122,90,.22);}
.lp .btn-primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);color:#fff;}
.lp .btn-lg{padding:14px 26px;font-size:15px;border-radius:13px;}
/* hero */
.lp .hero{padding:64px 0 52px;}
.lp .hero-grid{display:grid;grid-template-columns:1.05fr .95fr;gap:54px;align-items:center;}
.lp .eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;letter-spacing:.5px;color:var(--accent-deep);background:var(--accent-soft);border-radius:30px;padding:6px 14px;}
.lp .eyebrow .pip{width:7px;height:7px;border-radius:50%;background:var(--accent);}
.lp .hero h1{font-family:var(--serif);font-size:52px;line-height:1.18;font-weight:800;letter-spacing:-.5px;margin:20px 0 0;}
.lp .hero h1 em{font-style:normal;color:var(--accent);}
.lp .hero-lead{font-size:17px;color:var(--sub);margin:20px 0 0;max-width:30em;}
.lp .hero-cta{display:flex;gap:14px;margin-top:30px;}
.lp .hero-tags{display:flex;gap:22px;margin-top:26px;flex-wrap:wrap;}
.lp .hero-tag{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--sub);}
.lp .hero-tag svg{width:17px;height:17px;color:var(--accent);}
.lp .hero-visual{position:relative;}
.lp .hero-img{width:100%;height:420px;border-radius:22px;border:1px solid var(--border);}
.lp .hero-badge{position:absolute;right:-14px;top:26px;background:var(--ink);color:#fff;border-radius:13px;padding:11px 15px;box-shadow:0 14px 34px rgba(0,0,0,.22);}
.lp .hero-badge b{font-family:var(--serif);font-size:20px;display:block;}
.lp .hero-badge span{font-size:11px;opacity:.8;}
/* stats */
.lp .stats{background:var(--panel);border-top:1px solid var(--border);border-bottom:1px solid var(--border);}
.lp .stats-in{max-width:var(--maxw);margin:0 auto;padding:30px 28px;display:grid;grid-template-columns:repeat(4,1fr);gap:24px;}
.lp .stat{display:flex;flex-direction:column;gap:4px;border-left:2px solid var(--accent-soft);padding-left:18px;}
.lp .stat b{font-family:var(--serif);font-size:34px;font-weight:800;letter-spacing:-.5px;line-height:1;}
.lp .stat span{font-size:13px;color:var(--sub);}
/* section */
.lp .sec{padding:62px 0;}
.lp .sec-head{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:30px;gap:20px;}
.lp .sec-tag{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);text-transform:uppercase;}
.lp .sec-h{font-family:var(--serif);font-size:32px;font-weight:800;letter-spacing:-.4px;margin:8px 0 0;}
.lp .sec-sub{font-size:14.5px;color:var(--sub);margin-top:8px;max-width:34em;}
/* news carousel */
.lp .carousel{position:relative;}
.lp .cviewport{overflow:hidden;border-radius:20px;border:1px solid var(--border);background:var(--panel);}
.lp .ctrack{display:flex;transition:transform .55s cubic-bezier(.4,.8,.2,1);}
.lp .cslide{flex:0 0 100%;display:grid;grid-template-columns:46% 1fr;min-width:0;}
.lp .cslide-img{position:relative;min-height:340px;}
.lp .cslide-img .img-ph{position:absolute;inset:0;border-radius:0;}
.lp .cslide-tag{position:absolute;top:18px;left:18px;z-index:2;font-size:11.5px;font-weight:700;color:#fff;background:rgba(31,122,90,.92);border-radius:8px;padding:5px 12px;}
.lp .cslide-body{padding:42px 44px;display:flex;flex-direction:column;justify-content:center;}
.lp .cslide-date{font-size:12.5px;color:var(--faint);letter-spacing:.5px;}
.lp .cslide-body h3{font-family:var(--serif);font-size:27px;line-height:1.3;font-weight:800;margin:12px 0 0;letter-spacing:-.3px;}
.lp .cslide-body p{font-size:15px;color:var(--sub);margin:16px 0 0;}
.lp .cslide-more{display:inline-flex;align-items:center;gap:7px;font-size:14px;font-weight:600;color:var(--accent);margin-top:24px;}
.lp .cslide-more svg{width:16px;height:16px;}
.lp .cnav{position:absolute;top:50%;transform:translateY(-50%);width:46px;height:46px;border-radius:50%;border:1px solid var(--border);background:var(--panel);color:var(--ink);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 6px 18px rgba(60,45,20,.12);z-index:5;}
.lp .cnav:hover{background:var(--accent);color:#fff;border-color:var(--accent);}
.lp .cnav svg{width:20px;height:20px;}
.lp .cnav.prev{left:-23px;} .lp .cnav.next{right:-23px;}
.lp .cdots{display:flex;gap:9px;justify-content:center;margin-top:22px;}
.lp .cdot{width:9px;height:9px;border-radius:50%;border:none;background:var(--border2);cursor:pointer;padding:0;transition:.2s;}
.lp .cdot.on{background:var(--accent);width:26px;border-radius:5px;}
/* cases */
.lp .cases-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;}
.lp .case{background:var(--panel);border:1px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:.18s;}
.lp .case:hover{transform:translateY(-3px);box-shadow:0 16px 36px rgba(60,45,20,.12);}
.lp .case .img-ph{width:100%;height:172px;border-radius:0;}
.lp .case-b{padding:18px 19px 20px;}
.lp .case-type{font-size:11.5px;font-weight:700;color:var(--accent);letter-spacing:.5px;}
.lp .case h4{font-family:var(--serif);font-size:18px;font-weight:700;margin:7px 0 0;}
.lp .case-meta{font-size:12.5px;color:var(--faint);margin-top:3px;}
.lp .ba{margin-top:16px;display:flex;flex-direction:column;gap:9px;}
.lp .ba-row{display:grid;grid-template-columns:42px 1fr 62px;align-items:center;gap:10px;font-size:12px;}
.lp .ba-row .k{color:var(--sub);font-weight:600;}
.lp .ba-track{height:9px;background:var(--panel2);border-radius:5px;overflow:hidden;}
.lp .ba-fill{height:100%;border-radius:5px;}
.lp .ba-row .v{text-align:right;font-family:var(--serif);font-weight:800;font-variant-numeric:tabular-nums;}
.lp .case-foot{display:flex;align-items:center;justify-content:space-between;margin-top:16px;padding-top:14px;border-top:1px solid var(--border);}
.lp .chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;border-radius:8px;padding:4px 11px;}
.lp .chip::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;}
.lp .chip-good{color:var(--good);background:var(--good-soft);}
.lp .case-foot .lk{font-size:13px;font-weight:600;color:var(--accent);display:inline-flex;align-items:center;gap:6px;}
/* steps */
.lp .steps{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;}
.lp .step{background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:26px 24px;position:relative;}
.lp .step-n{font-family:var(--serif);font-size:14px;font-weight:800;color:var(--accent);width:34px;height:34px;border-radius:10px;background:var(--accent-soft);display:flex;align-items:center;justify-content:center;}
.lp .step h4{font-family:var(--serif);font-size:19px;font-weight:700;margin:16px 0 0;}
.lp .step p{font-size:14px;color:var(--sub);margin:9px 0 0;}
.lp .step-ar{position:absolute;right:-22px;top:50%;transform:translateY(-50%);color:var(--border2);z-index:2;}
.lp .step-ar svg{width:24px;height:24px;}
/* cta band */
.lp .ctaband{background:linear-gradient(135deg,var(--accent-deep),var(--accent));color:#fff;border-radius:24px;padding:52px 48px;display:flex;align-items:center;justify-content:space-between;gap:30px;overflow:hidden;position:relative;}
.lp .ctaband h2{font-family:var(--serif);font-size:34px;font-weight:800;letter-spacing:-.4px;margin:0;line-height:1.25;}
.lp .ctaband p{margin:12px 0 0;font-size:15.5px;opacity:.85;max-width:30em;}
.lp .ctaband .btn-primary{background:#fff;color:var(--accent-deep);border-color:#fff;}
.lp .ctaband .btn-primary:hover{background:#f4f0e7;color:var(--accent-deep);border-color:#f4f0e7;}
.lp .ctaband .btn{background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.3);color:#fff;}
.lp .ctaband .deco{position:absolute;right:-40px;top:-40px;width:220px;height:220px;border:32px solid rgba(255,255,255,.07);border-radius:50%;}
/* footer */
.lp .footer{border-top:1px solid var(--border);margin-top:62px;}
.lp .footer-in{max-width:var(--maxw);margin:0 auto;padding:34px 28px;display:flex;align-items:center;justify-content:space-between;gap:20px;color:var(--faint);font-size:13px;}
.lp .footer .brand-tt{font-size:15px;}
@media(max-width:900px){
.lp .hero-grid,.lp .cslide{grid-template-columns:1fr;}
.lp .stats-in,.lp .cases-grid,.lp .steps{grid-template-columns:1fr 1fr;}
.lp .nav-links{display:none;}
.lp .hero h1{font-size:38px;}
}

View File

@ -0,0 +1,122 @@
/* 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

@ -14,110 +14,167 @@ export interface EmissionParams {
/** 一种材料在某空间的用量 + 各污染物散发参数 */
export interface SpaceMaterialInput {
materialId: string;
/** 使用量(配合用量单位,通常为面积 m² 或其它) */
/** 使用面积 A (m²)。长度单位时调用方需先 ×0.1 折算 */
usageAmount: number;
/** 每项污染物的散发参数 */
params: Record<Pollutant, EmissionParams>;
}
/** 空间环境条件 */
export interface SpaceConditions {
/** 体积 m³ */
/** 体积 V () */
volume: number;
/** 温度 */
/** 温度 T (°C) */
temperature: number;
/** 湿度 %rh */
/** 湿度 H (%rh,如 50内部折算为小数) */
humidity: number;
/** 通风换气率 次/小时 (ACH) */
/** 通风换气次数 ACH (次/小时) */
ventilationRate: number;
standard: StandardCode;
}
export interface MaterialContribution {
materialId: string;
/** 每项污染物对最终浓度的贡献 (mg/m³) */
/** 各污染物:该材料占最终浓度的份额 (mg/m³) = Gn × C */
contribution: Record<Pollutant, number>;
/** 每项污染物的贡献率 0~1 */
/** 各污染物贡献率 Gn (0~1) = (Yn修正·Ln)/C密闭 */
contributionRate: Record<Pollutant, number>;
}
export interface SpacePredictionResult {
/** 各污染物预测浓度 mg/m³ */
/** 各污染物最终预测浓度 C (mg/m³) */
concentration: Record<Pollutant, number>;
/** 各污染物密闭浓度 C密闭 (mg/m³),贡献率分母 */
cClosed: Record<Pollutant, number>;
/** 所用标准的各污染物限值 */
limits: Record<Pollutant, number>;
/** 各污染物是否超标 */
exceeded: Record<Pollutant, boolean>;
/** 各材料贡献 */
contributions: MaterialContribution[];
/** 各污染物的污染源材料ID仅超标污染物累计贡献率>50%的前几个材料) */
sources: Record<Pollutant, string[]>;
rating: PredictionRating;
}
/** 平衡释放量范围阈值Yp < 0.01 视为该污染物不释放 */
const YP_THRESHOLD = 0.01;
const zero = (): Record<Pollutant, number> =>
Object.fromEntries(POLLUTANTS.map((p) => [p, 0])) as Record<Pollutant, number>;
/**
*
* /湿
*
* (mg/m²·h)
*
* @param p Y0/Yp/B
* @param cond 湿
*/
export function emissionRate(p: EmissionParams, cond: SpaceConditions): number {
// —— PLACEHOLDER替换为你的标定公式 ——
// 占位:以 Y0 为基线Yp 受温度偏离 25℃ 影响B 作为湿度/装载修正系数。
const tempFactor = 1 + 0.02 * (cond.temperature - 25); // 每偏离1℃修正2%
const humFactor = 1 + 0.005 * (cond.humidity - 50); // 每偏离50%rh修正0.5%
const rate = (p.y0 + p.yp * Math.max(0, tempFactor - 1) + p.b * humFactor * 0.0) * tempFactor * humFactor;
return Math.max(0, rate);
}
/**
*
*
* G = = ACH × V × C => C = G / (ACH × V)
* G_pollutant = Σ_material ( emissionRate × usageAmount )
* 20240307
*
* Ln=A/V Yp0.01 Ln修正=Σ(Li·Ypi)/Ypn Yn修正=Y0+Yp·e^(Ln修正/B)
* C密闭=Σ(Yi修正·Li) E=0.51+8.74·C密闭/Σ(Li·Ypi) U=1/(1+E·ACH)
* C通风=U·C密闭 WS=e^(0.102(T23))·e^(1.2312(H0.5)) C=WS·C通风
* Gn=(Yn修正·Ln)/C密闭=Gn降序累计>50%
*/
export function predictSpace(
materials: SpaceMaterialInput[],
cond: SpaceConditions,
): SpacePredictionResult {
const V = Math.max(1e-6, cond.volume);
const ACH = Math.max(0, cond.ventilationRate);
const T = cond.temperature;
const H = cond.humidity > 1 ? cond.humidity / 100 : cond.humidity; // %rh → 小数
const WS = Math.exp(0.102 * (T - 23)) * Math.exp(1.2312 * (H - 0.5));
const limits = STANDARD_LIMITS[cond.standard];
const concentration = zero();
const denom = Math.max(1e-6, cond.ventilationRate * cond.volume);
// 累计各材料各污染物的生成量
const rawContrib: Record<string, Record<Pollutant, number>> = {};
// 真实承载率 Ln = A/V
const Ln = new Map<string, number>();
for (const m of materials) Ln.set(m.materialId, m.usageAmount / V);
const concentration = zero();
const cClosed = zero();
const exceeded = {} as Record<Pollutant, boolean>;
const sources = {} as Record<Pollutant, string[]>;
const contribByMat: Record<string, Record<Pollutant, number>> = {};
const rateByMat: Record<string, Record<Pollutant, number>> = {};
for (const m of materials) {
rawContrib[m.materialId] = zero();
for (const pol of POLLUTANTS) {
const G = emissionRate(m.params[pol], cond) * m.usageAmount;
const c = G / denom;
rawContrib[m.materialId][pol] = c;
concentration[pol] += c;
contribByMat[m.materialId] = zero();
rateByMat[m.materialId] = zero();
}
for (const p of POLLUTANTS) {
// 1. 筛选释放该污染物的材料Yp≥0.01
const emitters = materials.filter((m) => (m.params[p]?.yp ?? 0) >= YP_THRESHOLD);
// Σ(Li·Ypi)
const sumLYp = emitters.reduce((s, m) => s + Ln.get(m.materialId)! * m.params[p].yp, 0);
// 2. Yn修正 = Y0 + Yp·e^(Ln修正/B),其中 Ln修正 = Σ(Li·Ypi)/Ypn
const yCorr = new Map<string, number>();
for (const m of emitters) {
const { y0, yp, b } = m.params[p];
const lnCorr = yp > 0 ? sumLYp / yp : 0;
const term = b > 0 ? yp * Math.exp(-lnCorr / b) : 0;
yCorr.set(m.materialId, y0 + term);
}
// 3. 密闭浓度 C密闭 = Σ(Yn修正·Ln)
const cClose = emitters.reduce((s, m) => s + yCorr.get(m.materialId)! * Ln.get(m.materialId)!, 0);
cClosed[p] = round3(cClose);
// 4. 通风修正
const E = sumLYp > 0 ? 0.51 + 8.74 * (cClose / sumLYp) : 0;
const U = 1 / (1 + E * ACH);
const cVent = U * cClose;
// 5. 温湿度修正 → 最终浓度
const C = WS * cVent;
concentration[p] = round3(C);
// 6. 贡献率 Gn = (Yn修正·Ln)/C密闭
for (const m of emitters) {
const gn = cClose > 0 ? (yCorr.get(m.materialId)! * Ln.get(m.materialId)!) / cClose : 0;
rateByMat[m.materialId][p] = gn;
contribByMat[m.materialId][p] = round3(gn * C);
}
// 7. 超标判定 + 污染源累计Gn>50%
const over = C > limits[p];
exceeded[p] = over;
if (over) {
const sorted = emitters
.map((m) => ({ id: m.materialId, gn: rateByMat[m.materialId][p] }))
.sort((a, b) => b.gn - a.gn);
const src: string[] = [];
let cum = 0;
for (const s of sorted) {
src.push(s.id);
cum += s.gn;
if (cum > 0.5) break;
}
sources[p] = src;
} else {
sources[p] = [];
}
}
// 贡献率
const contributions: MaterialContribution[] = materials.map((m) => {
const contribution = rawContrib[m.materialId];
const contributionRate = zero();
for (const pol of POLLUTANTS) {
contributionRate[pol] = concentration[pol] > 0 ? contribution[pol] / concentration[pol] : 0;
}
return { materialId: m.materialId, contribution, contributionRate };
});
const contributions: MaterialContribution[] = materials.map((m) => ({
materialId: m.materialId,
contribution: contribByMat[m.materialId],
contributionRate: rateByMat[m.materialId],
}));
const exceeded = Object.fromEntries(
POLLUTANTS.map((p) => [p, concentration[p] > limits[p]]),
) as Record<Pollutant, boolean>;
return {
concentration,
cClosed,
limits,
exceeded,
contributions,
sources,
rating: ratingFor(concentration, cond.standard),
};
}
return { concentration, exceeded, contributions, rating: ratingFor(concentration, cond.standard) };
function round3(n: number): number {
return Math.round(n * 1000) / 1000;
}
/**
*
* A: 全部 60% ; B: 100%; C: 150%; D: 超过
* /"待补充"
* A60% B100% C150% D>150%
*/
export function ratingFor(
concentration: Record<Pollutant, number>,

View File

@ -104,6 +104,9 @@ importers:
vue-router:
specifier: ^4.4.5
version: 4.6.4(vue@3.5.35(typescript@5.9.3))
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.1.4
@ -823,6 +826,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@ -994,6 +1001,10 @@ packages:
caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -1043,6 +1054,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1112,6 +1127,11 @@ packages:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
@ -1357,6 +1377,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@ -2005,6 +2029,10 @@ packages:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -2338,6 +2366,14 @@ packages:
engines: {node: '>= 8'}
hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@ -2350,6 +2386,11 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@ -3059,6 +3100,8 @@ snapshots:
acorn@8.16.0: {}
adler-32@1.3.1: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@ -3268,6 +3311,11 @@ snapshots:
caniuse-lite@1.0.30001797: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -3317,6 +3365,8 @@ snapshots:
clone@1.0.4: {}
codepage@1.15.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -3380,6 +3430,8 @@ snapshots:
optionalDependencies:
typescript: 5.7.2
crc-32@1.2.2: {}
create-require@1.1.1: {}
cross-spawn@7.0.6:
@ -3648,6 +3700,8 @@ snapshots:
forwarded@0.2.0: {}
frac@1.1.2: {}
fresh@0.5.2: {}
fs-extra@10.1.0:
@ -4328,6 +4382,10 @@ snapshots:
source-map@0.7.4: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
statuses@2.0.2: {}
streamsearch@1.1.0: {}
@ -4602,6 +4660,10 @@ snapshots:
dependencies:
isexe: 2.0.0
wmf@1.0.2: {}
word@0.3.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@ -4620,6 +4682,16 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xtend@4.0.2: {}
yargs-parser@21.1.1: {}