问题可能出在提示位置不够醒目:错误信息只显示在按钮旁边的小字区,容易被忽略或挤出视野。我加强了三层提示,确保“必填项为空时”一定能看到提醒。
主要改动:
- 每个字段下方出现独立红色错误文字(最直观)。
- 顶部浮动 toast 提示,不可能错过。
- 按钮旁状态栏文字同步提示(保留原有)。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>首页 · 右侧呼出需求咨询板</title>
<style>
:root{
--bg:#f7f8fa;
--card:#ffffff;
--line:#e5e7eb;
--text:#111827;
--muted:#6b7280;
--brand:#2563eb;
--ok:#059669;
--err:#dc2626;
}
*{box-sizing:border-box}
html{scrollbar-gutter:stable}
body{
margin:0;
background:var(--bg);
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
-webkit-font-smoothing:antialiased;
}
body::before{
content:'';
position:fixed;
inset:0;
background:radial-gradient(820px 380px at 50% -8%, #dbeafe 0%, transparent 65%);
pointer-events:none;
z-index:0;
}
body.gb-open{overflow:hidden}
/* ================= 模拟首页内容 ================= */
.page{position:relative;z-index:1}
.nav{
max-width:960px;margin:0 auto;
padding:22px 24px;
display:flex;align-items:center;
}
.logo{font-weight:700;font-size:16px;letter-spacing:-.01em}
.logo span{color:var(--brand)}
.nav .spacer{flex:1}
.nav a{
color:var(--muted);text-decoration:none;
font-size:14px;margin-left:20px;transition:color .15s;
}
.nav a:hover{color:var(--text)}
.hero{max-width:960px;margin:0 auto;padding:52px 24px 40px}
.hero .tag{
display:inline-block;
font-size:12.5px;font-weight:600;
color:var(--brand);
background:#eff6ff;
border:1px solid #dbeafe;
border-radius:999px;
padding:4px 12px;
margin-bottom:18px;
}
.hero h1{
font-size:clamp(28px,5vw,42px);
line-height:1.18;
letter-spacing:-.025em;
margin:0 0 16px;
}
.hero p{
font-size:16px;color:var(--muted);
line-height:1.78;margin:0;max-width:600px;
}
.hero p b{color:var(--text);font-weight:600}
.cards{
max-width:960px;margin:0 auto;
padding:0 24px 96px;
display:grid;
grid-template-columns:repeat(auto-fit,minmax(230px,1fr));
gap:16px;
}
.card{
background:var(--card);
border:1px solid var(--line);
border-radius:14px;
padding:20px 20px 22px;
transition:box-shadow .2s, transform .2s;
}
.card:hover{
transform:translateY(-2px);
box-shadow:0 14px 32px -18px rgba(16,24,40,.3);
}
.card h3{margin:0 0 8px;font-size:15px}
.card p{margin:0;font-size:13.5px;line-height:1.7;color:var(--muted)}
/* ================= 右侧触发图标 ================= */
.gb-trigger{
position:fixed;
right:0;
top:50%;
z-index:60;
display:flex;
flex-direction:column;
align-items:center;
gap:9px;
padding:15px 9px 17px;
border:1px solid var(--line);
border-right:0;
border-radius:14px 0 0 14px;
background:#fff;
color:var(--text);
cursor:pointer;
box-shadow:-8px 0 26px -12px rgba(16,24,40,.28);
transform:translateY(-50%) translateX(0);
transition:transform .34s cubic-bezier(.22,.61,.36,1),
box-shadow .25s, opacity .25s;
outline:none;
}
.gb-trigger:hover{
transform:translateY(-50%) translateX(-3px);
box-shadow:-12px 0 30px -12px rgba(16,24,40,.34);
}
.gb-trigger:focus-visible{
box-shadow:-8px 0 26px -12px rgba(16,24,40,.28), 0 0 0 3px rgba(37,99,235,.35);
}
.tr-ico{
display:block;
width:22px;height:22px;
color:var(--brand);
}
.tr-ico svg{width:100%;height:100%;display:block}
.tr-txt{
writing-mode:vertical-rl;
font-size:12.5px;
font-weight:600;
letter-spacing:3px;
color:#374151;
line-height:1;
}
.tr-dot{
position:absolute;
top:9px;right:8px;
width:9px;height:9px;
border-radius:50%;
background:#ef4444;
border:1.5px solid #fff;
display:none;
}
.tr-dot.show{
display:block;
animation:pulse 1.9s ease-in-out infinite;
}
@keyframes pulse{
0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.6)}
55%{box-shadow:0 0 0 6px rgba(239,68,68,0)}
}
body.gb-open .gb-trigger{
opacity:0;
pointer-events:none;
transform:translateY(-50%) translateX(110%);
}
/* ================= 遮罩 ================= */
.gb-overlay{
position:fixed;
inset:0;
z-index:70;
background:rgba(15,23,42,.45);
-webkit-backdrop-filter:blur(2px);
backdrop-filter:blur(2px);
opacity:0;
pointer-events:none;
transition:opacity .34s ease;
}
.gb-overlay.show{opacity:1;pointer-events:auto}
/* ================= 顶部浮动 Toast ================= */
.gb-toast{
position:fixed;
top:22px;
left:50%;
transform:translate(-50%, -16px);
z-index:200;
padding:11px 18px 11px 14px;
background:#dc2626;
color:#fff;
border-radius:11px;
font-size:13.5px;
font-weight:600;
line-height:1.5;
box-shadow:0 12px 36px -12px rgba(220,38,38,.55);
display:flex;
align-items:center;
gap:9px;
max-width:calc(100vw - 32px);
opacity:0;
pointer-events:none;
transition:opacity .22s ease, transform .22s ease;
}
.gb-toast.show{
opacity:1;
transform:translate(-50%, 0);
}
.gb-toast .t-ico{
flex:none;
width:18px;height:18px;
display:grid;place-items:center;
border-radius:50%;
background:rgba(255,255,255,.22);
}
.gb-toast .t-ico svg{
width:12px;height:12px;display:block;
}
.gb-toast .t-txt{
min-width:0;
word-break:break-word;
}
.gb-toast.ok{background:#059669;box-shadow:0 12px 36px -12px rgba(5,150,105,.5)}
/* ================= 抽屉面板 ================= */
.gb-drawer{
position:fixed;
top:0;right:0;
z-index:80;
width:min(440px,100%);
height:100vh;
height:100dvh;
background:#fff;
display:flex;
flex-direction:column;
box-shadow:-26px 0 70px -24px rgba(15,23,42,.4);
transform:translateX(102%);
transition:transform .4s cubic-bezier(.22,.61,.36,1);
will-change:transform;
}
.gb-drawer.show{transform:translateX(0)}
/* --- 抽屉头部 --- */
.dh{
flex:none;
padding:18px 20px 15px;
border-bottom:1px solid var(--line);
display:flex;
align-items:flex-start;
gap:12px;
}
.dh-main{flex:1;min-width:0}
.dh-title{
display:flex;align-items:center;gap:9px;
margin:0;
font-size:16.5px;
letter-spacing:-.01em;
}
.dh-dot{
width:8px;height:8px;flex:none;
border-radius:50%;
background:var(--ok);
box-shadow:0 0 0 4px rgba(5,150,105,.14);
}
.dh-sub{
margin:7px 0 0;
font-size:12.8px;
line-height:1.6;
color:var(--muted);
}
.dh-close{
flex:none;
width:32px;height:32px;
display:grid;place-items:center;
border:1px solid transparent;
border-radius:9px;
background:transparent;
color:#9ca3af;
cursor:pointer;
transition:background .15s, color .15s;
}
.dh-close:hover{background:#f3f4f6;color:#374151}
.dh-close svg{width:17px;height:17px;display:block}
/* --- 抽屉滚动区 --- */
.gb-body{
flex:1;
min-height:0;
overflow-y:auto;
overscroll-behavior:contain;
-webkit-overflow-scrolling:touch;
padding:18px 20px 26px;
}
.gb-body::-webkit-scrollbar{width:6px}
.gb-body::-webkit-scrollbar-thumb{
background:#d8dbe0;border-radius:99px;
}
.gb-body::-webkit-scrollbar-thumb:hover{background:#c3c7cd}
/* ================= 表单 ================= */
.row{display:flex;gap:11px}
.row .field{flex:1;min-width:0}
.field{margin-bottom:14px}
.field label{
display:block;
font-size:12.6px;
font-weight:600;
color:#374151;
margin-bottom:6px;
}
.field label .req{color:var(--err);margin-left:2px;font-weight:700}
input[type=text],
input[type=email],
textarea{
width:100%;
padding:10px 12px;
border:1px solid var(--line);
border-radius:10px;
background:#fcfcfd;
color:var(--text);
font:inherit;
font-size:14px;
outline:none;
transition:border-color .15s, box-shadow .15s, background .15s;
}
textarea{
min-height:104px;
resize:vertical;
line-height:1.66;
}
input::placeholder,textarea::placeholder{color:#9ca3af}
input:focus,textarea:focus{
background:#fff;
border-color:var(--brand);
box-shadow:0 0 0 3.5px rgba(37,99,235,.13);
}
/* 校验失败:输入框红框 + 抖动 */
input.invalid,
textarea.invalid{
border-color:#fca5a5;
background:#fff7f7;
}
input.invalid:focus,
textarea.invalid:focus{
border-color:var(--err);
box-shadow:0 0 0 3.5px rgba(220,38,38,.13);
}
input.invalid,
textarea.invalid{
animation:shake .32s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake{
10%,90%{transform:translateX(-1px)}
20%,80%{transform:translateX(2px)}
30%,50%,70%{transform:translateX(-3px)}
40%,60%{transform:translateX(3px)}
}
/* 字段下方的错误提示 */
.field-err{
display:none;
margin-top:6px;
font-size:12.2px;
line-height:1.5;
color:var(--err);
align-items:center;
gap:5px;
}
.field-err.show{display:flex}
.field-err::before{
content:'';
flex:none;
width:13px;height:13px;
background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23dc2626' stroke-width='2.4' stroke-linecap='round'><circle cx='12' cy='12' r='10'/><path d='M12 7v6M12 16.5v.01'/></svg>") center/contain no-repeat;
}
/* 快捷方向选择 */
.quick{
display:flex;
flex-wrap:wrap;
gap:7px;
margin-bottom:14px;
}
.quick button{
appearance:none;
border:1px solid var(--line);
background:#fcfcfd;
color:#4b5563;
font:inherit;
font-size:12.4px;
padding:5px 11px;
border-radius:999px;
cursor:pointer;
transition:all .15s;
white-space:nowrap;
}
.quick button:hover{
border-color:#bfdbfe;
background:#eff6ff;
color:var(--brand);
}
.quick button:active{transform:scale(.97)}
.counter{
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
margin-top:6px;
font-size:11.8px;
color:var(--muted);
min-height:16px;
}
.counter.warn{color:#d97706}
.actions{
display:flex;
align-items:center;
gap:11px;
margin-top:18px;
}
button.submit{
appearance:none;
border:0;
border-radius:10px;
padding:10px 18px;
font:inherit;
font-size:14px;
font-weight:600;
color:#fff;
background:linear-gradient(180deg,#3b82f6,var(--brand));
box-shadow:0 1px 2px rgba(37,99,235,.35), inset 0 1px 0 rgba(255,255,255,.22);
cursor:pointer;
display:inline-flex;
align-items:center;
gap:7px;
white-space:nowrap;
transition:filter .15s, transform .06s;
}
button.submit:hover:not(:disabled){filter:brightness(1.06)}
button.submit:active:not(:disabled){transform:translateY(1px)}
button.submit:disabled{opacity:.62;cursor:not-allowed}
button.submit .s-ico{width:15px;height:15px;display:block}
button.submit .s-ico svg{width:100%;height:100%;display:block}
.spinner{
width:13px;height:13px;
border:2px solid rgba(255,255,255,.45);
border-top-color:#fff;
border-radius:50%;
animation:spin .7s linear infinite;
display:none;
}
.loading .spinner{display:block}
.loading .s-ico{display:none}
@keyframes spin{to{transform:rotate(360deg)}}
.status{
flex:1;
min-width:0;
font-size:12.8px;
line-height:1.5;
}
.status.ok{color:var(--ok)}
.status.err{color:var(--err)}
.status.info{color:var(--muted)}
/* 蜜罐 */
.hp{
position:absolute!important;
left:-9999px!important;
width:1px;height:1px;
opacity:0;
pointer-events:none;
}
/* ================= 留言列表 ================= */
.divider{
height:1px;
background:var(--line);
margin:22px 0 16px;
}
.list-head{
display:flex;
align-items:baseline;
justify-content:space-between;
margin-bottom:11px;
}
.list-head h3{
margin:0;
font-size:13.5px;
color:#374151;
letter-spacing:.01em;
}
.list-head span{font-size:12.2px;color:var(--muted)}
ul.msgs{list-style:none;margin:0;padding:0}
ul.msgs li{
padding:12px 13px;
border:1px solid var(--line);
border-radius:11px;
background:#fcfcfd;
margin-bottom:9px;
animation:pop .3s ease both;
}
@keyframes pop{
from{opacity:0;transform:translateY(-7px)}
to{opacity:1;transform:none}
}
.m-top{
display:flex;
align-items:center;
gap:8px;
margin-bottom:6px;
flex-wrap:wrap;
}
.avatar{
width:25px;height:25px;
flex:none;
border-radius:50%;
display:grid;place-items:center;
font-size:11px;
font-weight:700;
color:#fff;
}
.m-name{font-size:13.2px;font-weight:600}
.m-time{
font-size:11.5px;
color:var(--muted);
margin-left:auto;
white-space:nowrap;
}
.m-body{
font-size:13.6px;
line-height:1.7;
color:#374151;
white-space:pre-wrap;
word-break:break-word;
}
/* 需求类型标签 */
.tag-pill{
font-size:10.5px;
font-weight:600;
padding:2px 8px;
border-radius:999px;
border:1px solid transparent;
white-space:nowrap;
flex:none;
}
.badge{
font-size:10.5px;
font-weight:600;
padding:2px 7px;
border-radius:999px;
background:#fef3c7;
color:#92400e;
border:1px solid #fde68a;
flex:none;
}
.empty{
padding:20px 0 8px;
text-align:center;
font-size:13px;
color:var(--muted);
}
/* ================= 演示模式提示 ================= */
.demo-bar{
display:none;
gap:8px;
margin:0 0 15px;
padding:9px 12px;
font-size:12.2px;
line-height:1.55;
color:#78350f;
background:#fffbeb;
border:1px solid #fde68a;
border-radius:10px;
}
.demo-bar.show{display:flex}
.demo-bar code{
font-size:11.5px;
background:rgba(120,53,15,.1);
padding:1px 4px;
border-radius:4px;
}
/* ================= 响应式 ================= */
@media (max-width:520px){
.nav{padding:16px 16px}
.nav a{margin-left:14px;font-size:13px}
.hero{padding:34px 16px 26px}
.cards{padding:0 16px 80px}
.row{flex-direction:column;gap:0}
.dh{padding:15px 16px 13px}
.gb-body{padding:15px 16px 24px}
.gb-trigger{padding:13px 8px 15px;gap:8px}
.tr-ico{width:20px;height:20px}
.tr-txt{font-size:11.5px;letter-spacing:2.5px}
.gb-toast{top:14px;font-size:13px;padding:10px 14px 10px 12px}
}
@media (prefers-reduced-motion:reduce){
*{transition-duration:.01ms!important;animation-duration:.01ms!important}
}
</style>
</head>
<body>
<!-- 顶部浮动提示 -->
<div class="gb-toast" id="gbToast" role="alert" aria-live="assertive">
<span class="t-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.6" stroke-linecap="round">
<path d="M12 7v6M12 16.5v.01"/>
<circle cx="12" cy="12" r="10"/>
</svg>
</span>
<span class="t-txt" id="gbToastText"></span>
</div>
<!-- ===================== 模拟首页 ===================== -->
<div class="page">
<header class="nav">
<div class="logo">我的<span>小站</span></div>
<div class="spacer"></div>
<a href="#">首页</a>
<a href="#">案例</a>
<a href="#">关于</a>
</header>
<section class="hero">
<div class="tag">小程序 · 网站 · 备案 · 软件开发</div>
<h1>有需求?<br>先聊聊,不收费</h1>
<p>
从 <b>小程序</b>、<b>企业网站</b> 到 <b>ICP 备案</b>、<b>定制软件开发</b>,
有想法或卡在某个环节,都可以直接留言。
点右侧的 <b>「需求咨询」</b> 图标,一分钟说清楚你的情况。
</p>
</section>
<section class="cards">
<div class="card">
<h3>小程序开发</h3>
<p>预约、商城、报名、工具类小程序,含微信支付、订阅消息等能力对接。</p>
</div>
<div class="card">
<h3>企业网站 / 官网改版</h3>
<p>官网、品牌站、落地页,支持移动端适配、SEO 基础优化与后台内容管理。</p>
</div>
<div class="card">
<h3>ICP 备案与合规</h3>
<p>首次备案、接入备案、备案变更、驳回重提,协助梳理材料与网站命名。</p>
</div>
<div class="card">
<h3>软件开发与接手</h3>
<p>管理系统、SaaS 后台、接口对接,也接受接手他人未完成的代码。</p>
</div>
</section>
</div>
<!-- ===================== 触发按钮 ===================== -->
<button class="gb-trigger" id="gbTrigger" type="button"
aria-label="打开需求咨询" aria-expanded="false" aria-controls="gbDrawer">
<span class="tr-ico" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="tr-txt">需求咨询</span>
<span class="tr-dot show" id="trDot" aria-hidden="true"></span>
</button>
<!-- ===================== 遮罩 ===================== -->
<div class="gb-overlay" id="gbOverlay"></div>
<!-- ===================== 抽屉面板 ===================== -->
<aside class="gb-drawer" id="gbDrawer" role="dialog" aria-modal="true"
aria-labelledby="gbTitle" tabindex="-1">
<div class="dh">
<div class="dh-main">
<h2 class="dh-title" id="gbTitle">
<span class="dh-dot"></span>提交需求 / 咨询
</h2>
<p class="dh-sub">留下你的需求,会直接发送到站长邮箱,通常 1 个工作日内回复。</p>
</div>
<button class="dh-close" id="gbClose" type="button" aria-label="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.9" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18"/>
</svg>
</button>
</div>
<div class="gb-body" id="gbBody">
<div class="demo-bar" id="demoBar">
<span>🔔</span>
<span><b>预览模式</b>:未检测到后端,提交走本地模拟。部署 <code>server.js</code> 后自动切换为真实提交并发邮件。</span>
</div>
<form id="gbForm" novalidate>
<div class="row">
<div class="field">
<label for="gbName">称呼 <span class="req">*</span></label>
<input id="gbName" name="name" type="text" maxlength="50" required
placeholder="怎么称呼你?" autocomplete="name">
<div class="field-err" id="gbErrName"></div>
</div>
<div class="field">
<label for="gbEmail">邮箱 <span class="req">*</span></label>
<input id="gbEmail" name="email" type="email" maxlength="100" required
placeholder="接收回复的邮箱" autocomplete="email">
<div class="field-err" id="gbErrEmail"></div>
</div>
</div>
<div class="field">
<label for="gbContent">需求描述 <span class="req">*</span></label>
<!-- 快捷填入:点击哪个,就只显示哪句 -->
<div class="quick" id="gbQuick">
<button type="button" data-fill="想做一个微信小程序,主要功能是:">小程序</button>
<button type="button" data-fill="公司官网需要改版,现有站点情况是:">企业网站</button>
<button type="button" data-fill="ICP 备案遇到问题,具体情况是:">ICP 备案</button>
<button type="button" data-fill="有个软件项目想找人开发 / 接手,大致需求是:">软件开发</button>
<button type="button" data-fill="想先了解一下大概报价和工期,我的需求是:">问报价</button>
</div>
<textarea id="gbContent" name="content" maxlength="1000" required
placeholder="简单说说你的需求,例如:想做一个预约类小程序,需要微信支付和短信提醒,大概多久能上线?"></textarea>
<div class="counter" id="gbCounter">
<span id="gbHint">内容至少 2 个字</span>
<span id="gbCount">0 / 1000</span>
</div>
<div class="field-err" id="gbErrContent"></div>
</div>
<!-- 蜜罐字段:正常用户看不见 -->
<input class="hp" id="gbHp" type="text" name="website"
tabindex="-1" autocomplete="off" aria-hidden="true">
<div class="actions">
<button class="submit" type="submit" id="gbSubmit">
<span class="spinner"></span>
<span class="s-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</svg>
</span>
<span id="gbSubmitText">提交需求</span>
</button>
<div class="status info" id="gbStatus" role="status" aria-live="polite"></div>
</div>
</form>
<div class="divider"></div>
<div class="list-head">
<h3>近期咨询</h3>
<span id="gbListCount">共 0 条</span>
</div>
<ul class="msgs" id="gbList"></ul>
<div class="empty" id="gbEmpty" style="display:none">还没有咨询记录,来说说你的需求吧</div>
</div>
</aside>
<script>
(function () {
'use strict';
/* ==========================================================
* 配置
* ======================================================== */
var API = '/api/messages';
var MAX = 1000;
/* ==========================================================
* 演示数据
* ======================================================== */
var messages = [
{
id: 6,
name: '张先生',
tag: '小程序',
content: '想做一个预约类小程序,门店 3 家,需要微信支付和到店提醒短信,问下大概报价和工期。',
created_at: '2026-10-05 10:24',
approved: true
},
{
id: 5,
name: 'Ada',
tag: 'ICP备案',
content: '公司官网备案被驳回了,说网站名称不合规。原来的服务商不管了,这种情况能帮忙重新提交吗?',
created_at: '2026-10-04 17:06',
approved: true
},
{
id: 4,
name: '老陈',
tag: '软件开发',
content: '手上有个 SaaS 后台做到一半,前端 Vue2 写完了,后端接口没接。想找人接手继续开发,能评估下工作量吗?',
created_at: '2026-10-04 09:41',
approved: true
},
{
id: 3,
name: '小林',
tag: '企业网站',
content: '公司官网想改版,现在用的是老模板站,手机端打开排版全乱。想换成自适应版本,顺便做下 SEO 基础优化。',
created_at: '2026-10-03 15:32',
approved: true
},
{
id: 2,
name: '匿名',
tag: '小程序',
content: '已有的小程序想加微信支付功能,公众号已经认证过了,需要准备哪些材料?',
created_at: '2026-10-02 20:18',
approved: true
}
];
/* 需求类型配色 */
var TAG_STYLE = {
'小程序': 'background:#eff6ff;color:#2563eb;border-color:#dbeafe;',
'企业网站': 'background:#f5f3ff;color:#7c3aed;border-color:#ede9fe;',
'ICP备案': 'background:#fffbeb;color:#b45309;border-color:#fde68a;',
'软件开发': 'background:#ecfdf5;color:#047857;border-color:#d1fae5;',
'报价咨询': 'background:#fef2f2;color:#b91c1c;border-color:#fecaca;'
};
/* ==========================================================
* DOM
* ======================================================== */
var trigger = document.getElementById('gbTrigger');
var trDot = document.getElementById('trDot');
var overlay = document.getElementById('gbOverlay');
var drawer = document.getElementById('gbDrawer');
var closeBtn = document.getElementById('gbClose');
var bodyEl = document.getElementById('gbBody');
var form = document.getElementById('gbForm');
var fName = document.getElementById('gbName');
var fEmail = document.getElementById('gbEmail');
var fContent = document.getElementById('gbContent');
var fHp = document.getElementById('gbHp');
var quickBox = document.getElementById('gbQuick');
var errName = document.getElementById('gbErrName');
var errEmail = document.getElementById('gbErrEmail');
var errContent = document.getElementById('gbErrContent');
var submitBtn = document.getElementById('gbSubmit');
var submitText = document.getElementById('gbSubmitText');
var statusEl = document.getElementById('gbStatus');
var counterEl = document.getElementById('gbCounter');
var hintEl = document.getElementById('gbHint');
var countEl = document.getElementById('gbCount');
var listEl = document.getElementById('gbList');
var emptyEl = document.getElementById('gbEmpty');
var listCount = document.getElementById('gbListCount');
var demoBar = document.getElementById('demoBar');
var toastEl = document.getElementById('gbToast');
var toastText = document.getElementById('gbToastText');
var isOpen = false;
var demoMode = true;
var lastFocus = null;
/* ==========================================================
* 工具函数
* ======================================================== */
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c];
});
}
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function nowStr() {
var d = new Date();
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function avatarColor(name) {
var palette = ['#2563eb','#7c3aed','#0891b2','#059669','#d97706','#dc2626','#db2777'];
var s = String(name || '匿名'), h = 0;
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 997;
return palette[h % palette.length];
}
function setStatus(text, kind) {
statusEl.textContent = text || '';
statusEl.className = 'status ' + (kind || 'info');
}
function setLoading(on) {
submitBtn.disabled = on;
submitBtn.classList.toggle('loading', on);
submitText.textContent = on ? '提交中…' : '提交需求';
}
function resetCounter() {
countEl.textContent = '0 / ' + MAX;
hintEl.textContent = '内容至少 2 个字';
counterEl.classList.remove('warn');
}
/* ---------- Toast 顶部浮动提示 ---------- */
var toastTimer = null;
function showToast(msg, kind) {
toastText.textContent = msg || '请检查表单';
toastEl.classList.toggle('ok', kind === 'ok');
// 强制重绘以便重新触发动画
void toastEl.offsetWidth;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove('show');
}, 3200);
}
/* ---------- 字段级错误 ---------- */
function setFieldError(input, errEl, msg) {
if (!msg) {
errEl.textContent = '';
errEl.classList.remove('show');
input.classList.remove('invalid');
return;
}
errEl.textContent = msg;
errEl.classList.add('show');
// 先移除再添加,重新触发抖动动画
input.classList.remove('invalid');
void input.offsetWidth;
input.classList.add('invalid');
}
function clearAllErrors() {
setFieldError(fName, errName, '');
setFieldError(fEmail, errEmail, '');
setFieldError(fContent, errContent, '');
}
/* ==========================================================
* 渲染列表
* ======================================================== */
function render() {
var visible = messages.filter(function (m) { return m.approved; });
listCount.textContent = '共 ' + visible.length + ' 条';
if (!visible.length) {
listEl.innerHTML = '';
emptyEl.style.display = '';
return;
}
emptyEl.style.display = 'none';
listEl.innerHTML = visible.map(function (m) {
var name = m.name || '匿名';
var initial = name.slice(0, 1).toUpperCase();
var tagHtml = '';
if (m.tag) {
var style = TAG_STYLE[m.tag] || TAG_STYLE['小程序'];
tagHtml = '<span class="tag-pill" style="' + style + '">' + esc(m.tag) + '</span>';
}
var badge = m.pending ? '<span class="badge">待回复</span>' : '';
return '' +
'<li>' +
'<div class="m-top">' +
'<span class="avatar" style="background:' + avatarColor(name) + '">' +
esc(initial) +
'</span>' +
'<span class="m-name">' + esc(name) + '</span>' +
tagHtml +
badge +
'<span class="m-time">' + esc(m.created_at || '') + '</span>' +
'</div>' +
'<div class="m-body">' + esc(m.content) + '</div>' +
'</li>';
}).join('');
}
/* ==========================================================
* 开关抽屉
* ======================================================== */
function openDrawer() {
if (isOpen) return;
isOpen = true;
lastFocus = document.activeElement;
document.body.classList.add('gb-open');
drawer.classList.add('show');
overlay.classList.add('show');
trigger.setAttribute('aria-expanded', 'true');
trDot.classList.remove('show');
setTimeout(function () {
if (isOpen) fName.focus({ preventScroll: true });
}, 340);
}
function closeDrawer() {
if (!isOpen) return;
isOpen = false;
document.body.classList.remove('gb-open');
drawer.classList.remove('show');
overlay.classList.remove('show');
trigger.setAttribute('aria-expanded', 'false');
if (lastFocus && typeof lastFocus.focus === 'function') {
lastFocus.focus({ preventScroll: true });
} else {
trigger.focus({ preventScroll: true });
}
}
trigger.addEventListener('click', openDrawer);
closeBtn.addEventListener('click', closeDrawer);
overlay.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen) {
e.preventDefault();
closeDrawer();
}
});
document.addEventListener('focusin', function (e) {
if (isOpen && !drawer.contains(e.target) && !toastEl.contains(e.target)) {
fName.focus({ preventScroll: true });
}
});
/* ==========================================================
* 快捷填入
* ======================================================== */
quickBox.addEventListener('click', function (e) {
var btn = e.target.closest('button[data-fill]');
if (!btn) return;
var prefix = btn.getAttribute('data-fill');
fContent.value = prefix;
fContent.classList.remove('invalid');
errContent.classList.remove('show');
errContent.textContent = '';
fContent.focus();
try {
fContent.setSelectionRange(fContent.value.length, fContent.value.length);
} catch (err) { /* 忽略 */ }
fContent.dispatchEvent(new Event('input'));
});
/* ==========================================================
* 实时清除字段错误
* ======================================================== */
fName.addEventListener('input', function () {
if (fName.value.trim()) setFieldError(fName, errName, '');
});
fEmail.addEventListener('input', function () {
if (fEmail.value.trim()) setFieldError(fEmail, errEmail, '');
});
fContent.addEventListener('input', function () {
var len = fContent.value.length;
countEl.textContent = len + ' / ' + MAX;
var tooLong = len >= MAX;
counterEl.classList.toggle('warn', tooLong);
hintEl.textContent = tooLong
? '已达上限'
: (len > 0 && len < 2 ? '还差一点点' : '内容至少 2 个字');
if (len >= 2) setFieldError(fContent, errContent, '');
});
/* ==========================================================
* 校验 + 提交
* ======================================================== */
function validate() {
var errors = [];
// 称呼
if (!fName.value.trim()) {
setFieldError(fName, errName, '请填写你的称呼');
errors.push({ input: fName, msg: '请填写你的称呼' });
} else {
setFieldError(fName, errName, '');
}
// 邮箱
var email = fEmail.value.trim();
if (!email) {
setFieldError(fEmail, errEmail, '请填写邮箱,方便回复你');
errors.push({ input: fEmail, msg: '请填写邮箱,方便回复你' });
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setFieldError(fEmail, errEmail, '邮箱格式看起来不太对');
errors.push({ input: fEmail, msg: '邮箱格式看起来不太对' });
} else {
setFieldError(fEmail, errEmail, '');
}
// 需求描述
var content = fContent.value.trim();
if (content.length < 2) {
setFieldError(fContent, errContent, '请简单描述一下你的需求(至少 2 个字)');
errors.push({ input: fContent, msg: '请简单描述一下你的需求(至少 2 个字)' });
} else {
setFieldError(fContent, errContent, '');
}
return errors;
}
form.addEventListener('submit', function (e) {
e.preventDefault();
// 蜜罐命中,静默成功
if (fHp.value.trim()) {
showToast('提交成功,会尽快联系你', 'ok');
setStatus('提交成功,会尽快联系你', 'ok');
form.reset();
clearAllErrors();
resetCounter();
return;
}
var errors = validate();
if (errors.length) {
// 三层提示同时出现
var first = errors[0];
// ① 顶部浮动 toast
showToast(first.msg);
// ② 按钮旁状态提示
setStatus(first.msg, 'err');
// ③ 聚焦到第一个出错的输入框
try {
first.input.focus({ preventScroll: false });
} catch (err) {
first.input.focus();
}
return;
}
/* ---------- 校验通过,提交 ---------- */
var data = {
name: fName.value.trim(),
email: fEmail.value.trim(),
content: fContent.value.trim()
};
setLoading(true);
setStatus('');
/* 演示模式 */
if (demoMode) {
setTimeout(function () {
messages.unshift({
id: Date.now(),
name: data.name,
tag: guessTag(data.content),
content: data.content,
created_at: nowStr(),
approved: true,
pending: true
});
render();
form.reset();
clearAllErrors();
resetCounter();
setLoading(false);
showToast('已提交(预览模式,未真实发送邮件)', 'ok');
setStatus('✓ 已提交(预览模式,未真实发送邮件)', 'ok');
bodyEl.scrollTo({ top: bodyEl.scrollHeight, behavior: 'smooth' });
}, 620);
return;
}
/* 真实提交 */
fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function (res) {
return res.json()
.catch(function () { return {}; })
.then(function (json) {
if (!res.ok || !json.ok) {
throw new Error(json.error || '提交失败,请稍后重试');
}
return json;
});
})
.then(function (json) {
var msg = json.message || '已收到你的需求,会尽快回复';
showToast(msg, 'ok');
setStatus('✓ ' + msg, 'ok');
form.reset();
clearAllErrors();
resetCounter();
})
.catch(function (err) {
var msg = err.message || '网络错误,请稍后重试';
showToast(msg);
setStatus(msg, 'err');
})
.then(function () {
setLoading(false);
});
});
function guessTag(text) {
var t = String(text || '');
if (/小程序|公众号|微信支付/.test(t)) return '小程序';
if (/备案|ICP|域名/.test(t)) return 'ICP备案';
if (/官网|网站|改版|落地页/.test(t)) return '企业网站';
if (/系统|后台|SaaS|接口|开发|接手/.test(t)) return '软件开发';
if (/报价|多少钱|工期|预算/.test(t)) return '报价咨询';
return '';
}
/* ==========================================================
* 探测后端
* ======================================================== */
(function probe() {
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 2500);
fetch(API, { method: 'GET', signal: ctrl ? ctrl.signal : undefined })
.then(function (res) {
clearTimeout(timer);
if (!res.ok) throw new Error('no backend');
return res.json();
})
.then(function (json) {
demoMode = false;
demoBar.classList.remove('show');
if (json && json.ok && Array.isArray(json.data)) {
messages = json.data.map(function (m) {
return {
id: m.id,
name: m.name,
tag: m.tag || guessTag(m.content),
content: m.content,
created_at: m.created_at,
approved: true
};
});
} else {
messages = [];
}
render();
})
.catch(function () {
clearTimeout(timer);
demoMode = true;
demoBar.classList.add('show');
render();
});
})();
resetCounter();
render();
})();
</script>
</body>
</html>修改说明
1. 三层错误提示同时触发
必填项为空点提交时,现在会同时出现以下三处提醒:
- 顶部浮动 toast(红色圆角卡片,2 秒后自动消失),文字醒目,不受抽屉滚动影响。
- 字段下方的红色错误文字,直接标在对应输入框下面,并带一个圆形感叹号图标。
- 按钮旁的 status 文字同步显示同样的错误提示。
2. 输入框抖动 + 红框
出错字段会播放一个短促的左右抖动动画,并变成浅红底 + 红边框,即使页面滚到别处也能一眼看到。
3. 自动聚焦到第一个错误字段
如果同时有多个字段为空,会优先聚焦到最上面的那个(称呼 → 邮箱 → 需求描述),用户按顺序补填即可。
4. 输入时自动清除错误
用户开始输入后,对应字段的红框和错误文字会立即消失,无需再点一次提交。



