feat: 引流版公开落地页 + 手机号验证码注册 + 污染源识别页
- 落地页(引流版主界面.html 还原): 暖绿杂志风,公开免登录,nav/hero/ stats/资讯轮播/治理案例/三步/CTA/footer - 手机号注册: 后端 /auth/sms/send(开发模式返回验证码) /auth/sms/verify (建/找手机号组织→发JWT); Organization 加 phone 字段 - 点"免费预测甲醛"等CTA → 手机注册弹窗 → 验证后跳 /source - 污染源识别页(port source.jsx): 房间/材料输入→稳态质量平衡公式→ 超标判定→公式溯源各材料贡献→整改建议(应用通风/换E0板材) - 路由: /landing /source 公开/半公开, 游客访问根路径落到 landing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e1dff63c59
commit
2e87e4c0c2
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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,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,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,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('/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>
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<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 · 甲醛</small></div>
|
||||
<div class="s-spacer" />
|
||||
<span class="muted">{{ auth.org?.name || '访客' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="s-body">
|
||||
<div 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, id) in ROOMS" :key="id" class="room-b" :class="{ on: st.roomId === id }" @click="setRoom(id)">{{ r.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<div class="row2">
|
||||
<div><div class="fld-lab">房间面积</div><div class="num"><input type="number" :value="st.area" @input="set('area', +($event.target as HTMLInputElement).value)" /><span class="unit">m²</span></div></div>
|
||||
<div><div class="fld-lab">层高</div><div class="num"><input type="number" step="0.1" :value="st.h" @input="set('h', +($event.target as HTMLInputElement).value)" /><span class="unit">m</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<div class="fld-lab">通风换气率 <span class="v">{{ (+st.n).toFixed(1) }} 次/h</span></div>
|
||||
<input class="slider" type="range" min="0.3" max="3" step="0.1" :value="st.n" @input="set('n', +($event.target as HTMLInputElement).value)" />
|
||||
<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">勾选计入 · 填写用量(m²)</span></div>
|
||||
<div class="mats">
|
||||
<div v-for="(m, id) in st.mats" :key="id" class="mat" :class="{ off: !m.on }">
|
||||
<div class="mat-chk" :class="{ on: m.on }" @click="toggleMat(id)"><CheckIcon v-if="m.on" /></div>
|
||||
<div class="mat-main">
|
||||
<div class="mat-nm">{{ MATS[id].name }}<span v-if="st.upg[id]" style="color:var(--accent);font-weight:700"> · 已换E0</span></div>
|
||||
<div class="mat-cat">{{ MATS[id].cat }} · EF {{ MATS[id].ef }}</div>
|
||||
</div>
|
||||
<div class="mat-qty"><input type="number" :value="m.A" @input="setQty(id, +($event.target as HTMLInputElement).value)" /><span class="u">m²</span></div>
|
||||
</div>
|
||||
<button v-if="addable" class="add-mat" @click="addMat"><PlusIcon />添加材料 · {{ MATS[addable].name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />识别结论</div><span class="card-step">结果</span></div>
|
||||
<div class="verdict">
|
||||
<div class="verd-num"><span class="big" :style="{ color: `var(--${r.level})` }">{{ fmt(r.C) }}</span><span class="u">mg/m³</span></div>
|
||||
<div class="verd-meta">
|
||||
<span class="chip" :class="'chip-' + r.level">甲醛 {{ LEVELTXT[r.level] }}</span>
|
||||
<div class="verd-sub" v-if="r.level === 'good'">低于 GB/T 18883 限值 <b>{{ LIMIT }}</b>,余量 <b>{{ Math.round((1 - r.ratio) * 100) }}%</b></div>
|
||||
<div class="verd-sub" v-else>超出 GB/T 18883 限值 <b>{{ LIMIT }}</b> 约 <b style="color:var(--bad)">{{ Math.round((r.ratio - 1) * 100) }}%</b>(GB 50325-I 限值 {{ LIMIT2 }})</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gate">
|
||||
<div class="gate-step act">计算预测浓度</div>
|
||||
<span class="gate-arrow"><ArrowS /></span>
|
||||
<div class="gate-step" :class="r.level === 'good' ? 'act' : 'bad'">{{ r.level === 'good' ? '判定达标' : '判定超标' }}</div>
|
||||
<span class="gate-arrow"><ArrowS /></span>
|
||||
<div class="gate-step" :class="{ bad: r.level !== 'good' }">公式溯源污染材料</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t"><span class="bar" />溯源公式 · 稳态质量平衡</div><span class="card-step">公式</span></div>
|
||||
<div class="formula">
|
||||
<div class="eq">C = <span class="frac"><span class="top">Σ ( EFᵢ · Aᵢ )</span><span class="bot">n · V</span></span></div>
|
||||
<div class="plug">
|
||||
房间体积 V = 面积 × 层高 = <code>{{ fmt(st.area, 0) }} × {{ (+st.h).toFixed(1) }} = {{ fmt(r.V, 1) }} m³</code> ·
|
||||
通风量 n·V = <code>{{ (+st.n).toFixed(1) }} × {{ fmt(r.V, 1) }} = {{ fmt(r.nV, 1) }} m³/h</code><br />
|
||||
总释放速率 Σ(EF·A) = <code>{{ fmt(r.totalEmis, 2) }} mg/h</code> ⇒
|
||||
C = <code>{{ fmt(r.totalEmis, 2) }} ÷ {{ fmt(r.nV, 1) }} = {{ fmt(r.C) }} mg/m³</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h"><div><div class="card-t"><span class="bar" />公式溯源 · 各材料甲醛浓度贡献</div><div class="muted" style="margin-top:3px">贡献ᵢ = EFᵢ·Aᵢ /(n·V) · 已按贡献排序</div></div></div>
|
||||
<div class="contrib">
|
||||
<div class="cb" v-for="(x, i) in r.items" :key="x.id">
|
||||
<div class="cb-nm"><span class="rk" :style="{ background: col(i) }">{{ i + 1 }}</span>{{ x.name }}</div>
|
||||
<div class="cb-track"><div class="cb-fill" :style="{ width: Math.max(3, x.c / maxC * 100) + '%', background: col(i) }" /></div>
|
||||
<div class="cb-val"><span class="c" :style="{ color: col(i) }">{{ fmt(x.c) }}</span> <span class="p">{{ Math.round(x.pct * 100) }}%</span></div>
|
||||
</div>
|
||||
<div v-if="!r.items.length" class="muted">未选择任何材料</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" v-if="r.items.length">
|
||||
<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">{{ r.level === 'good' ? '当前方案达标 ✓' : `主要污染源:${r.items[0].name}` }}</div>
|
||||
<div class="sugg-tx" v-if="r.level === 'good'">预测甲醛 <b>{{ fmt(r.C) }} mg/m³</b> 已低于国标限值。建议入住前仍保持 <b>{{ (+st.n).toFixed(1) }} 次/h</b> 以上通风,并复测确认。</div>
|
||||
<div class="sugg-tx" v-else>该材料贡献 <b>{{ Math.round(r.items[0].pct * 100) }}%</b>({{ fmt(r.items[0].c) }} mg/m³)。将通风提升至 <b>{{ r.requiredN.toFixed(1) }} 次/h</b> 或更换主源材料即可达标。</div>
|
||||
<div class="sugg-act" v-if="r.level !== 'good'">
|
||||
<button class="s-btn s-btn-primary" @click="applyVent">应用:通风至 {{ Math.min(3, r.requiredN).toFixed(1) }} 次/h</button>
|
||||
<button class="s-btn" @click="upgrade">{{ st.upg[r.items[0].id] ? '还原板材' : '更换为 E0 级板材' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="toast" v-if="toast"><SparkIcon />{{ toast }}</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
// 图标
|
||||
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 PlusIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.4', 'stroke-linecap': 'round' }, [h('path', { d: 'M12 5v14M5 12h14' })]);
|
||||
const ArrowS = () => h('svg', { viewBox: '0 0 24 24', width: 18, height: 18, fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12h14M13 6l6 6-6 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 SparkIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5L18 18M18 6l-2.5 2.5M8.5 15.5L6 18' })]);
|
||||
|
||||
// 甲醛释放因子 EF [mg/(m²·h)]
|
||||
const MATS: Record<string, { name: string; cat: string; ef: number }> = {
|
||||
floor: { name: '多层实木复合地板', cat: '地板 · 人造板基材', ef: 0.07 },
|
||||
wardrobe: { name: '人造板衣柜', cat: '板材 · 颗粒板(展开面积)', ef: 0.04 },
|
||||
woodpaint: { name: '木器漆 · 饰面', cat: '涂料 · 溶剂型', ef: 0.015 },
|
||||
wallpaper: { name: '壁纸及基膜', cat: '墙面 · 胶粘剂', ef: 0.0073 },
|
||||
sofa: { name: '布艺沙发软装', cat: '软装 · 纺织/海绵', ef: 0.032 },
|
||||
latex: { name: '乳胶漆墙面', cat: '墙面 · 水性漆', ef: 0.006 },
|
||||
ceiling: { name: '石膏板吊顶', cat: '顶面 · 板材', ef: 0.004 },
|
||||
door: { name: '免漆木门', cat: '门窗 · 人造板', ef: 0.022 },
|
||||
};
|
||||
const LIMIT = 0.1, LIMIT2 = 0.07;
|
||||
const ROOMS: Record<string, { name: string; area: number; h: number; mats: Record<string, number> }> = {
|
||||
zw: { name: '主卧', area: 18, h: 2.7, mats: { floor: 18, wardrobe: 25, woodpaint: 30, wallpaper: 40, sofa: 6 } },
|
||||
etf: { name: '儿童房', area: 14, h: 2.7, mats: { floor: 14, wardrobe: 32, woodpaint: 24, wallpaper: 30, sofa: 4 } },
|
||||
sf: { name: '书房', area: 12, h: 2.7, mats: { floor: 12, wardrobe: 24, woodpaint: 20, door: 8, wallpaper: 16 } },
|
||||
kt: { name: '客厅', area: 30, h: 2.85, mats: { floor: 30, woodpaint: 38, latex: 55, sofa: 14, ceiling: 30 } },
|
||||
wsj: { name: '卫生间', area: 6, h: 2.6, mats: { latex: 16, ceiling: 6, door: 4 } },
|
||||
};
|
||||
const LEVELTXT: Record<string, string> = { bad: '超标', warn: '临界', good: '达标' };
|
||||
const fmt = (n: number, d = 3) => Number(n).toFixed(d);
|
||||
|
||||
function freshRoom(id: string) {
|
||||
const r = ROOMS[id];
|
||||
const mats: Record<string, { on: boolean; A: number }> = {};
|
||||
Object.entries(r.mats).forEach(([k, A]) => { mats[k] = { on: true, A }; });
|
||||
return { roomId: id, area: r.area, h: r.h, n: 0.5, mats, upg: {} as Record<string, boolean> };
|
||||
}
|
||||
|
||||
const st = reactive(freshRoom('zw'));
|
||||
|
||||
function compute() {
|
||||
const V = Math.max(0.1, st.area * st.h);
|
||||
const nV = Math.max(0.01, st.n * V);
|
||||
const items: any[] = [];
|
||||
Object.entries(st.mats).forEach(([id, m]: any) => {
|
||||
if (!m.on) return;
|
||||
const ef = MATS[id].ef * (st.upg[id] ? 0.35 : 1);
|
||||
const emis = ef * m.A;
|
||||
items.push({ id, name: MATS[id].name, emis, c: emis / nV });
|
||||
});
|
||||
const C = items.reduce((s, x) => s + x.c, 0);
|
||||
const totalEmis = items.reduce((s, x) => s + x.emis, 0);
|
||||
items.sort((a, b) => b.c - a.c).forEach((x, i) => { x.rank = i; x.pct = C ? x.c / C : 0; });
|
||||
const ratio = C / LIMIT;
|
||||
const level = ratio >= 1 ? 'bad' : ratio >= 0.85 ? 'warn' : 'good';
|
||||
const requiredN = Math.min(3, Math.max(0.3, Math.ceil((C * st.n) / (LIMIT * 0.9) * 10) / 10));
|
||||
return { V, nV, C, totalEmis, items, ratio, level, requiredN };
|
||||
}
|
||||
const r = computed(compute);
|
||||
const maxC = computed(() => (r.value.items.length ? r.value.items[0].c : 1));
|
||||
const addable = computed(() => Object.keys(MATS).find((k) => !st.mats[k]) || '');
|
||||
const col = (i: number) => (i === 0 ? 'var(--bad)' : i === 1 ? 'var(--warn)' : 'var(--accent)');
|
||||
|
||||
function set(k: 'area' | 'h' | 'n', v: number) { (st as any)[k] = v; }
|
||||
function setRoom(id: string) { Object.assign(st, freshRoom(id)); }
|
||||
function toggleMat(id: string) { st.mats[id].on = !st.mats[id].on; }
|
||||
function setQty(id: string, A: number) { st.mats[id].A = A || 0; }
|
||||
function addMat() { const k = addable.value; if (k) st.mats[k] = { on: true, A: 10 }; }
|
||||
|
||||
const toast = ref('');
|
||||
let tt: any = null;
|
||||
function fireToast(msg: string) { toast.value = msg; if (tt) clearTimeout(tt); tt = setTimeout(() => (toast.value = ''), 2600); }
|
||||
function applyVent() { st.n = Math.min(3, Math.max(st.n, r.value.requiredN)); fireToast(`已将通风换气率调整至 ${Math.min(3, r.value.requiredN).toFixed(1)} 次/h`); }
|
||||
function upgrade() {
|
||||
const top = r.value.items[0];
|
||||
const was = st.upg[top.id];
|
||||
st.upg = { ...st.upg, [top.id]: !was };
|
||||
fireToast(was ? `已还原 ${top.name}` : `已将 ${top.name} 替换为 E0 级低释放材料`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="../styles/source.css"></style>
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
|
@ -2,10 +2,12 @@ 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: '/',
|
||||
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') },
|
||||
|
|
@ -22,9 +24,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,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; } }
|
||||
Loading…
Reference in New Issue