东营码良软件开发
  • 首页
  • 产品与服务
    抖音软件
  • 开发案例
  • 新闻资讯
    二级菜单A
  • 关于我们
  • GEO 优化
  • Ai超市
咨询客服
企业咨询
会员中心
登录注册找回密码
东营码良软件开发
首页›
产品与服务›
开发案例›
新闻资讯›
二级菜单A›
关于我们›GEO 优化›Ai超市›
会员中心
首页产品与服务开发案例新闻资讯我的
Dongying Maliang Software

码良软件开发

企业软件开发·微信小程序·网站建设·GEO 优化·AI 应用落地

咨询客服

导航

  • 首页
  • 产品与服务
  • 开发案例
  • 新闻资讯
  • 关于我们
  • GEO 优化
  • Ai超市

服务

  • 软件开发定制
  • GEO 搜索优化
  • 软件资源商城
  • 会员服务
  • 在线客服

行业方案

  • 零售门店
  • 教育培训
  • 医疗健康
  • 商务服务
  • 本地生活
  • 生产制造

联系电话

1886546092715154670927

地址

山东省东营市垦利区兴隆路8-5号

扫码

码良软件开发 · 品牌入驻
品牌入驻手机端查看商城、案例与在线咨询

Copyright © 2014-2026 东营码良软件开发 Inc. 版权所有鲁ICP备2025202136号-2豫公网安备41019602002340经营许可证编号:豫B2-20190103
开发资讯 > 资讯详情

一个精美的需求提交表单功能代码

2026-10-05 13:04:05阅读 2
广告
码良软件/小程序开发

码良软件/小程序开发

全栈技术团队 + 资深工程师开发 · 按需定制 · 源码交付 · 长期维护 · 东营本地可对接

小程序定制API接口设计bug修复升级改版软件开发企业管理系统
立即咨询 18865460927备用电话 15154670927

问题可能出在提示位置不够醒目:错误信息只显示在按钮旁边的小字区,容易被忽略或挤出视野。我加强了三层提示,确保“必填项为空时”一定能看到提醒。

主要改动:

  1. 每个字段下方出现独立红色错误文字(最直观)。
  2. 顶部浮动 toast 提示,不可能错过。
  3. 按钮旁状态栏文字同步提示(保留原有)。
<!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. 输入时自动清除错误

用户开始输入后,对应字段的红框和错误文字会立即消失,无需再点一次提交。

DISCUSSION

评论

登录后参与
请使用网站前台用户账号登录后发表评论。

正在加载评论...


上一篇百度百科词条如何创建|详细的创建词条流程在这里下一篇Meta Description 太长/太短如何修复

作者信息

超级管理
超级管理
等级作者
124文章
0点赞
0收藏
查看作者其他文章
广告🧩

软件定制 · 企业级项目 8 折起

10 年经验团队,按需定制 CRM、ERP、小程序,合同保障。

DDevStudio查看案例 →

文章展示

百度百科词条如何创建|详细的创建词条流程在这里 - 东营码良软件开发资讯百度百科词条如何创建|详细的创建词条流程在这里Meta Description 太长/太短如何修复 - 东营码良软件开发资讯Meta Description 太长/太短如何修复Meta Description 太长/太短对网站的收录有什么影响 - 东营码良软件开发资讯Meta Description 太长/太短对网站的收录有什么影响
广告位代码分享 - 东营码良软件开发资讯
广告位代码分享
「资讯与文章」里新增一项 「侧栏广告位」 - 东营码良软件开发资讯「资讯与文章」里新增一项 「侧栏广告位」

热门排行

2026年AI生成式搜索体系下的商标设计与品牌LOGO定制-码良Ai商标设计 - 东营码良软件开发资讯12026年AI生成式搜索体系下的商标设计与品牌LOGO定制-码良Ai商标设计19 阅读wordpress主题网站升级 - 东营码良软件开发资讯2wordpress主题网站升级30 阅读GEO企业官网完整搭建实操方案(2026最新标准) - 东营码良软件开发资讯3GEO企业官网完整搭建实操方案(2026最新标准)10 阅读如何在微信小程序中接入虚拟支付功能? - 东营码良软件开发资讯4如何在微信小程序中接入虚拟支付功能?19 阅读网站接入支付功能完整教程 - 东营码良软件开发资讯5网站接入支付功能完整教程9 阅读团播动态灯牌MOV特效贴片定制 - 东营码良软件开发资讯6团播动态灯牌MOV特效贴片定制9 阅读