feat(web): landing 4-item nav, section relabels, eco-materials section

This commit is contained in:
zty 2026-07-07 03:37:01 -04:00
parent ef35204bc6
commit 208c6b93ba
1 changed files with 250 additions and 0 deletions

View File

@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from 'react'
import { history } from '@umijs/max'
import { LOC_STOR_KEY } from '@/common/constants'
import PhoneAuthModal from '@/components/phone-auth-modal'
import api from '@/services/api'
import type { EcoMaterial } from '@/services/api/material'
import './landing.css'
const LeafIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M4 20c10 2 16-4 16-14 0 0-8-2-12 2-3 3-3 7-1 9 3-4 6-6 9-7" /></svg>
)
const ArrowIcon = ({ small }: { small?: boolean }) => (
<svg viewBox="0 0 24 24" width={small ? 15 : undefined} height={small ? 15 : undefined} fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
)
const CheckIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
)
const ChevronIcon = ({ dir }: { dir: 'left' | 'right' }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round"><path d={dir === 'left' ? 'M15 5l-7 7 7 7' : 'M9 5l7 7-7 7'} /></svg>
)
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 hasToken = () => !!localStorage.getItem(LOC_STOR_KEY.AUTH_TOKEN)
const goPro = () => history.push(hasToken() ? '/home' : '/login')
const scrollTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const scrollToId = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
export default function Landing() {
const [cur, setCur] = useState(0)
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const go = (n: number) => setCur((((n % news.length) + news.length) % news.length))
const stop = () => { if (timer.current) { clearInterval(timer.current); timer.current = null } }
const start = () => { stop(); timer.current = setInterval(() => setCur(c => (c + 1) % news.length), 5500) }
const next = () => { setCur(c => (c + 1) % news.length); start() }
const prev = () => { setCur(c => (c - 1 + news.length) % news.length); start() }
useEffect(() => { start(); return stop }, [])
const [authOpen, setAuthOpen] = useState(false)
const [ecoList, setEcoList] = useState<EcoMaterial[]>([])
useEffect(() => {
api.material.getEcoMaterials().then(setEcoList).catch(() => setEcoList([]))
}, [])
const openPredict = () => {
if (hasToken()) history.push('/source')
else setAuthOpen(true)
}
const onAuthed = () => {
setAuthOpen(false)
history.push('/source')
}
return (
<div className="lp">
<header className="nav">
<div className="nav-in">
<a className="brand" onClick={scrollTop}>
<span className="brand-logo"><LeafIcon /></span>
<span><span className="brand-tt"></span><br /><span className="brand-sub">INDOOR AIR · </span></span>
</a>
<nav className="nav-links">
<a onClick={() => scrollToId('news')}></a>
<a onClick={() => scrollToId('cases')}></a>
<a onClick={() => scrollToId('eco')}></a>
<a onClick={() => history.push('/forum')}></a>
</nav>
<span className="nav-sp" />
<a className="btn btn-primary" onClick={openPredict}><ArrowIcon /></a>
</div>
</header>
<section className="hero">
<div className="wrap hero-grid">
<div>
<span className="eyebrow"><span className="pip" /> GB/T 18883-2022 GB 50325-2020 </span>
<h1>,<br /><em></em></h1>
<p className="hero-lead">,TVOC 6 ,,,</p>
<div className="hero-cta">
<a className="btn btn-primary btn-lg" onClick={openPredict}> · TVOC<ArrowIcon /></a>
<a className="btn btn-lg" onClick={() => scrollToId('cases')}></a>
</div>
<div className="hero-tags">
<span className="hero-tag"><CheckIcon />,</span>
<span className="hero-tag"><CheckIcon /></span>
<span className="hero-tag"><CheckIcon /></span>
</div>
</div>
<div className="hero-visual">
<div className="img-ph hero-img"> / </div>
<div className="hero-badge"><b>6 </b><span></span></div>
</div>
</div>
</section>
<section className="stats">
<div className="stats-in">
{stats.map((s, i) => <div className="stat" key={i}><b>{s.n}</b><span>{s.t}</span></div>)}
</div>
</section>
<section className="sec" id="news">
<div className="wrap">
<div className="sec-head">
<div><div className="sec-tag"></div><h2 className="sec-h">,</h2></div>
<a className="btn" onClick={openPredict}></a>
</div>
<div className="carousel">
<div className="cviewport">
<div className="ctrack" style={{ transform: `translateX(${-cur * 100}%)` }}>
{news.map((n, i) => (
<article className="cslide" key={i}>
<div className="cslide-img"><span className="cslide-tag">{n.tag}</span><div className="img-ph"></div></div>
<div className="cslide-body">
<div className="cslide-date">{n.date}</div>
<h3>{n.title}</h3>
<p>{n.desc}</p>
<span className="cslide-more"><ArrowIcon /></span>
</div>
</article>
))}
</div>
</div>
<button className="cnav prev" onClick={prev}><ChevronIcon dir="left" /></button>
<button className="cnav next" onClick={next}><ChevronIcon dir="right" /></button>
</div>
<div className="cdots">
{news.map((_, i) => <button key={i} className={'cdot' + (i === cur ? ' on' : '')} onClick={() => go(i)} />)}
</div>
</div>
</section>
<section className="sec" id="cases" style={{ background: 'var(--bg2)' }}>
<div className="wrap">
<div className="sec-head">
<div><div className="sec-tag"></div><h2 className="sec-h"> ,</h2><p className="sec-sub">真实流程演示:从预测超标,,</p></div>
</div>
<div className="cases-grid">
{cases.map((c, i) => (
<article className="case" key={i}>
<div className="img-ph"></div>
<div className="case-b">
<div className="case-type">{c.type}</div>
<h4>{c.name}</h4>
<div className="case-meta">{c.meta}</div>
<div className="ba">
<div className="ba-row"><span className="k"></span><div className="ba-track"><div className="ba-fill" style={{ width: c.w1 + '%', background: 'var(--bad)' }} /></div><span className="v" style={{ color: 'var(--bad)' }}>{c.v1}</span></div>
<div className="ba-row"><span className="k"></span><div className="ba-track"><div className="ba-fill" style={{ width: c.w2 + '%', background: 'var(--good)' }} /></div><span className="v" style={{ color: 'var(--good)' }}>{c.v2}</span></div>
</div>
<div className="case-foot"><span className="chip chip-good">{c.chip}</span><span className="lk"><ArrowIcon small /></span></div>
</div>
</article>
))}
</div>
</div>
</section>
{ecoList.length > 0 && (
<section className="sec" id="eco">
<div className="wrap">
<div className="sec-head">
<div>
<div className="sec-tag"></div>
<h2 className="sec-h">,</h2>
<p className="sec-sub"> E0/E1 , TVOC </p>
</div>
</div>
<div className="cases-grid">
{ecoList.map(m => (
<article className="case" key={m.material_id}>
<div className="case-b">
<div className="case-type">{m.category} · {m.brand}</div>
<h4>{m.name || m.brand || '环保建材'}</h4>
<div className="case-meta">{m.factory || '—'}</div>
<div className="case-foot">
<span className="chip chip-good"> {m.eco_level}</span>
{typeof m.methanal === 'number' && <span className="lk"> {m.methanal}</span>}
</div>
</div>
</article>
))}
</div>
</div>
</section>
)}
<section className="sec" id="how">
<div className="wrap">
<div className="sec-head"><div><div className="sec-tag">使</div><h2 className="sec-h">,</h2></div></div>
<div className="steps">
{steps.map((s, i) => (
<div className="step" key={i}>
<div className="step-n">{i + 1}</div><h4>{s.h}</h4><p>{s.p}</p>
{i < steps.length - 1 && <div className="step-ar"><ArrowIcon /></div>}
</div>
))}
</div>
</div>
</section>
<section className="sec" style={{ paddingTop: 0 }}>
<div className="wrap">
<div className="ctaband">
<div style={{ position: 'relative', zIndex: 2 }}>
<h2><br /></h2>
<p>,,</p>
</div>
<div style={{ display: 'flex', gap: 14, position: 'relative', zIndex: 2 }}>
<a className="btn btn-primary btn-lg" onClick={openPredict}></a>
<a className="btn btn-lg" onClick={goPro}></a>
</div>
<div className="deco" />
</div>
</div>
</section>
<footer className="footer">
<div className="footer-in">
<div className="brand">
<span className="brand-logo"><LeafIcon /></span>
<span className="brand-tt"></span>
</div>
<div> GB/T 18883-2022 · GB 50325-2020 · , CMA </div>
</div>
</footer>
<PhoneAuthModal open={authOpen} onSuccess={onAuthed} onCancel={() => setAuthOpen(false)} />
</div>
)
}