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:
zty 2026-06-11 15:38:47 +08:00
parent e1dff63c59
commit 2e87e4c0c2
13 changed files with 896 additions and 7 deletions

View File

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

View File

@ -1,18 +1,33 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { SmsService } from './sms.service';
import { LoginDto } from './dto/login.dto';
import { SendSmsDto, VerifySmsDto } from './dto/sms.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentOrg, OrgPayload } from './current-org.decorator';
@Controller('auth')
export class AuthController {
constructor(private auth: AuthService) {}
constructor(
private auth: AuthService,
private sms: SmsService,
) {}
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto.username, dto.password);
}
@Post('sms/send')
sendSms(@Body() dto: SendSmsDto) {
return this.sms.send(dto.phone);
}
@Post('sms/verify')
verifySms(@Body() dto: VerifySmsDto) {
return this.sms.verify(dto.phone, dto.code);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentOrg() org: OrgPayload) {

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { SmsService } from './sms.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@ -13,7 +14,7 @@ import { JwtStrategy } from './jwt.strategy';
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}),
],
providers: [AuthService, JwtStrategy],
providers: [AuthService, SmsService, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}

View File

@ -0,0 +1,15 @@
import { IsString, Matches, Length } from 'class-validator';
export class SendSmsDto {
@Matches(/^1\d{10}$/, { message: '手机号格式不正确' })
phone!: string;
}
export class VerifySmsDto {
@Matches(/^1\d{10}$/, { message: '手机号格式不正确' })
phone!: string;
@IsString()
@Length(4, 6)
code!: string;
}

View File

@ -0,0 +1,54 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
interface CodeEntry {
code: string;
expireAt: number;
}
/**
* 5
*
*/
@Injectable()
export class SmsService {
private store = new Map<string, CodeEntry>();
private readonly DEV = true; // 开发模式:发送接口直接返回验证码
constructor(
private prisma: PrismaService,
private jwt: JwtService,
) {}
send(phone: string) {
if (!/^1\d{10}$/.test(phone)) throw new BadRequestException('手机号格式不正确');
// 6 位验证码(开发模式固定算法,便于测试可读)
const code = String(Math.floor(100000 + (Date.now() % 900000)));
this.store.set(phone, { code, expireAt: Date.now() + 5 * 60 * 1000 });
// 真实环境此处调用短信网关;开发模式直接回传
return this.DEV ? { sent: true, devCode: code } : { sent: true };
}
async verify(phone: string, code: string) {
const entry = this.store.get(phone);
if (!entry || entry.expireAt < Date.now()) throw new BadRequestException('验证码已过期,请重新获取');
if (entry.code !== code) throw new BadRequestException('验证码不正确');
this.store.delete(phone);
// 找到或创建该手机号对应的访客组织
let org = await this.prisma.organization.findUnique({ where: { phone } });
if (!org) {
org = await this.prisma.organization.create({
data: {
username: 'U' + phone,
name: phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
phone,
passwordHash: '',
},
});
}
const token = await this.jwt.signAsync({ sub: org.id, username: org.username });
return { token, org: { id: org.id, username: org.username, name: org.name, phone } };
}
}

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

@ -0,0 +1,10 @@
import { http } from './http';
import type { Org } from '../stores/auth';
export function sendSms(phone: string) {
return http.post<any, { sent: boolean; devCode?: string }>('/auth/sms/send', { phone });
}
export function verifySms(phone: string, code: string) {
return http.post<any, { token: string; org: Org & { phone: string } }>('/auth/sms/verify', { phone, code });
}

View File

@ -0,0 +1,82 @@
<template>
<a-config-provider :theme="{ token: { colorPrimary: '#1f7a5a' } }">
<a-modal :open="open" title="手机号快速预测" :footer="null" width="400px" @cancel="emit('cancel')">
<p class="tip">登录后即可免费预测甲醛 / TVOC,无需密码,验证码登录</p>
<a-form layout="vertical" @submit.prevent="onVerify">
<a-form-item label="手机号">
<a-input v-model:value="phone" size="large" placeholder="请输入手机号" :maxlength="11" />
</a-form-item>
<a-form-item label="验证码">
<div class="code-row">
<a-input v-model:value="code" size="large" placeholder="6 位验证码" :maxlength="6" />
<a-button size="large" :disabled="countdown > 0 || !validPhone" :loading="sending" @click="onSend">
{{ countdown > 0 ? countdown + 's' : '发送验证码' }}
</a-button>
</div>
</a-form-item>
<a-alert v-if="devCode" type="info" show-icon :message="`开发模式验证码:${devCode}`" style="margin-bottom: 12px" />
<a-button type="primary" size="large" block :loading="verifying" :disabled="!validPhone || !code" @click="onVerify">
验证并开始预测
</a-button>
</a-form>
</a-modal>
</a-config-provider>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { sendSms, verifySms } from '../api/sms';
import { useAuthStore } from '../stores/auth';
defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'ok'): void; (e: 'cancel'): void }>();
const auth = useAuthStore();
const phone = ref('');
const code = ref('');
const devCode = ref('');
const sending = ref(false);
const verifying = ref(false);
const countdown = ref(0);
const validPhone = computed(() => /^1\d{10}$/.test(phone.value));
async function onSend() {
sending.value = true;
try {
const res = await sendSms(phone.value);
if (res.devCode) {
devCode.value = res.devCode;
code.value = res.devCode; // 便
}
message.success('验证码已发送');
countdown.value = 60;
const t = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) clearInterval(t);
}, 1000);
} finally {
sending.value = false;
}
}
async function onVerify() {
if (!validPhone.value || !code.value) return;
verifying.value = true;
try {
const res = await verifySms(phone.value, code.value);
auth.setSession(res.token, res.org);
message.success('登录成功');
emit('ok');
} finally {
verifying.value = false;
}
}
</script>
<style scoped>
.tip { color: #888; font-size: 13px; margin: 0 0 16px; }
.code-row { display: flex; gap: 10px; }
.code-row .ant-input { flex: 1; }
</style>

View File

@ -0,0 +1,215 @@
<template>
<div class="lp">
<!-- NAV -->
<header class="nav">
<div class="nav-in">
<a class="brand" @click="scrollTop">
<span class="brand-logo"><LeafIcon /></span>
<span><span class="brand-tt">污染物预测系统</span><br><span class="brand-sub">INDOOR AIR · 装修污染</span></span>
</a>
<nav class="nav-links">
<a @click="scrollTo('news')">资讯科普</a>
<a @click="scrollTo('cases')">治理案例</a>
<a @click="scrollTo('how')">如何使用</a>
<a @click="openPredict">污染源识别</a>
<a @click="goPro">专业看板</a>
</nav>
<span class="nav-sp"></span>
<a class="btn btn-primary" @click="openPredict">免费试算<ArrowIcon /></a>
</div>
</header>
<!-- HERO -->
<section class="hero">
<div class="wrap hero-grid">
<div>
<span class="eyebrow"><span class="pip"></span>对照 GB/T 18883-2022 GB 50325-2020 双国标</span>
<h1>装修住得安心,<br>从一次<em>污染预测</em>开始</h1>
<p class="hero-lead">输入房间环境与所用材料,系统用稳态质量平衡公式预测甲醛TVOC 6 项污染物浓度,判定是否超标,并溯源到具体污染材料,给出整改建议</p>
<div class="hero-cta">
<a class="btn btn-primary btn-lg" @click="openPredict">免费预测甲醛 · TVOC<ArrowIcon /></a>
<a class="btn btn-lg" @click="scrollTo('cases')">查看治理案例</a>
</div>
<div class="hero-tags">
<span class="hero-tag"><CheckIcon />无需上门,先估后测</span>
<span class="hero-tag"><CheckIcon />公式可溯源</span>
<span class="hero-tag"><CheckIcon />材料数据库支撑</span>
</div>
</div>
<div class="hero-visual">
<div class="img-ph hero-img">室内 / 装修实景照片</div>
<div class="hero-badge"><b>6 </b><span>污染物预测</span></div>
</div>
</div>
</section>
<!-- STATS -->
<section class="stats">
<div class="stats-in">
<div class="stat" v-for="s in stats" :key="s.t"><b>{{ s.n }}</b><span>{{ s.t }}</span></div>
</div>
</section>
<!-- NEWS CAROUSEL -->
<section class="sec" id="news">
<div class="wrap">
<div class="sec-head">
<div><div class="sec-tag">资讯 · 科普</div><h2 class="sec-h">读懂装修污染,先把知识装进脑子</h2></div>
<a class="btn" @click="openPredict">全部文章</a>
</div>
<div class="carousel" @mouseenter="stop" @mouseleave="start">
<div class="cviewport">
<div class="ctrack" :style="{ transform: `translateX(${-cur * 100}%)` }">
<article class="cslide" v-for="(n, i) in news" :key="i">
<div class="cslide-img"><span class="cslide-tag">{{ n.tag }}</span><div class="img-ph">资讯配图</div></div>
<div class="cslide-body">
<div class="cslide-date">{{ n.date }}</div>
<h3>{{ n.title }}</h3>
<p>{{ n.desc }}</p>
<span class="cslide-more">阅读全文<ArrowIcon /></span>
</div>
</article>
</div>
</div>
<button class="cnav prev" @click="prev"><ChevronIcon dir="left" /></button>
<button class="cnav next" @click="next"><ChevronIcon dir="right" /></button>
</div>
<div class="cdots">
<button v-for="(n, i) in news" :key="i" class="cdot" :class="{ on: i === cur }" @click="go(i)"></button>
</div>
</div>
</section>
<!-- CASES -->
<section class="sec" id="cases" style="background:var(--bg2);">
<div class="wrap">
<div class="sec-head">
<div><div class="sec-tag">治理案例</div><h2 class="sec-h">预测 溯源 整改,看得见的下降</h2><p class="sec-sub">真实流程演示:从预测超标,到锁定主要污染材料,再到整改复测达标</p></div>
</div>
<div class="cases-grid">
<article class="case" v-for="(c, i) in cases" :key="i">
<div class="img-ph">案例实景图</div>
<div class="case-b">
<div class="case-type">{{ c.type }}</div>
<h4>{{ c.name }}</h4>
<div class="case-meta">{{ c.meta }}</div>
<div class="ba">
<div class="ba-row"><span class="k">治理前</span><div class="ba-track"><div class="ba-fill" :style="{ width: c.w1 + '%', background: 'var(--bad)' }"></div></div><span class="v" style="color:var(--bad)">{{ c.v1 }}</span></div>
<div class="ba-row"><span class="k">治理后</span><div class="ba-track"><div class="ba-fill" :style="{ width: c.w2 + '%', background: 'var(--good)' }"></div></div><span class="v" style="color:var(--good)">{{ c.v2 }}</span></div>
</div>
<div class="case-foot"><span class="chip chip-good">{{ c.chip }}</span><span class="lk">查看溯源<ArrowIcon small /></span></div>
</div>
</article>
</div>
</div>
</section>
<!-- HOW -->
<section class="sec" id="how">
<div class="wrap">
<div class="sec-head"><div><div class="sec-tag">如何使用</div><h2 class="sec-h">三步,得到一份可溯源的预测报告</h2></div></div>
<div class="steps">
<div class="step" v-for="(s, i) in steps" :key="i">
<div class="step-n">{{ i + 1 }}</div><h4>{{ s.h }}</h4><p>{{ s.p }}</p>
<div class="step-ar" v-if="i < steps.length - 1"><ArrowIcon /></div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="sec" style="padding-top:0;">
<div class="wrap">
<div class="ctaband">
<div style="position:relative;z-index:2;">
<h2>现在就免费预测<br>你家会不会甲醛超标</h2>
<p>无需上门,输入材料即可估算先估后测,把检测的钱花在刀刃上</p>
</div>
<div style="display:flex;gap:14px;position:relative;z-index:2;">
<a class="btn btn-primary btn-lg" @click="openPredict">免费开始预测</a>
<a class="btn btn-lg" @click="goPro">查看专业看板</a>
</div>
<div class="deco"></div>
</div>
</div>
</section>
<!-- FOOTER -->
<footer class="footer">
<div class="footer-in">
<div class="brand">
<span class="brand-logo"><LeafIcon /></span>
<span class="brand-tt">室内装修工程污染物预测系统</span>
</div>
<div>依据 GB/T 18883-2022 · GB 50325-2020 · 预测结果仅供参考, CMA 检测为准</div>
</div>
</footer>
<PhoneAuthModal :open="authOpen" @ok="onAuthed" @cancel="authOpen = false" />
</div>
</template>
<script setup lang="ts">
import { h, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import PhoneAuthModal from '../components/PhoneAuthModal.vue';
const router = useRouter();
//
const LeafIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M4 20c10 2 16-4 16-14 0 0-8-2-12 2-3 3-3 7-1 9 3-4 6-6 9-7' })]);
const ArrowIcon = (props: any) => h('svg', { viewBox: '0 0 24 24', width: props.small ? 15 : undefined, height: props.small ? 15 : undefined, fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M5 12h14M13 6l6 6-6 6' })]);
const CheckIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: 'M20 6L9 17l-5-5' })]);
const ChevronIcon = (props: any) => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.4', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [h('path', { d: props.dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7' })]);
const stats = [
{ n: '28+', t: '在管装修项目' },
{ n: '2,400+', t: '累计预测次数' },
{ n: '6 项', t: '污染物 · 甲醛/苯/TVOC/氨/氡/VOC' },
{ n: '2 部', t: '国标依据 · 18883 / 50325' },
];
const news = [
{ tag: '政策解读', date: '专栏 · 2026.05', title: '两大国标怎么读?GB/T 18883 与 GB 50325 的差别', desc: '一个是"住进去后"的室内空气质量标准,一个是"交工验收时"的工程控制标准——限值与采样条件并不相同,看懂它们才能判断房子到底达不达标。' },
{ tag: '科普', date: '专栏 · 2026.04', title: '新装住宅的甲醛,为什么能持续释放 315 年?', desc: '人造板里的脲醛树脂胶会缓慢分解释放甲醛,释放周期长、受温湿度影响大。短期通风只能降一时浓度,真正的关键在源头材料的选择。' },
{ tag: '指南', date: '专栏 · 2026.03', title: '夏天为什么更容易超标?温度与释放速率', desc: '温度每升高若干度,材料的甲醛释放速率会明显增大。这也是"冬天测达标、夏天又超标"的原因。预测时把环境温湿度纳入计算,结果才靠谱。' },
{ tag: '方法', date: '专栏 · 2026.02', title: '先预测,再决定要不要做 CMA 检测', desc: '上门检测有成本。用本系统先做一次免费预测、定位高风险房间与主要污染材料,再有针对性地安排第三方 CMA 检测,省钱也更有的放矢。' },
];
const cases = [
{ type: '住宅 · I类民用建筑', name: '锦绣华庭 · 主卧', meta: '主源:多层实木复合地板 · 人造板衣柜', w1: 82, v1: '0.18', w2: 36, v2: '0.08', chip: '甲醛达标' },
{ type: '住宅 · I类民用建筑', name: '翠湖天地 · 儿童房', meta: '主源:人造板衣柜 · 壁纸基膜', w1: 95, v1: '0.21', w2: 32, v2: '0.07', chip: '甲醛达标' },
{ type: '酒店客房 · II类民用建筑', name: '云栖精选酒店 · 标准间', meta: '主源:木器漆饰面 · 软包', w1: 90, v1: '0.72', w2: 58, v2: '0.46', chip: 'TVOC达标' },
];
const steps = [
{ h: '录入房间与材料', p: '选择房间、填写面积层高与通风换气率,勾选所用装修材料及用量。' },
{ h: '公式计算浓度', p: '按稳态质量平衡 C = Σ(EFᵢ·Aᵢ)/(n·V) 计算 6 项污染物浓度,对照国标判定达标。' },
{ h: '溯源与整改建议', p: '若超标,公式溯源出贡献最大的材料并排序,给出通风或换材的可行整改方案。' },
];
//
const cur = ref(0);
let timer: any = null;
function go(n: number) { cur.value = (n + news.length) % news.length; }
function next() { go(cur.value + 1); start(); }
function prev() { go(cur.value - 1); start(); }
function start() { stop(); timer = setInterval(() => go(cur.value + 1), 5500); }
function stop() { if (timer) clearInterval(timer); timer = null; }
onMounted(start);
onBeforeUnmount(stop);
function scrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
function scrollTo(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); }
function goPro() { router.push('/login'); }
//
const authOpen = ref(false);
function openPredict() {
if (localStorage.getItem('token')) router.push('/source');
else authOpen.value = true;
}
function onAuthed() {
authOpen.value = false;
router.push('/source');
}
</script>
<style src="../styles/landing.css"></style>

View File

@ -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"></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">勾选计入 · 填写用量()</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"></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/</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) }} </code> ·
通风量 n·V = <code>{{ (+st.n).toFixed(1) }} × {{ fmt(r.V, 1) }} = {{ fmt(r.nV, 1) }} /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/</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/</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/)将通风提升至 <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>

View File

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

View File

@ -19,11 +19,17 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('token', res.token);
}
function setSession(t: string, o: Org) {
token.value = t;
org.value = o;
localStorage.setItem('token', t);
}
function logout() {
token.value = null;
org.value = null;
localStorage.removeItem('token');
}
return { org, token, login, logout };
return { org, token, login, setSession, logout };
});

View File

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

View File

@ -0,0 +1,122 @@
/* source.css — 污染源识别工具(暖绿杂志风),变量挂在 .s-app 上。 */
.s-app {
--bg: #f4f0e7; --panel: #fffdf8; --panel2: #f4efe3; --panel3: #ede6d8;
--border: #e9e1d2; --border2: #ded3bf;
--ink: #221d15; --sub: #6c6353; --faint: #a89c86;
--accent: #1f7a5a; --accent2: #2f9e74; --accent-soft: rgba(31,122,90,.12); --accent-deep: #165c43;
--good: #2f8f5b; --good-soft: rgba(47,143,91,.14);
--warn: #ca8326; --warn-soft: rgba(202,131,38,.16);
--bad: #bf4a30; --bad-soft: rgba(191,74,48,.13);
--track: #ebe3d4; --radius: 16px;
--serif: 'Songti SC','STSong',Georgia,'Times New Roman',serif;
background: var(--bg); color: var(--ink);
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',system-ui,sans-serif;
font-size: 14px; letter-spacing: .1px;
height: 100vh; display: flex; flex-direction: column;
}
.s-app * { box-sizing: border-box; }
.s-top { flex: 0 0 64px; display: flex; align-items: center; gap: 16px; padding: 0 28px; border-bottom: 1px solid var(--border); background: rgba(255,253,248,.85); }
.s-back { display: flex; align-items: center; gap: 7px; color: var(--sub); font-size: 13px; font-weight: 500; cursor: pointer; padding: 7px 11px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); white-space: nowrap; }
.s-back:hover { background: var(--panel2); }
.s-back svg { width: 15px; height: 15px; }
.s-logo { width: 34px; height: 34px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; }
.s-logo svg { width: 19px; height: 19px; }
.s-tt { font-family: var(--serif); font-size: 18px; font-weight: 700; }
.s-tt small { font-size: 11px; color: var(--faint); font-weight: 500; letter-spacing: 1px; margin-left: 8px; }
.s-spacer { flex: 1; }
.s-body { flex: 1; overflow-y: auto; padding: 22px 28px 30px; }
.s-grid { display: grid; gap: 18px; align-items: start; grid-template-columns: 400px 1fr; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.card-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.card-t { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.card-t .bar { width: 3px; height: 14px; border-radius: 2px; background: var(--accent); }
.card-step { font-size: 10px; font-weight: 700; letter-spacing: .5px; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 2px 8px; }
.muted { color: var(--faint); font-size: 11px; font-weight: 500; }
.fld { margin-bottom: 16px; }
.fld-lab { font-size: 12px; font-weight: 600; color: var(--sub); margin-bottom: 7px; display: flex; align-items: center; justify-content: space-between; }
.fld-lab .v { font-family: var(--serif); font-weight: 700; color: var(--ink); font-size: 14px; }
.rooms { display: flex; flex-wrap: wrap; gap: 7px; }
.room-b { font-size: 12.5px; font-weight: 600; padding: 8px 13px; border-radius: 9px; border: 1px solid var(--border2); background: var(--panel); color: var(--sub); cursor: pointer; transition: .12s; white-space: nowrap; }
.room-b:hover { border-color: var(--accent); color: var(--accent); }
.room-b.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.num { display: flex; align-items: center; background: var(--panel2); border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
.num input { flex: 1; min-width: 0; border: none; background: transparent; padding: 9px 11px; font-family: var(--serif); font-size: 15px; font-weight: 700; color: var(--ink); outline: none; }
.num .unit { font-size: 11px; color: var(--faint); padding: 0 11px; font-weight: 600; }
.slider { width: 100%; -webkit-appearance: none; appearance: none; height: 6px; border-radius: 4px; background: var(--track); outline: none; }
.slider::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.2); border: 3px solid #fff; }
.slider-scale { display: flex; justify-content: space-between; font-size: 10px; color: var(--faint); margin-top: 4px; }
.mats { display: flex; flex-direction: column; gap: 8px; }
.mat { display: flex; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 11px; background: var(--panel2); }
.mat.off { opacity: .42; }
.mat-chk { width: 18px; height: 18px; border-radius: 6px; border: 1.5px solid var(--border2); flex: 0 0 18px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: var(--panel); }
.mat-chk.on { background: var(--accent); border-color: var(--accent); }
.mat-chk svg { width: 12px; height: 12px; color: #fff; }
.mat-main { flex: 1; min-width: 0; }
.mat-nm { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mat-cat { font-size: 10px; color: var(--faint); }
.mat-qty { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.mat-qty input { width: 52px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); padding: 5px 7px; font-family: var(--serif); font-weight: 700; font-size: 13px; text-align: right; color: var(--ink); outline: none; }
.mat-qty .u { font-size: 10px; color: var(--faint); }
.add-mat { margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--accent); border: 1px dashed var(--border2); border-radius: 11px; padding: 9px; cursor: pointer; background: transparent; width: 100%; }
.add-mat:hover { background: var(--accent-soft); }
.add-mat svg { width: 15px; height: 15px; flex: 0 0 15px; }
.verdict { display: flex; align-items: center; gap: 22px; }
.verd-num { font-family: var(--serif); font-weight: 800; letter-spacing: -1px; line-height: .9; }
.verd-num .big { font-size: 56px; }
.verd-num .u { font-size: 16px; color: var(--faint); margin-left: 4px; }
.verd-meta { display: flex; flex-direction: column; gap: 8px; }
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; border-radius: 9px; padding: 6px 13px; width: fit-content; }
.chip::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.chip-bad { color: var(--bad); background: var(--bad-soft); }
.chip-warn { color: var(--warn); background: var(--warn-soft); }
.chip-good { color: var(--good); background: var(--good-soft); }
.verd-sub { font-size: 12.5px; color: var(--sub); }
.verd-sub b { color: var(--ink); }
.gate { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; align-items: center; gap: 10px; font-size: 12px; margin: 14px 0 0; }
.gate-step { text-align: center; padding: 9px 6px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel2); color: var(--sub); font-weight: 600; white-space: nowrap; font-size: 12px; }
.gate-step.act { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.gate-step.bad { border-color: var(--bad); color: var(--bad); background: var(--bad-soft); }
.gate-arrow { color: var(--faint); flex: 0 0 auto; display: flex; align-items: center; }
.formula { background: var(--panel2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; font-family: var(--serif); }
.formula .eq { font-size: 17px; font-weight: 700; letter-spacing: .3px; }
.formula .frac { display: inline-block; vertical-align: middle; }
.formula .frac .top { display: block; border-bottom: 2px solid currentColor; padding: 0 8px; font-size: 14px; }
.formula .frac .bot { display: block; padding: 2px 8px 0; font-size: 14px; text-align: center; }
.formula .plug { font-family: var(--sans, sans-serif); font-size: 12px; color: var(--sub); margin-top: 8px; line-height: 1.7; }
.formula .plug code { font-family: var(--serif); font-weight: 700; color: var(--accent-deep); background: var(--accent-soft); border-radius: 5px; padding: 1px 6px; }
.contrib { display: flex; flex-direction: column; gap: 11px; }
.cb { display: grid; grid-template-columns: 140px 1fr 92px; align-items: center; gap: 12px; }
.cb-nm { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cb-nm .rk { display: inline-block; width: 17px; height: 17px; border-radius: 5px; font-size: 10px; font-weight: 800; text-align: center; line-height: 17px; margin-right: 7px; color: #fff; }
.cb-track { height: 18px; background: var(--track); border-radius: 6px; overflow: hidden; }
.cb-fill { height: 100%; border-radius: 6px; transition: width .35s cubic-bezier(.3,.8,.3,1); }
.cb-val { text-align: right; font-size: 12px; }
.cb-val .c { font-family: var(--serif); font-weight: 800; }
.cb-val .p { font-size: 10.5px; color: var(--faint); }
.sugg { display: flex; gap: 13px; padding: 15px 17px; border-radius: 13px; background: linear-gradient(180deg, var(--accent-soft), rgba(31,122,90,.04)); border: 1px solid rgba(31,122,90,.18); }
.sugg-ic { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; }
.sugg-ic svg { width: 19px; height: 19px; }
.sugg-tt { font-size: 13px; font-weight: 700; font-family: var(--serif); margin-bottom: 4px; }
.sugg-tx { font-size: 12.5px; color: var(--sub); line-height: 1.6; }
.sugg-tx b { color: var(--accent-deep); }
.sugg-act { display: flex; gap: 9px; margin-top: 11px; }
.s-btn { font-size: 12.5px; font-weight: 600; border-radius: 9px; padding: 8px 14px; cursor: pointer; border: 1px solid var(--border2); background: var(--panel); color: var(--ink); white-space: nowrap; }
.s-btn:hover { border-color: var(--accent); }
.s-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.s-btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
.stack { display: flex; flex-direction: column; gap: 18px; }
.toast { position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%); background: var(--ink); color: #fff; font-size: 13px; font-weight: 600; padding: 12px 20px; border-radius: 11px; box-shadow: 0 10px 30px rgba(0,0,0,.25); z-index: 80; display: flex; align-items: center; gap: 9px; }
.toast svg { width: 16px; height: 16px; color: var(--accent2); }
@media(max-width:880px){ .s-grid { grid-template-columns: 1fr; } }