Compare commits
10 Commits
3bbafc99d7
...
d3abadf8eb
| Author | SHA1 | Date |
|---|---|---|
|
|
d3abadf8eb | |
|
|
d4f8bb3826 | |
|
|
325269c2fe | |
|
|
3437a6d8f5 | |
|
|
efb537e966 | |
|
|
022bd721ee | |
|
|
956954cf09 | |
|
|
2e87e4c0c2 | |
|
|
e1dff63c59 | |
|
|
7f03b11a95 |
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -62,6 +62,38 @@ async function main() {
|
|||
}
|
||||
console.log(`已导入 ${MATERIALS.length} 条公共材料(散发参数为占位值,待替换真实检测数据)`);
|
||||
|
||||
// 官方算例 6 种材料(真实 Y0/Yp/B,5 污染物),供 C 端样板间 + 复现算例
|
||||
const ep5 = (
|
||||
hcho: number[], tvoc: number[], benzene: number[], toluene: number[], xylene: number[],
|
||||
) => ({
|
||||
hcho: { y0: hcho[0], yp: hcho[1], b: hcho[2] },
|
||||
tvoc: { y0: tvoc[0], yp: tvoc[1], b: tvoc[2] },
|
||||
benzene: { y0: benzene[0], yp: benzene[1], b: benzene[2] },
|
||||
toluene: { y0: toluene[0], yp: toluene[1], b: toluene[2] },
|
||||
xylene: { y0: xylene[0], yp: xylene[1], b: xylene[2] },
|
||||
});
|
||||
const REAL_MATERIALS = [
|
||||
{ id: 'PM20000001', name: '多层实木复合地板', category: '木地板/实木地板', brand: '示例', healthGrade: 'B', ep: ep5([0.09, 0.4, 0.47], [0.074, 0.7, 0.205], [0.03, 0.186, 0.265], [0, 0, 0], [0, 0, 0]) },
|
||||
{ id: 'PM20000002', name: '踢脚线', category: '其他', brand: '示例', healthGrade: 'C', ep: ep5([0.38, 2.3, 0.2], [0.25, 1.69, 0.113], [0.053, 0.446, 0.09], [0.05, 0.229, 0.1], [0.009, 0.35, 0.085]) },
|
||||
{ id: 'PM20000003', name: '吸音板', category: '其他', brand: '示例', healthGrade: 'B', ep: ep5([0, 0, 0], [0.24, 1.71, 0.09], [0, 0, 0], [0, 0, 0], [0, 0, 0]) },
|
||||
{ id: 'PM20000004', name: '乳胶漆涂料', category: '涂料/墙面漆', brand: '示例', healthGrade: 'A', ep: ep5([0, 0, 0], [0.04, 0.337, 0.288], [0, 0, 0], [0, 0, 0], [0, 0, 0]) },
|
||||
{ id: 'PM20000005', name: '免漆木门', category: '其他', brand: '示例', healthGrade: 'C', ep: ep5([0, 0, 0], [0.46, 3.05, 0.132], [0.07, 0.53, 0.27], [0.05, 0.41, 0.29], [0.194, 1.24, 0.223]) },
|
||||
{ id: 'PM20000006', name: '人造板家具', category: '家具', brand: '示例', healthGrade: 'C', ep: ep5([0.45, 2.63, 0.446], [0.14, 0.88, 0.36], [0, 0, 0], [0.08, 0.5, 0.39], [0, 0, 0]) },
|
||||
];
|
||||
for (let i = 0; i < REAL_MATERIALS.length; i++) {
|
||||
const m = REAL_MATERIALS[i];
|
||||
await prisma.material.upsert({
|
||||
where: { id: m.id },
|
||||
update: { emissionParams: m.ep, healthGrade: m.healthGrade },
|
||||
create: {
|
||||
id: m.id, name: m.name, category: m.category, brand: m.brand,
|
||||
healthGrade: m.healthGrade, sortOrder: 1000 + i, usageUnit: 'm²',
|
||||
emissionParams: m.ep, isPublic: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`已导入 ${REAL_MATERIALS.length} 条官方算例真实材料(PM2000000x)`);
|
||||
|
||||
// 一条公共项目模板(含 1 个空间 + 2 种材料),供模板库展示
|
||||
const tplId = 'T13000001';
|
||||
await prisma.project.upsert({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 } };
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
@ -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/m³</span></div>
|
||||
${C.columns({ items: D.rooms, limit: D.roomLimit, limit2: D.roomLimit2, unit: 'mg/m³', p, w: 760, h: 268 })}
|
||||
</div>`;
|
||||
|
||||
const donutCard = `<div class="card sp4">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>项目达标率</div><span class="card-tag">${totalRooms} 间房</span></div>
|
||||
${complianceViz(opts.compliance || 'donut', p, totalRooms)}
|
||||
</div>`;
|
||||
|
||||
const gaugeCard = `<div class="card sp5">
|
||||
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>最近一次预测 · 主卧</div><div class="card-sub">${D.project.updated} · 6 项污染物 vs 国标限值</div></div></div>
|
||||
<div class="d-alert">${ICON.alert}<span>甲醛 ${hcho.value} mg/m³ · 超出 GB/T 18883 限值 ${over}%,建议加强通风并核查人造板材料</span></div>
|
||||
${pollutantViz(opts.pollutant || 'ring', p)}
|
||||
</div>`;
|
||||
|
||||
const radarCard = `<div class="card sp4">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>污染物雷达</div><span class="card-tag">主卧</span></div>
|
||||
<div class="card-sub" style="margin:-8px 0 2px">虚线红环 = 国标限值(比值 1.0)</div>
|
||||
${C.radar({ axes: D.pollutants.map(d => ({ name: d.name, value: d.value, limit: d.limit })), p, size: 250 })}
|
||||
</div>`;
|
||||
|
||||
const decayCard = `<div class="card sp3">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>甲醛衰减</div></div>
|
||||
<div class="card-sub" style="margin:-8px 0 8px">随通风天数</div>
|
||||
${C.decayArea({ points: D.decay, limit: D.roomLimit, unit: 'mg/m³', p, w: 300, h: 176 })}
|
||||
</div>`;
|
||||
|
||||
const tableRows = D.exceed.map(r => {
|
||||
const ratio = r.value / r.limit;
|
||||
return `<tr><td><div class="rm">${r.room}</div><div class="pj">${r.project}</div></td>
|
||||
<td><span class="d-pol">${r.pollutant}</span></td>
|
||||
<td class="vn" style="color:${C.status(ratio, p)}">${C.fmt(r.value, r.value >= 10 ? 0 : 3)}</td>
|
||||
<td class="lm">${C.fmt(r.limit, 2)}</td>
|
||||
<td><span class="d-chip chip-${r.level}">${cnLevel[r.level]} ${Math.round(ratio * 100)}%</span></td></tr>`;
|
||||
}).join('');
|
||||
const tableCard = `<div class="card sp8">
|
||||
<div class="card-h"><div class="card-t"><span class="d-bar"></span>超标房间清单</div><span class="card-tag">${ICON.alert ? '' : ''}共 ${D.exceed.length} 条 · 按超标率排序</span></div>
|
||||
<table class="d-tbl"><thead><tr><th>房间 / 项目</th><th>污染物</th><th>预测浓度</th><th>限值</th><th>状态</th></tr></thead>
|
||||
<tbody>${tableRows}</tbody></table></div>`;
|
||||
|
||||
const matCard = `<div class="card sp4">
|
||||
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>污染源识别 · 材料贡献</div><div class="card-sub">主卧甲醛 · 公式溯源排行</div></div></div>
|
||||
${C.hbars({ items: D.materials.map((m, i) => ({ ...m, color: i === 0 ? p.bad : i === 1 ? p.warn : p.accent })), p, w: 340, rowH: 40 })}
|
||||
</div>`;
|
||||
|
||||
let body;
|
||||
if (warm) {
|
||||
const heroComp = `<div class="d-hero"><div class="d-hero-l">
|
||||
${C.donut({ segments: D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] })), centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p, size: 150 })}
|
||||
<div class="d-hero-txt"><div class="t">在管 ${D.kpis.projects} 个项目 · ${totalRooms} 间房</div>
|
||||
<div class="n">${D.compliance[2].value} 间超标 · ${D.compliance[1].value} 间临界</div>
|
||||
<div class="t" style="margin-top:8px">最近更新 ${D.project.updated}</div></div>
|
||||
</div>
|
||||
<div class="d-kpis" style="margin:0">
|
||||
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', '')}
|
||||
${kpiCard('达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}</span>`)}
|
||||
${kpiCard('超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', '')}
|
||||
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', '')}
|
||||
</div></div>`;
|
||||
body = heroComp + `<div class="d-grid">
|
||||
${colCard}
|
||||
${gaugeCard.replace('sp5','sp4')}
|
||||
${radarCard}
|
||||
${decayCard.replace('sp3','sp4')}
|
||||
${matCard}
|
||||
${tableCard.replace('sp8','sp12')}
|
||||
</div>`;
|
||||
} else {
|
||||
body = kpis + `<div class="d-grid">
|
||||
${colCard}${donutCard}
|
||||
${gaugeCard}${radarCard}${decayCard}
|
||||
${tableCard}${matCard}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="dash ${T.cls}" style="${styleVars}">${C.defs(p)}${side}
|
||||
<div class="d-main">${top}<div class="d-scroll">${body}</div></div></div>`;
|
||||
};
|
||||
})();
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`, {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 }}m²</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a-button type="link" size="small" :loading="usingId === record.id" @click="useTemplate(record)">使用此模板</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { listTemplates, type TemplateRow } from '../api/templates';
|
||||
import { createProject } from '../api/projects';
|
||||
import type { Paged } from '../api/materials';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'created', id: string): void; (e: 'cancel'): void }>();
|
||||
|
||||
const scope = ref<'public' | 'self'>('public');
|
||||
const loading = ref(false);
|
||||
const usingId = ref('');
|
||||
const data = ref<Paged<TemplateRow>>({ total: 0, page: 1, pageSize: 8, items: [] });
|
||||
const page = ref(1);
|
||||
|
||||
const columns = [
|
||||
{ title: '模板ID', dataIndex: 'id' },
|
||||
{ title: '工程名称', dataIndex: 'name' },
|
||||
{ title: '项目类型', dataIndex: 'type' },
|
||||
{ title: '所在城市', key: 'city' },
|
||||
{ title: '建筑面积', key: 'area' },
|
||||
{ title: '空间数', dataIndex: 'spaceCount' },
|
||||
{ title: '操作', key: 'op', width: 120 },
|
||||
];
|
||||
const pagination = computed(() => ({ current: data.value.page, pageSize: data.value.pageSize, total: data.value.total }));
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await listTemplates({ scope: scope.value, page: page.value, pageSize: 8 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function onTableChange(pg: any) { page.value = pg.current; reload(); }
|
||||
|
||||
async function useTemplate(r: TemplateRow) {
|
||||
usingId.value = r.id;
|
||||
try {
|
||||
const p = await createProject({
|
||||
name: r.name, type: r.type, province: r.province, city: r.city, area: r.area, fromTemplateId: r.id,
|
||||
});
|
||||
message.success('已按模板创建项目');
|
||||
emit('created', p.id);
|
||||
} finally {
|
||||
usingId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (o) => { if (o) { page.value = 1; reload(); } });
|
||||
</script>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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/m³</b>
|
||||
<span class="limit">限值 {{ limits[p] }} mg/m³({{ space.standard }})</span>
|
||||
</div>
|
||||
|
||||
<div class="bars">
|
||||
<div class="bar-row" v-for="(c, i) in ranked(p)" :key="c.id">
|
||||
<div class="nm">
|
||||
<span class="rk" :style="{ background: barColor(i) }">{{ i + 1 }}</span>{{ c.name }}
|
||||
<a-tag v-if="isSource(p, c.id)" color="red" size="small">污染源</a-tag>
|
||||
</div>
|
||||
<div class="track"><div class="fill" :style="{ width: Math.max(2, c.rate / maxRate(p) * 100) + '%', background: barColor(i) }" /></div>
|
||||
<div class="val">{{ (c.rate * 100).toFixed(1) }}%</div>
|
||||
</div>
|
||||
<div v-if="!ranked(p).length" class="muted">该污染物无材料释放</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exceeded(p)" class="sugg">
|
||||
💡 <b>{{ labels[p].zh }}超标</b>。主要污染源:<b>{{ sourceNames(p) }}</b>(累计贡献 {{ sourceCumPct(p) }}%)。建议优先更换/减少这些材料,或提高通风换气率。
|
||||
</div>
|
||||
<div v-else class="sugg ok">✓ {{ labels[p].zh }}达标,余量 {{ marginPct(p) }}%。</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||||
import type { SpaceRow } from '../api/projects';
|
||||
|
||||
const props = defineProps<{ open: boolean; space?: SpaceRow | null }>();
|
||||
const emit = defineEmits<{ (e: 'cancel'): void }>();
|
||||
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const active = ref<Pollutant>('hcho');
|
||||
watch(() => props.open, (o) => { if (o) active.value = 'hcho'; });
|
||||
|
||||
const limits = computed(() => STANDARD_LIMITS[(props.space?.standard as StandardCode) || 'GB50325-2020']);
|
||||
const fmt = (n: number) => Number(n ?? 0).toFixed(3);
|
||||
const conc = (p: Pollutant) => props.space?.predictedConc?.[p] ?? 0;
|
||||
const exceeded = (p: Pollutant) => conc(p) > (limits.value[p] ?? Infinity);
|
||||
const marginPct = (p: Pollutant) => Math.max(0, Math.round((1 - conc(p) / (limits.value[p] || 1)) * 100));
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
function ranked(p: Pollutant): Row[] {
|
||||
const ms = props.space?.materials || [];
|
||||
return ms
|
||||
.map((m) => ({ id: m.materialId, name: m.material?.name || m.materialId, rate: m.contributionRate?.[p] ?? 0 }))
|
||||
.filter((r) => r.rate > 0)
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}
|
||||
const maxRate = (p: Pollutant) => (ranked(p)[0]?.rate || 1);
|
||||
function sources(p: Pollutant): Row[] {
|
||||
const r = ranked(p);
|
||||
const out: Row[] = [];
|
||||
let cum = 0;
|
||||
for (const x of r) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
|
||||
return out;
|
||||
}
|
||||
const isSource = (p: Pollutant, id: string) => exceeded(p) && sources(p).some((s) => s.id === id);
|
||||
const sourceNames = (p: Pollutant) => sources(p).map((s) => s.name).join('、');
|
||||
const sourceCumPct = (p: Pollutant) => Math.round(sources(p).reduce((s, x) => s + x.rate, 0) * 100);
|
||||
const barColor = (i: number) => (i === 0 ? '#b4232a' : i === 1 ? '#ca8326' : '#1f7a5a');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conc-line { margin: 4px 0 16px; color: #555; }
|
||||
.conc-line b { font-size: 18px; margin: 0 8px; }
|
||||
.conc-line b.over { color: #b4232a; }
|
||||
.conc-line .limit { color: #999; font-size: 13px; }
|
||||
.bars { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bar-row { display: grid; grid-template-columns: 220px 1fr 56px; align-items: center; gap: 12px; }
|
||||
.nm { font-size: 13px; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rk { display: inline-flex; width: 18px; height: 18px; border-radius: 5px; color: #fff; font-size: 11px; font-weight: 700; align-items: center; justify-content: center; }
|
||||
.track { height: 16px; background: #f0f0f0; border-radius: 5px; overflow: hidden; }
|
||||
.fill { height: 100%; border-radius: 5px; }
|
||||
.val { text-align: right; font-weight: 700; font-size: 13px; }
|
||||
.sugg { margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #fff7f5; border: 1px solid #f0d0c8; font-size: 13px; line-height: 1.6; }
|
||||
.sugg.ok { background: #f3faf6; border-color: #cce8d8; }
|
||||
.muted { color: #aaa; }
|
||||
</style>
|
||||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 }}m²</template>
|
||||
<template v-else-if="column.key === 'rating'">
|
||||
<a-tag :color="ratingColor(record.rating)">{{ record.rating || '-' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'time'">{{ fmt(record.reportGeneratedAt) }}</template>
|
||||
<template v-else-if="column.key === 'op'">
|
||||
<a @click="goDetail(record)">详情</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="goReport(record)">查看报告</a>
|
||||
<a-divider type="vertical" />
|
||||
<a @click="onReuse(record)">复用</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: '新装住宅的甲醛,为什么能持续释放 3–15 年?', desc: '人造板里的脲醛树脂胶会缓慢分解释放甲醛,释放周期长、受温湿度影响大。短期通风只能降一时浓度,真正的关键在源头材料的选择。' },
|
||||
{ tag: '指南', date: '专栏 · 2026.03', title: '夏天为什么更容易超标?温度与释放速率', desc: '温度每升高若干度,材料的甲醛释放速率会明显增大。这也是"冬天测达标、夏天又超标"的原因。预测时把环境温湿度纳入计算,结果才靠谱。' },
|
||||
{ tag: '方法', date: '专栏 · 2026.02', title: '先预测,再决定要不要做 CMA 检测', desc: '上门检测有成本。用本系统先做一次免费预测、定位高风险房间与主要污染材料,再有针对性地安排第三方 CMA 检测,省钱也更有的放矢。' },
|
||||
];
|
||||
const cases = [
|
||||
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' },
|
||||
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07', chip: '甲醛达标' },
|
||||
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46', chip: 'TVOC达标' },
|
||||
];
|
||||
const steps = [
|
||||
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
|
||||
{ h: '公式计算浓度', p: '按稳态质量平衡 C = Σ(EFᵢ·Aᵢ)/(n·V) 计算 6 项污染物浓度,对照国标判定达标。' },
|
||||
{ h: '溯源与整改建议', p: '若超标,公式溯源出贡献最大的材料并排序,给出通风或换材的可行整改方案。' },
|
||||
];
|
||||
|
||||
// 轮播
|
||||
const cur = ref(0);
|
||||
let timer: any = null;
|
||||
function go(n: number) { cur.value = (n + news.length) % news.length; }
|
||||
function next() { go(cur.value + 1); start(); }
|
||||
function prev() { go(cur.value - 1); start(); }
|
||||
function start() { stop(); timer = setInterval(() => go(cur.value + 1), 5500); }
|
||||
function stop() { if (timer) clearInterval(timer); timer = null; }
|
||||
onMounted(start);
|
||||
onBeforeUnmount(stop);
|
||||
|
||||
function scrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
|
||||
function scrollTo(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); }
|
||||
function goPro() { router.push(localStorage.getItem('token') ? '/dashboard' : '/login'); }
|
||||
|
||||
// 预测入口 → 手机注册
|
||||
const authOpen = ref(false);
|
||||
function openPredict() {
|
||||
if (localStorage.getItem('token')) router.push('/source');
|
||||
else authOpen.value = true;
|
||||
}
|
||||
function onAuthed() {
|
||||
authOpen.value = false;
|
||||
router.push('/source');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="../styles/landing.css"></style>
|
||||
|
|
@ -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('已删除');
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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 }}m²</span>
|
||||
</div>
|
||||
<div class="cover-rating">
|
||||
预测评级 <b :class="'r-' + (project.rating || 'A')">{{ project.rating || '-' }}</b>
|
||||
<span class="gen">生成时间:{{ fmt(project.reportGeneratedAt) }}</span>
|
||||
</div>
|
||||
<div class="cover-summary">
|
||||
预测结果:共 {{ project.spaces.length }} 个空间,其中
|
||||
<b :class="{ bad: overSpaces > 0 }">{{ overSpaces }}</b> 个存在污染物浓度超标。
|
||||
依据 {{ standardsUsed }}。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各空间 -->
|
||||
<div class="space-block" v-for="(s, idx) in project.spaces" :key="s.id">
|
||||
<div class="sb-head">
|
||||
<h2>空间 {{ idx + 1 }} · {{ s.name }}</h2>
|
||||
<span class="sb-meta">{{ s.type }} · {{ s.area }}m² · {{ s.temperature }}℃ · {{ s.humidity }}%rh · 通风 {{ s.ventilationRate }}次/h · {{ s.standard }}</span>
|
||||
</div>
|
||||
|
||||
<table class="conc-table">
|
||||
<thead><tr><th>污染物</th><th v-for="p in pollutants" :key="p">{{ labels[p].zh }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>限值 (mg/m³)</td><td v-for="p in pollutants" :key="p">{{ limitOf(s, p) }}</td></tr>
|
||||
<tr>
|
||||
<td>预测浓度 (mg/m³)</td>
|
||||
<td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">
|
||||
{{ fmt3(s.predictedConc?.[p]) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>判定</td><td v-for="p in pollutants" :key="p" :class="{ over: isOver(s, p) }">{{ isOver(s, p) ? '超标' : '达标' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 污染源溯源(仅超标污染物) -->
|
||||
<div v-if="overPollutants(s).length" class="trace">
|
||||
<div class="trace-h">污染源溯源</div>
|
||||
<div v-for="p in overPollutants(s)" :key="p" class="trace-pol">
|
||||
<div class="tp-name">{{ labels[p].zh }}(超标)主要污染源:<b>{{ sourceNames(s, p) }}</b></div>
|
||||
<div class="bars">
|
||||
<div class="bar" v-for="(c, i) in ranked(s, p)" :key="c.id">
|
||||
<span class="bn">{{ c.name }}<em v-if="isSource(s, p, c.id)">(污染源)</em></span>
|
||||
<span class="bt"><span class="bf" :style="{ width: Math.max(2, c.rate / ranked(s,p)[0].rate * 100) + '%', background: i === 0 ? '#bf4a30' : i === 1 ? '#ca8326' : '#1f7a5a' }" /></span>
|
||||
<span class="bv">{{ (c.rate * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">依据 GB/T 18883-2022 · GB 50325-2020 · 预测结果仅供参考,以 CMA 检测为准</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { POLLUTANTS, POLLUTANT_LABELS, STANDARD_LIMITS, type Pollutant, type StandardCode } from '@airpredict/shared';
|
||||
import { getProject, type ProjectDetail, type SpaceRow } from '../api/projects';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const project = ref<ProjectDetail | null>(null);
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
|
||||
const fmt = (s?: string) => (s ? new Date(s).toLocaleString() : '-');
|
||||
const fmt3 = (n?: number) => (n == null ? '-' : Number(n).toFixed(3));
|
||||
const limitsOf = (s: SpaceRow) => STANDARD_LIMITS[(s.standard as StandardCode) || 'GB50325-2020'];
|
||||
const limitOf = (s: SpaceRow, p: Pollutant) => limitsOf(s)[p];
|
||||
const isOver = (s: SpaceRow, p: Pollutant) => (s.predictedConc?.[p] ?? 0) > limitOf(s, p);
|
||||
const overPollutants = (s: SpaceRow) => POLLUTANTS.filter((p) => isOver(s, p));
|
||||
const overSpaces = computed(() => (project.value?.spaces || []).filter((s) => overPollutants(s).length).length);
|
||||
const standardsUsed = computed(() => [...new Set((project.value?.spaces || []).map((s) => s.standard))].join('、'));
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
function ranked(s: SpaceRow, p: Pollutant): Row[] {
|
||||
return s.materials
|
||||
.map((m) => ({ id: m.materialId, name: m.material?.name || m.materialId, rate: m.contributionRate?.[p] ?? 0 }))
|
||||
.filter((r) => r.rate > 0)
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
}
|
||||
function sources(s: SpaceRow, p: Pollutant): Row[] {
|
||||
const out: Row[] = []; let cum = 0;
|
||||
for (const x of ranked(s, p)) { out.push(x); cum += x.rate; if (cum > 0.5) break; }
|
||||
return out;
|
||||
}
|
||||
const isSource = (s: SpaceRow, p: Pollutant, id: string) => sources(s, p).some((x) => x.id === id);
|
||||
const sourceNames = (s: SpaceRow, p: Pollutant) => sources(s, p).map((x) => x.name).join('、');
|
||||
|
||||
function printReport() { window.print(); }
|
||||
|
||||
onMounted(async () => {
|
||||
project.value = await getProject(route.params.id as string);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report { background: #eef0f2; min-height: 100vh; padding-bottom: 40px; }
|
||||
.rpt-bar { display: flex; align-items: center; gap: 16px; padding: 12px 24px; background: #fff; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 5; }
|
||||
.rpt-bar a { color: #b4232a; cursor: pointer; }
|
||||
.rpt-title { flex: 1; font-weight: 600; }
|
||||
.sheet { max-width: 900px; margin: 20px auto; background: #fff; padding: 40px 48px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
|
||||
.cover { border-bottom: 2px solid #b4232a; padding-bottom: 20px; margin-bottom: 24px; }
|
||||
.cover-tag { color: #b4232a; font-size: 13px; font-weight: 600; letter-spacing: 1px; }
|
||||
.cover h1 { font-size: 28px; margin: 8px 0 16px; }
|
||||
.cover-meta { display: flex; flex-wrap: wrap; gap: 6px 24px; color: #555; font-size: 14px; }
|
||||
.cover-rating { margin-top: 16px; font-size: 15px; }
|
||||
.cover-rating b { font-size: 22px; margin: 0 8px; }
|
||||
.cover-rating b.r-A { color: #2f8f5b; } .cover-rating b.r-B { color: #2778c4; } .cover-rating b.r-C { color: #ca8326; } .cover-rating b.r-D { color: #bf4a30; }
|
||||
.cover-rating .gen { color: #999; font-size: 13px; margin-left: 16px; }
|
||||
.cover-summary { margin-top: 14px; color: #444; }
|
||||
.cover-summary b { color: #2f8f5b; } .cover-summary b.bad { color: #bf4a30; }
|
||||
.space-block { margin-bottom: 30px; page-break-inside: avoid; }
|
||||
.sb-head h2 { font-size: 18px; margin: 0; }
|
||||
.sb-meta { color: #888; font-size: 13px; }
|
||||
.conc-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.conc-table th, .conc-table td { border: 1px solid #e8e8e8; padding: 8px 10px; text-align: center; font-size: 13px; }
|
||||
.conc-table th { background: #fafafa; }
|
||||
.conc-table td:first-child, .conc-table th:first-child { text-align: left; color: #666; }
|
||||
.conc-table td.over { color: #bf4a30; font-weight: 700; }
|
||||
.trace { background: #fcf8f6; border: 1px solid #f0e0d8; border-radius: 8px; padding: 14px 16px; }
|
||||
.trace-h { font-weight: 600; color: #b4232a; margin-bottom: 10px; }
|
||||
.trace-pol { margin-bottom: 14px; }
|
||||
.tp-name { font-size: 13px; margin-bottom: 8px; }
|
||||
.bars { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bar { display: grid; grid-template-columns: 200px 1fr 52px; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.bar em { color: #bf4a30; font-style: normal; font-weight: 700; }
|
||||
.bt { height: 12px; background: #eee; border-radius: 4px; overflow: hidden; }
|
||||
.bf { display: block; height: 100%; }
|
||||
.bv { text-align: right; font-weight: 700; }
|
||||
.foot { color: #aaa; font-size: 12px; text-align: center; margin-top: 30px; border-top: 1px solid #eee; padding-top: 16px; }
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
.report { background: #fff; }
|
||||
.sheet { box-shadow: none; margin: 0; max-width: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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">m²</span></div></div>
|
||||
<div><div class="fld-lab">层高</div><div class="num"><input type="number" step="0.1" v-model.number="height" /><span class="unit">m</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<div class="row2">
|
||||
<div><div class="fld-lab">温度</div><div class="num"><input type="number" v-model.number="temperature" /><span class="unit">℃</span></div></div>
|
||||
<div><div class="fld-lab">湿度</div><div class="num"><input type="number" v-model.number="humidity" /><span class="unit">%rh</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<div class="fld-lab">通风换气率 <span class="v">{{ (+ventilationRate).toFixed(1) }} 次/h</span></div>
|
||||
<input class="slider" type="range" min="0.3" max="3" step="0.1" v-model.number="ventilationRate" />
|
||||
<div class="slider-scale"><span>0.3 密闭</span><span>1.0 一般</span><span>3.0 强通风</span></div>
|
||||
</div>
|
||||
|
||||
<div class="fld" style="margin-bottom:0">
|
||||
<div class="fld-lab">装修材料 <span class="muted">体积 V={{ volume.toFixed(1) }} m³ · 勾选计入、填用量</span></div>
|
||||
<div class="mats">
|
||||
<div v-for="m in mats" :key="m.id" class="mat" :class="{ off: !m.on }">
|
||||
<div class="mat-chk" :class="{ on: m.on }" @click="m.on = !m.on"><CheckIcon v-if="m.on" /></div>
|
||||
<div class="mat-main">
|
||||
<div class="mat-nm">{{ m.name }}</div>
|
||||
<div class="mat-cat">{{ matMap[m.id]?.category }}</div>
|
||||
</div>
|
||||
<div class="mat-qty"><input type="number" v-model.number="m.area" /><span class="u">m²</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />识别结论 · 5 项污染物</div><span class="card-step">结果</span></div>
|
||||
<table class="res-table">
|
||||
<thead><tr><th>污染物</th><th>预测浓度</th><th>限值({{ standard }})</th><th>判定</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pollutants" :key="p">
|
||||
<td>{{ labels[p].zh }}</td>
|
||||
<td :class="{ over: r.exceeded[p] }">{{ fmt(r.concentration[p]) }} mg/m³</td>
|
||||
<td class="muted">{{ r.limits[p] }}</td>
|
||||
<td><span class="chip" :class="r.exceeded[p] ? 'chip-bad' : 'chip-good'">{{ r.exceeded[p] ? '超标' : '达标' }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<div class="card-t"><span class="bar" />公式溯源 · 各材料贡献</div>
|
||||
<div class="pol-seg">
|
||||
<b v-for="p in pollutants" :key="p" :class="{ on: pol === p }" @click="pol = p">{{ labels[p].zh }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contrib">
|
||||
<div class="cb" v-for="(c, i) in ranked" :key="c.id">
|
||||
<div class="cb-nm">
|
||||
<span class="rk" :style="{ background: col(i) }">{{ i + 1 }}</span>{{ c.name }}
|
||||
<span v-if="isSource(c.id)" style="color:var(--bad);font-weight:700"> (污染源)</span>
|
||||
</div>
|
||||
<div class="cb-track"><div class="cb-fill" :style="{ width: Math.max(3, c.rate / maxRate * 100) + '%', background: col(i) }" /></div>
|
||||
<div class="cb-val"><span class="c" :style="{ color: col(i) }">{{ (c.rate * 100).toFixed(1) }}%</span></div>
|
||||
</div>
|
||||
<div v-if="!ranked.length" class="muted">该污染物无材料释放</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />整改建议</div></div>
|
||||
<div class="sugg">
|
||||
<div class="sugg-ic"><BulbIcon /></div>
|
||||
<div style="flex:1">
|
||||
<div class="sugg-tt">{{ anyOver ? `${overNames} 超标` : '当前方案全部达标 ✓' }}</div>
|
||||
<div class="sugg-tx" v-if="anyOver">
|
||||
主要污染源:<b>{{ topSourceNames }}</b>。可将通风提升至 <b>{{ requiredACH.toFixed(1) }} 次/h</b>,或更换/减少这些材料。
|
||||
</div>
|
||||
<div class="sugg-tx" v-else>各污染物均低于 {{ standard }} 限值。建议入住前仍保持通风并复测确认。</div>
|
||||
<div class="sugg-act" v-if="anyOver">
|
||||
<button class="s-btn s-btn-primary" @click="ventilationRate = requiredACH">应用:通风至 {{ requiredACH.toFixed(1) }} 次/h</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
predictSpace, POLLUTANTS, POLLUTANT_LABELS,
|
||||
type Pollutant, type StandardCode, type EmissionParams,
|
||||
} from '@airpredict/shared';
|
||||
import { listMaterials, type Material } from '../api/materials';
|
||||
import { PRESET_ROOMS } from '../data/rooms';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const pollutants = POLLUTANTS;
|
||||
const labels = POLLUTANT_LABELS;
|
||||
const rooms = PRESET_ROOMS;
|
||||
|
||||
const ChevronL = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M15 5l-7 7 7 7' })]);
|
||||
const SourceIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('circle', { cx: 12, cy: 12, r: 3 }), h('path', { d: 'M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1' })]);
|
||||
const CheckIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '3', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12l5 5L20 6' })]);
|
||||
const BulbIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z' })]);
|
||||
|
||||
const loading = ref(true);
|
||||
const matMap = reactive<Record<string, Material>>({});
|
||||
|
||||
const roomId = ref('demo');
|
||||
const area = ref(19.8);
|
||||
const height = ref(3);
|
||||
const temperature = ref(22);
|
||||
const humidity = ref(45);
|
||||
const ventilationRate = ref(0.5);
|
||||
const standard = ref<StandardCode>('GB50325-2020');
|
||||
const mats = ref<{ id: string; name: string; area: number; on: boolean }[]>([]);
|
||||
const pol = ref<Pollutant>('hcho');
|
||||
|
||||
const volume = computed(() => +(area.value * height.value).toFixed(2));
|
||||
|
||||
function loadRoom(id: string) {
|
||||
const room = rooms.find((r) => r.id === id)!;
|
||||
roomId.value = id;
|
||||
area.value = room.area; height.value = room.height;
|
||||
temperature.value = room.temperature; humidity.value = room.humidity;
|
||||
ventilationRate.value = room.ventilationRate;
|
||||
mats.value = room.materials.map((m) => ({ id: m.id, name: matMap[m.id]?.name || m.id, area: m.a, on: true }));
|
||||
}
|
||||
|
||||
const r = computed(() => {
|
||||
const input = mats.value
|
||||
.filter((m) => m.on && matMap[m.id])
|
||||
.map((m) => ({
|
||||
materialId: m.id,
|
||||
usageAmount: m.area,
|
||||
params: matMap[m.id].emissionParams as Record<Pollutant, EmissionParams>,
|
||||
}));
|
||||
return predictSpace(input, {
|
||||
volume: volume.value, temperature: temperature.value, humidity: humidity.value,
|
||||
ventilationRate: ventilationRate.value, standard: standard.value,
|
||||
});
|
||||
});
|
||||
|
||||
const fmt = (n: number) => Number(n ?? 0).toFixed(3);
|
||||
const col = (i: number) => (i === 0 ? 'var(--bad)' : i === 1 ? 'var(--warn)' : 'var(--accent)');
|
||||
|
||||
interface Row { id: string; name: string; rate: number }
|
||||
const ranked = computed<Row[]>(() =>
|
||||
r.value.contributions
|
||||
.map((c) => ({ id: c.materialId, name: matMap[c.materialId]?.name || c.materialId, rate: c.contributionRate[pol.value] }))
|
||||
.filter((x) => x.rate > 0)
|
||||
.sort((a, b) => b.rate - a.rate),
|
||||
);
|
||||
const maxRate = computed(() => ranked.value[0]?.rate || 1);
|
||||
const isSource = (id: string) => r.value.sources[pol.value]?.includes(id);
|
||||
|
||||
const anyOver = computed(() => POLLUTANTS.some((p) => r.value.exceeded[p]));
|
||||
const overNames = computed(() => POLLUTANTS.filter((p) => r.value.exceeded[p]).map((p) => labels[p].zh).join('、'));
|
||||
const topSourceNames = computed(() => {
|
||||
const ids = new Set<string>();
|
||||
POLLUTANTS.forEach((p) => r.value.sources[p]?.forEach((id) => ids.add(id)));
|
||||
return [...ids].map((id) => matMap[id]?.name || id).join('、') || '—';
|
||||
});
|
||||
|
||||
// 找到能让全部达标的最小通风换气率
|
||||
const requiredACH = computed(() => {
|
||||
const input = mats.value.filter((m) => m.on && matMap[m.id]).map((m) => ({
|
||||
materialId: m.id, usageAmount: m.area, params: matMap[m.id].emissionParams as Record<Pollutant, EmissionParams>,
|
||||
}));
|
||||
for (let ach = ventilationRate.value; ach <= 3.001; ach += 0.1) {
|
||||
const res = predictSpace(input, { volume: volume.value, temperature: temperature.value, humidity: humidity.value, ventilationRate: ach, standard: standard.value });
|
||||
if (!POLLUTANTS.some((p) => res.exceeded[p])) return Math.round(ach * 10) / 10;
|
||||
}
|
||||
return 3;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await listMaterials({ scope: 'public', page: 1, pageSize: 300 });
|
||||
res.items.forEach((m) => (matMap[m.id] = m));
|
||||
loadRoom('demo');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style src="../styles/source.css"></style>
|
||||
<style scoped>
|
||||
.res-table { width: 100%; border-collapse: collapse; }
|
||||
.res-table th, .res-table td { border-bottom: 1px solid var(--border); padding: 9px 8px; text-align: left; font-size: 13px; }
|
||||
.res-table th { color: var(--faint); font-weight: 600; font-size: 12px; }
|
||||
.res-table td.over { color: var(--bad); font-weight: 700; }
|
||||
.pol-seg { display: flex; gap: 2px; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
|
||||
.pol-seg b { font-size: 11.5px; font-weight: 600; padding: 5px 9px; border-radius: 6px; color: var(--sub); cursor: pointer; }
|
||||
.pol-seg b.on { background: var(--accent); color: #fff; }
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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;}
|
||||
}
|
||||
|
|
@ -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; } }
|
||||
|
|
@ -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 (m³) */
|
||||
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 → 筛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%的材料。
|
||||
*/
|
||||
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: 超过。
|
||||
* 评级(占位:官方评分/评级规则文档"待补充")。
|
||||
* 现按各污染物相对限值的最差比值给级:A≤60% B≤100% C≤150% D>150%。
|
||||
*/
|
||||
export function ratingFor(
|
||||
concentration: Record<Pollutant, number>,
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue