diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c317b08..22174aa 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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[] diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 404e224..a481b2c 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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) { diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index f06d7ca..5a424df 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/dto/sms.dto.ts b/apps/api/src/auth/dto/sms.dto.ts new file mode 100644 index 0000000..6fc1c5a --- /dev/null +++ b/apps/api/src/auth/dto/sms.dto.ts @@ -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; +} diff --git a/apps/api/src/auth/sms.service.ts b/apps/api/src/auth/sms.service.ts new file mode 100644 index 0000000..8883eed --- /dev/null +++ b/apps/api/src/auth/sms.service.ts @@ -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(); + 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 } }; + } +} diff --git a/apps/web/src/api/sms.ts b/apps/web/src/api/sms.ts new file mode 100644 index 0000000..f9817e0 --- /dev/null +++ b/apps/web/src/api/sms.ts @@ -0,0 +1,10 @@ +import { http } from './http'; +import type { Org } from '../stores/auth'; + +export function sendSms(phone: string) { + return http.post('/auth/sms/send', { phone }); +} + +export function verifySms(phone: string, code: string) { + return http.post('/auth/sms/verify', { phone, code }); +} diff --git a/apps/web/src/components/PhoneAuthModal.vue b/apps/web/src/components/PhoneAuthModal.vue new file mode 100644 index 0000000..86b1377 --- /dev/null +++ b/apps/web/src/components/PhoneAuthModal.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/apps/web/src/pages/Landing.vue b/apps/web/src/pages/Landing.vue new file mode 100644 index 0000000..5b0ffa0 --- /dev/null +++ b/apps/web/src/pages/Landing.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/apps/web/src/pages/SourceTracing.vue b/apps/web/src/pages/SourceTracing.vue new file mode 100644 index 0000000..849a3a2 --- /dev/null +++ b/apps/web/src/pages/SourceTracing.vue @@ -0,0 +1,217 @@ + + + + + + diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 630c821..00ca2bc 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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; }); diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 8525859..b5caa16 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -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 }; }); diff --git a/apps/web/src/styles/landing.css b/apps/web/src/styles/landing.css new file mode 100644 index 0000000..2a3da6a --- /dev/null +++ b/apps/web/src/styles/landing.css @@ -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;} +} diff --git a/apps/web/src/styles/source.css b/apps/web/src/styles/source.css new file mode 100644 index 0000000..9092bb8 --- /dev/null +++ b/apps/web/src/styles/source.css @@ -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; } }