# -*- coding: utf-8 -*-
"""
培训页「三个可交互模拟演示沙盒」——CSS / HTML / JS 三块。
由 _build.py 导入；纯内存实现，零外链、零 localStorage、零网络请求。
- 沙盒一 · 活动录入（7 项字段级黄灯）
- 沙盒二 · 测试反馈（订单→货号→逐货号结果 + 现象/反馈分栏）
- 沙盒三 · 竞品信息（品牌归一化 + 三元组去重）
所有客户 / 订单 / 销售员均已匿名化。
"""

# ─────────────────────────── CSS ───────────────────────────
SB_CSS = r"""
/* ===== 交互沙盒（教学演示 · 纯内存） ===== */
.sb{background:linear-gradient(180deg,#FBFDFF,#F3F8FF);border:1px solid #D6E2FA;border-radius:14px;
  padding:16px 16px 15px;margin:16px 0 20px;scroll-margin-top:14px}
.sb-h{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:4px}
.sb-h .ttl{font-size:16px;font-weight:800;color:var(--deep)}
.sb-h .bdg{font-size:11px;background:#EAF1FF;color:var(--blue);border:1px solid #C7DAFF;
  border-radius:20px;padding:1px 9px;font-weight:700}
.sb-note{font-size:12.6px;color:var(--ink2);margin:4px 0 12px;line-height:1.75}
.sb-decl{background:#FEF2F2;border:1px solid #FCA5A5;border-left:4px solid #EF4444;border-radius:10px;
  padding:9px 12px;font-size:12.6px;color:#7F1D1D;margin:8px 0 12px;line-height:1.7}
.sb-decl b{color:#991B1B}
.sb-map{background:#F8FAFC;border:1px dashed #9DB8E8;border-radius:10px;padding:9px 12px;
  font-size:12.2px;color:var(--ink2);margin-top:11px;line-height:1.8}
.sb-map b{color:var(--deep)}
.sb-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(228px,1fr));gap:10px}
.sbf{display:flex;flex-direction:column;gap:4px;min-width:0}
.sbf label{font-size:12.4px;font-weight:700;color:var(--deep)}
.sbf .hint{font-weight:400;color:var(--grey);font-size:11.5px}
.sb input:not([type="checkbox"]),.sb select,.sb textarea{width:100%;border:1px solid var(--line);
  border-radius:8px;padding:7px 9px;font-family:var(--f);font-size:13px;outline:none;background:#fff;color:var(--ink)}
.sb textarea{min-height:62px;resize:vertical;line-height:1.65}
.sb input:focus,.sb select:focus,.sb textarea:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,67,193,.08)}
.sb .sub{font-size:12.8px;font-weight:800;color:var(--deep);margin:14px 0 5px;display:flex;align-items:center;gap:6px}
.sb .sub::before{content:'';width:3px;height:13px;background:var(--blue);border-radius:2px;display:inline-block}
.sb .row{display:grid;gap:8px;align-items:center;margin-top:6px}
.sb .row.params{grid-template-columns:1fr 2.1fr 32px}
.sb .row.plan{grid-template-columns:1.9fr 1fr 1.1fr 32px}
.sb .mini-del{border:1px solid var(--line);background:#fff;color:#B91C1C;border-radius:7px;
  height:33px;cursor:pointer;font-size:15px;line-height:1;padding:0}
.sb .mini-del:hover{border-color:#FCA5A5;background:#FEF2F2}
.sb .addbtn{border:1px dashed #9DB8E8;background:#fff;color:var(--blue);border-radius:8px;
  padding:5px 12px;font-size:12.5px;cursor:pointer;font-weight:700;margin-top:8px}
.sb .addbtn:hover{background:var(--tint)}
.sb .btns{display:flex;flex-wrap:wrap;gap:8px;margin-top:13px}
.sb .btn{border-radius:9px;padding:7px 14px;font-size:13px;font-weight:700;cursor:pointer;border:1px solid;
  font-family:var(--f)}
.sb .btn.primary{background:var(--blue);border-color:var(--blue);color:#fff}
.sb .btn.good{background:#F0FDF4;border-color:#86EFAC;color:#15803D}
.sb .btn.poor{background:#FFF7ED;border-color:#FDBA74;color:#B45309}
.sb .btn.ghost{background:#fff;border-color:var(--line);color:var(--ink2)}
.sb .btn:active{transform:translateY(1px)}
.sb .lamps{display:grid;grid-template-columns:repeat(auto-fit,minmax(228px,1fr));gap:7px;margin-top:13px}
.sb .lamp{display:flex;gap:8px;align-items:flex-start;background:#fff;border:1px solid var(--line);
  border-radius:9px;padding:7px 10px;font-size:12.3px;color:var(--ink2);line-height:1.6}
.sb .lamp .dot{flex:none;width:9px;height:9px;border-radius:50%;margin-top:5px;background:#D1D9E8}
.sb .lamp.warn{border-color:#FCD34D;background:#FFFBEB;color:#92400E}
.sb .lamp.warn .dot{background:#F59E0B;box-shadow:0 0 0 3px rgba(245,158,11,.18)}
.sb .lamp.ok{border-color:#BBF7D0;background:#F0FDF4;color:#15803D}
.sb .lamp.ok .dot{background:#22C55E}
.sb .score{margin-top:11px;border-radius:10px;padding:9px 13px;font-size:13px;font-weight:700;line-height:1.6}
.sb .score.warn{background:#FFFBEB;border:1px solid #FCD34D;color:#92400E}
.sb .score.ok{background:#F0FDF4;border:1px solid #86EFAC;color:#15803D}
.sb .chip{display:inline-flex;align-items:center;gap:5px;font-size:11.6px;background:var(--tint);
  border:1px solid #C7DAFF;color:var(--blue);border-radius:20px;padding:3px 10px;cursor:pointer;
  font-weight:700;width:auto;margin-top:8px;font-family:var(--f)}
.sb .chip:hover{background:#E3EDFF}
.sb .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
.sb .chips .chip{background:#F0FDF4;border-color:#BBF7D0;color:#15803D;margin-top:0}
.sb .chips .chip::after{content:' ×';opacity:.55}
.sb .normmsg{font-size:12.2px;margin-top:5px;font-weight:700;color:#B45309;line-height:1.6}
.sb .normmsg.hit{color:#15803D}
.sb .ordercard{border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;background:#fff}
.sb .ordercard .oh{font-size:13px;font-weight:800;color:var(--deep);display:flex;gap:8px;
  align-items:center;flex-wrap:wrap}
.sb label.cbx{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:700;
  color:var(--deep);cursor:pointer}
.sb label.cbx input{width:auto;margin:0}
.sb .note-box{background:#FFFBEB;border:1px solid #FCD34D;border-radius:9px;padding:8px 11px;
  font-size:12.5px;color:#92400E;margin-top:8px;line-height:1.65}
.sb pre.feishu{background:#0d2a52;color:#DCE6FB;border-radius:10px;padding:12px 14px;font-size:12.3px;
  white-space:pre-wrap;word-break:break-word;line-height:1.8;margin:10px 0 0;
  font-family:Consolas,Menlo,monospace;max-height:300px;overflow:auto}
.sb .hide{display:none}
.sb .libtbl{width:100%;border-collapse:collapse;font-size:12.3px;margin-top:8px;background:#fff;
  border:1px solid var(--line);border-radius:9px;overflow:hidden}
.sb .libtbl th{background:#F8FAFC;color:var(--deep);text-align:left;padding:7px 9px;
  border-bottom:1px solid var(--line);font-weight:700}
.sb .libtbl td{padding:7px 9px;border-bottom:1px solid #F1F4F9;color:var(--ink2)}
.sb .libtbl tr:last-child td{border-bottom:0}
@media(max-width:560px){.sb .row.params,.sb .row.plan{grid-template-columns:1fr 1fr}}
@media print{.sb .btns{display:none}.sb .addbtn,.sb .mini-del{display:none}
  .sb{break-inside:avoid;background:#fff}}
"""

# ─────────────────────────── HTML ───────────────────────────
SB1 = r'''<section class="sb" id="sb1" data-sb="1">
<div class="sb-h"><span class="ttl">🧪 试试看 · 活动录入模拟沙盒</span><span class="bdg">教学复刻 · 不落盘</span></div>
<div class="sb-decl">⚠️ 本沙盒为<b>教学示意</b>：在此实时亮灯，是为了帮你理解「好活动长什么样」。工作台<b>真实页</b>在<b>提交时</b>做必填校验，<b>没有实时黄灯</b>。</div>
<div class="sb-note">这是录入页的<b>教学复刻</b>：随便填、随便点，只在这里亮教学灯，<b>不写入任何系统、不留任何记录、不发任何请求</b>。
目标：把下面 <b>5 盏教学灯</b>逐条转绿。客户名、商机、订单均已<b>匿名化</b>，仅作演示。</div>
<div class="sb-grid">
  <div class="sbf"><label>活动类型</label>
    <select data-f1="type"><option>项目跟进</option><option>采购跟进</option><option>客户档案完善</option></select></div>
  <div class="sbf"><label>客户 <span class="hint">（下拉点选 · 匿名）</span></label>
    <select data-f1="cust"><option value="">请选择…</option>
      <option>某某生物科技有限公司</option><option>某某体外诊断股份有限公司</option>
      <option>某某基因医学检验实验室</option></select></div>
  <div class="sbf"><label>关联商机 <span class="hint">（下拉点选）</span></label>
    <select data-f1="opp"><option value="">请选择…</option>
      <option>某某生物 · 冻干体系上机验证</option><option>某某体外诊断 · 多重 PCR 原料替换</option></select></div>
</div>
<div class="sub">① 客户需求（结构化分点）</div>
<div class="sbf"><textarea data-f1="need" placeholder="一行一条编号，如：&#10;1. 全预混体系上机适配&#10;2. 要求 25℃ 常温稳定 18 个月"></textarea></div>
<div class="sub">② 技术参数 / 测试数据（货号 + 量化值）</div>
<div id="sb1-params"></div>
<button class="addbtn" data-add1="params">＋ 加一行参数</button>
<div class="sub">③ 问题现象 <span class="hint" style="font-weight:400;color:var(--grey)">（现象 + 量化；客户原话放引号内照抄 —— 不是独立字段）</span></div>
<div class="sbf"><textarea data-f1="issue" placeholder="客观发生了什么事 + 量化影响；客户原话放引号内照抄，如「你们的 NTC 起得比原来晚」"></textarea></div>
<div class="sbf" style="margin-top:8px"><label>客户反馈 <span class="hint">（态度 / 商务条件）</span></label>
  <textarea data-f1="fb"></textarea></div>
<div class="sub">④ 竞品信息 <span class="hint" style="font-weight:400;color:var(--grey)">（非提交必填；填了就要品牌＋货号）</span></div>
<label class="cbx"><input type="checkbox" data-f1="hascomp"> 本次含竞对信息（勾选后展开品牌/货号）</label>
<div id="sb1-comp" class="hide">
  <div class="sb-grid" style="margin-top:8px">
    <div class="sbf"><label>竞品品牌</label><input type="text" data-f1="cbrand" placeholder="如 诺唯赞"></div>
    <div class="sbf"><label>竞品货号</label><input type="text" data-f1="csku" placeholder="如 X-Taq-2025"></div>
  </div>
</div>
<div class="sub">⑤ 下一步计划（事项 / 负责人 / 时间节点）</div>
<div id="sb1-plan"></div>
<button class="addbtn" data-add1="plan">＋ 加一行计划</button>
<div class="lamps" id="sb1-lamps"></div>
<div class="score" id="sb1-score"></div>
<div class="sb-map"><b>沙盒灯项 = 教学示意；与真实页必填项的对应关系：</b>
① 客户＋商机 → 真实页必填「客户」；② 客户需求 → 必填「跟进内容（至少一套）」里的需求；
③ 技术参数 → 真实页「项目跟进」的客户需求 / 技术参数 / 问题现象至少一项；
④ 问题现象（量化＋原话照抄）→ 同上，并体现「客户原话放引号内照抄」这条填写要求；
⑤ 下一步计划 → 真实页必填「下一步计划」。
真实页没有实时灯：以上都只在<b>提交时</b>一次性必填校验，缺项列在底栏「待完善：…」。</div>
<div class="btns">
  <button class="btn good" data-sb1="fill-good">一键填充合格示例</button>
  <button class="btn poor" data-sb1="fill-poor">一键填充反例</button>
  <button class="btn ghost" data-sb1="reset">重置</button>
  <button class="btn primary" data-sb1="feishu">看合格版飞书描述</button>
</div>
<pre class="feishu hide" id="sb1-feishu"></pre>
</section>'''

SB2 = r'''<section class="sb" id="sb2" data-sb="2">
<div class="sb-h"><span class="ttl">🧪 试试看 · 测试反馈模拟沙盒</span><span class="bdg">教学复刻 · 不落盘</span></div>
<div class="sb-decl">⚠️ 本沙盒为<b>教学示意</b>：在此实时亮灯，是为了帮你理解标准。工作台<b>真实页</b>在<b>提交时</b>做必填校验，<b>没有实时黄灯</b>。</div>
<div class="sb-note">模拟真实链路：<b>选订单 → 带出全部货号 → 逐货号写量化结果 + 挂附件</b>，并检查
「问题现象」与「客户反馈」是否分成<b>两个独立字段</b>。下面全部是<b>匿名演示假单号</b>，不落盘、不发请求。</div>
<div class="sb-grid">
  <div class="sbf"><label>关联订单编号 <span class="hint">（下拉点选 · 假单号）</span></label>
    <select data-f2="order"><option value="">请选择…</option>
      <option value="0">ZHBR260701001（演示单）</option>
      <option value="1">ZHBR260802014（演示单）</option>
      <option value="2">ZHBR260915033（演示单）</option></select></div>
  <div class="sbf"><label>是否有效反馈</label>
    <select data-f2="valid"><option>有效反馈</option><option>待判定</option><option>无效反馈</option></select></div>
</div>
<div id="sb2-skus"></div>
<div class="sub">④ 问题现象 与 客户反馈 —— 必须是两个独立字段</div>
<div class="sb-grid">
  <div class="sbf"><label>问题现象 <span class="hint">（客观 · 量化 · 含原话）</span></label>
    <textarea data-f2="issue"></textarea></div>
  <div class="sbf"><label>客户反馈 <span class="hint">（客户态度 / 商务条件）</span></label>
    <textarea data-f2="cfb"></textarea></div>
</div>
<div class="lamps" id="sb2-lamps"></div>
<div class="score" id="sb2-score"></div>
<div class="sb-map"><b>沙盒灯项 = 教学示意；与真实页必填项的对应关系：</b>
① 关联真实订单 → 真实页测试反馈「关联真实订单编号」（测试单结果跟进）；
② 结果量化 → 属<b>内容级</b>要求，真实页不校验，但测试结果必须量化才可用；
③ 现象 / 反馈分栏 → 真实页「问题现象」（含量化 + 原话引号照抄）与「客户反馈」是两个独立字段，不要合并。
真实页只在<b>提交时</b>做必填校验，没有实时灯。</div>
<div class="btns">
  <button class="btn good" data-sb2="fill-good">一键填充合格示例</button>
  <button class="btn poor" data-sb2="fill-poor">一键填充反例</button>
  <button class="btn ghost" data-sb2="reset">重置</button>
</div>
</section>'''

SB3 = r'''<section class="sb" id="sb3" data-sb="3">
<div class="sb-h"><span class="ttl">🧪 试试看 · 竞品信息模拟沙盒</span><span class="bdg">教学复刻 · 不落盘</span></div>
<div class="sb-decl">⚠️ 本沙盒为<b>教学示意</b>：在此实时亮灯，是为了帮你理解标准。工作台<b>真实页</b>在<b>提交时</b>做必填校验，<b>没有实时黄灯</b>。</div>
<div class="sb-note">重点演示两件事：<b>品牌归一化</b>（输入「诺维赞」「Vazyme」会自动归到标准品牌「诺唯赞」，
不产生脏键）与 <b>三元组去重</b>（品牌＋货号＋客户 命中已入库记录 → 不会重复入库）。全部匿名、不落盘。</div>
<div class="sb-grid">
  <div class="sbf"><label>品牌 <span class="hint">（下拉 / 别名自动归一化）</span></label>
    <input type="text" list="sb3-brands" data-f3="brand" placeholder="输入或选择，如 诺维赞 / Vazyme">
    <datalist id="sb3-brands">
      <option value="诺唯赞"></option><option value="翌圣"></option><option value="近岸蛋白"></option>
      <option value="菲鹏生物"></option><option value="康为世纪"></option><option value="义翘神州"></option>
      <option value="赛默飞"></option><option value="TaKaRa"></option><option value="罗氏"></option>
    </datalist>
    <div class="normmsg" id="sb3-norm"></div></div>
  <div class="sbf"><label>竞品货号</label>
    <input type="text" data-f3="sku" placeholder="如 X-Taq-2025"></div>
  <div class="sbf"><label>关联客户 <span class="hint">（匿名）</span></label>
    <select data-f3="cust"><option value="">请选择…</option>
      <option>某某生物科技有限公司</option><option>某某体外诊断股份有限公司</option>
      <option>某某基因医学检验实验室</option></select></div>
</div>
<div class="sb-grid" style="margin-top:10px">
  <div class="sbf"><label>性能参数 <span class="hint">（尽量量化）</span></label>
    <textarea data-f3="perf" placeholder="如 灵敏度 5 copies/μL；延伸 1kb/s"></textarea></div>
  <div class="sbf"><label>客户评价</label>
    <textarea data-f3="fb" placeholder="客户对竞品的真实反馈"></textarea></div>
</div>
<div class="normmsg" id="sb3-dedup"></div>
<div class="sub">已入库记录（去重演示 · 匿名假数据）</div>
<div id="sb3-lib"></div>
<div class="lamps" id="sb3-lamps"></div>
<div class="score" id="sb3-score"></div>
<div class="sb-map"><b>沙盒灯项 = 教学示意；与真实页必填项的对应关系：</b>
① 品牌已归一化 → 真实页竞品「品牌」要求<b>归一化点选、禁手打</b>（否则产生脏键）；
② 非重复情报 → 真实页竞对情报去重键 = <b>品牌＋货号＋客户</b>；
③ 货号＋性能齐全 → 竞对信息的内容要求（货号 + 量化对比），属<b>内容级</b>，真实页不校验。
真实页只在<b>提交时</b>做必填校验，没有实时灯。</div>
<div class="btns">
  <button class="btn good" data-sb3="fill-good">一键填充合格示例</button>
  <button class="btn poor" data-sb3="fill-poor">一键填充反例</button>
  <button class="btn ghost" data-sb3="reset">重置</button>
</div>
</section>'''

SB_SECTIONS = [SB1, SB2, SB3]

# ─────────────────────────── JS ───────────────────────────
SB_JS = r'''
/* ══════════════ 交互沙盒（纯内存 · 不落盘 · 无网络） ══════════════ */
(function(){
  /* 中文输入法保护：合成期间不重渲染 */
  var composing = false;
  document.addEventListener('compositionstart', function(){ composing = true; });
  document.addEventListener('compositionend',   function(){ composing = false; });

  var QUANT  = /[%]|copy|μL|μl|ct|×|倍/i;                 /* 量化符 */
  var COLLOQ = /(还可以|有点|不错|差不多|还行|凑合)/;      /* 口语词反向检测 */

  function q(s,r){ return (r||document).querySelector(s); }
  function qa(s,r){ return Array.prototype.slice.call((r||document).querySelectorAll(s)); }
  function T(x){ return (x==null?'':String(x)).trim(); }
  function E(s){ return String(s==null?'':s).replace(/[&<>]/g,function(c){
      return c==='&'?'&amp;':(c==='<'?'&lt;':'&gt;'); }); }
  function V(sel){ var e=q(sel); return e?T(e.value):''; }
  function setV(sel,x){ var e=q(sel); if(e) e.value = (x==null?'':x); }

  /* ---------- 通用：灯 & 分数条 ---------- */
  function paintLamps(boxId, items){
    var b = document.getElementById(boxId); if(!b) return items.length;
    var nBad = 0;
    b.innerHTML = items.map(function(it){
      if(!it.ok) nBad++;
      return '<div class="lamp '+(it.ok?'ok':'warn')+'"><span class="dot"></span><div><b>'+it.n+'</b>　'+it.t+
        (it.ok ? '' : '<br><span style="opacity:.85">待完善：'+it.why+'</span>')+'</div></div>';
    }).join('');
    return nBad;
  }
  function paintScore(id, items, okText){
    var e = document.getElementById(id); if(!e) return;
    var bad = items.filter(function(x){ return !x.ok; });
    if(bad.length){
      e.className = 'score warn';
      e.textContent = '⚠️ 教学灯 ' + bad.length + ' 项待完善：' +
        bad.map(function(x){ return x.k; }).join('、') + '　（教学示意 · 不计分、不提交）';
    } else {
      e.className = 'score ok';
      e.textContent = '✅ ' + okText;
    }
  }

  /* ══════════ 沙盒一 · 活动录入 ══════════ */
  function pRow(s,v){
    var d = document.createElement('div'); d.className = 'row params';
    d.innerHTML = '<input type="text" data-p="sku" placeholder="货号，如 FM2181-4" value="'+E(s)+'">' +
      '<input type="text" data-p="val" placeholder="量化值，如 扩增 20min；灵敏度 1 copy/μL" value="'+E(v)+'">' +
      '<button class="mini-del" data-del="1" title="删除此行">×</button>';
    return d;
  }
  function plRow(a,b,c){
    var d = document.createElement('div'); d.className = 'row plan';
    d.innerHTML = '<input type="text" data-pl="item" placeholder="事项（做什么）" value="'+E(a)+'">' +
      '<input type="text" data-pl="owner" placeholder="负责人" value="'+E(b)+'">' +
      '<input type="text" data-pl="due" placeholder="时间节点" value="'+E(c)+'">' +
      '<button class="mini-del" data-del="1" title="删除此行">×</button>';
    return d;
  }
  function sb1Rows(params, plans){
    var P = document.getElementById('sb1-params'), L = document.getElementById('sb1-plan');
    P.innerHTML = ''; L.innerHTML = '';
    (params||[['','']]).forEach(function(r){ P.appendChild(pRow(r[0],r[1])); });
    (plans||[['','','']]).forEach(function(r){ L.appendChild(plRow(r[0],r[1],r[2])); });
  }
  function sb1Read(){
    var need=T(V('[data-f1="need"]')), issue=T(V('[data-f1="issue"]')),
        fb=T(V('[data-f1="fb"]')),
        cust=V('[data-f1="cust"]'), opp=V('[data-f1="opp"]');
    var has = q('[data-f1="hascomp"]').checked, cb=T(V('[data-f1="cbrand"]')), cs=T(V('[data-f1="csku"]'));
    var prm = qa('#sb1-params .row.params').map(function(r){
      return { s:T(q('[data-p="sku"]',r).value), v:T(q('[data-p="val"]',r).value) };
    }).filter(function(x){ return x.s || x.v; });
    var pln = qa('#sb1-plan .row.plan').map(function(r){
      return { i:T(q('[data-pl="item"]',r).value), o:T(q('[data-pl="owner"]',r).value),
               d:T(q('[data-pl="due"]',r).value) };
    }).filter(function(x){ return x.i || x.o || x.d; });
    return { need:need, issue:issue, fb:fb, cust:cust, opp:opp,
             has:has, cb:cb, cs:cs, prm:prm, pln:pln };
  }
  function sb1Update(){
    var d = sb1Read();
    var lines = d.need.split('\n').filter(function(x){ return T(x); }).length;
    var ok1 = /\d[.、]/.test(d.need) || lines >= 2;
    var ok2 = d.prm.some(function(x){ return x.s && QUANT.test(x.v); });
    var ok3 = !!d.issue && /[「」“”"]/.test(d.issue);
    var ok4 = !!(d.cust && d.opp);
    var ok5 = d.pln.some(function(x){ return x.i && x.o && x.d; });
    var items = [
      { n:'① 客户＋商机', t:'客户与商机都要「点选」', ok:ok4, k:'①客户＋商机', why:'客户 / 商机未选' },
      { n:'② 客户需求', t:'要编号（<code>1.</code>）或 ≥2 行', ok:ok1, k:'②客户需求',
        why:'当前 ' + lines + ' 行、无编号' },
      { n:'③ 技术参数', t:'每条参数带 <b>货号</b>＋量化值（% / copy / μL / Ct / × / 倍）', ok:ok2, k:'③技术参数',
        why:'缺货号或缺量化值' },
      { n:'④ 问题现象', t:'量化影响 + 客户原话用<b>引号照抄</b>（写在问题现象里，非独立字段）', ok:ok3, k:'④问题现象',
        why:(d.issue ? '未见引号照抄的客户原话' : '问题现象为空') },
      { n:'⑤ 下一步计划', t:'要「事项＋负责人＋时间节点」', ok:ok5, k:'⑤下一步计划', why:'三件套不全' }
    ];
    paintLamps('sb1-lamps', items);
    paintScore('sb1-score', items, '信息齐备（教学示意 · 1 套跟进内容）');
    return items.filter(function(x){ return !x.ok; }).length;
  }
  function sb1CompToggle(){
    var on = q('[data-f1="hascomp"]').checked;
    q('#sb1-comp').classList.toggle('hide', !on);
  }
  function sb1Feishu(){
    var d = sb1Read();
    var s = '';
    s += '【活动类型】' + (V('[data-f1="type"]') || '—') + '\n';
    s += '【客户】' + (d.cust || '—') + '\n';
    s += '【关联商机】' + (d.opp || '—') + '\n';
    s += '\n—— 跟进内容 ——\n';
    s += '① 客户需求\n' + (d.need || '—') + '\n\n';
    s += '② 技术参数 / 测试数据\n' + (d.prm.length
          ? d.prm.map(function(x){ return '· ' + (x.s || '—') + '：' + (x.v || '—'); }).join('\n') : '—') + '\n\n';
    s += '③ 问题现象（现象 + 量化；客户原话放引号内照抄）\n' + (d.issue || '—') + '\n\n';
    s += '④ 客户反馈\n' + (d.fb || '—') + '\n\n';
    s += '⑤ 竞对信息\n' + (d.has
          ? ((d.cb || '—') + '（' + (d.cs || '—') + '）') : '（本次无竞对，留空，不写「无」）') + '\n\n';
    s += '⑥ 下一步计划\n' + (d.pln.length
          ? d.pln.map(function(x){ return '· ' + (x.i || '—') + ' ｜ ' + (x.o || '—') + ' ｜ ' + (x.d || '—'); }).join('\n')
          : '—');
    var box = document.getElementById('sb1-feishu');
    box.textContent = s;
    box.classList.remove('hide');
  }
  function sb1FillGood(){
    setV('[data-f1="type"]', '项目跟进');
    setV('[data-f1="cust"]', '某某生物科技有限公司');
    setV('[data-f1="opp"]', '某某生物 · 冻干体系上机验证');
    setV('[data-f1="need"]', '1. 全预混体系上机适配，需按 64T 板位预封装\n2. 要求 25℃ 常温稳定 18 个月\n3. 需要宿主核酸残留的检测报告');
    setV('[data-f1="issue"]', '上机出现轻微非特异扩增，样品孔荧光值偏低约 15%，复孔一致；客户原话「你们的 NTC 起得比原来晚，但样品孔荧光值偏低」。');
    setV('[data-f1="fb"]', '客户认可灵敏度，要求先补齐常温稳定性数据再批量采购；关注点是交期与账期。');
    q('[data-f1="hascomp"]').checked = true; sb1CompToggle();
    setV('[data-f1="cbrand"]', '诺唯赞'); setV('[data-f1="csku"]', 'X-Taq-2025');
    sb1Rows([['FM2181-4','扩增 20min，灵敏度 1 copy/μL，批间 CV<5%'],
             ['FMD5071-4-DB','可冻干，25℃ 常温 18 个月，复溶后 7 天无衰减']],
            [['寄送 2 支样品并附测试方案','本人','2026-10-20'],
             ['安排技术交流确认上机时间','技术支持','2026-10-28']]);
    document.getElementById('sb1-feishu').classList.add('hide');
    sb1Update();
  }
  function sb1FillPoor(){
    setV('[data-f1="type"]', '项目跟进');
    setV('[data-f1="cust"]', ''); setV('[data-f1="opp"]', '');
    setV('[data-f1="need"]', '客户想让我们配合做一个新的体系，要求还挺多的。');
    setV('[data-f1="issue"]', '效果不太好，需要改进。');
    setV('[data-f1="fb"]', '客户说还行。');
    q('[data-f1="hascomp"]').checked = true; sb1CompToggle();
    setV('[data-f1="cbrand"]', ''); setV('[data-f1="csku"]', '');
    sb1Rows([['','我们的酶性能很好，比进口的便宜']], [['继续跟进','','']]);
    document.getElementById('sb1-feishu').classList.add('hide');
    sb1Update();
  }
  function sb1Reset(){
    setV('[data-f1="type"]', '项目跟进');
    ['cust','opp','need','issue','fb','cbrand','csku'].forEach(function(k){ setV('[data-f1="'+k+'"]',''); });
    q('[data-f1="hascomp"]').checked = false; sb1CompToggle();
    sb1Rows([['','']], [['','','']]);
    document.getElementById('sb1-feishu').classList.add('hide');
    sb1Update();
  }

  /* ══════════ 沙盒二 · 测试反馈 ══════════ */
  var ORDERS = [
    { no:'ZHBR260701001', skus:['FM0324-074901','FMD5071-4-DB'] },
    { no:'ZHBR260802014', skus:['FM2181-4','FMD5071-4-DB','FM0324-074901'] },
    { no:'ZHBR260915033', skus:['FM2181-4','FM0324-074901'] }
  ];
  function skuCard(s){
    return '<div class="ordercard" data-sku="' + E(s) + '">' +
      '<div class="oh"><span class="tag">货号</span><b>' + E(s) + '</b>' +
      '<label class="cbx" style="margin-left:auto"><input type="checkbox" data-sk="on" checked> 本次跟进</label></div>' +
      '<div class="sbf" style="margin-top:8px"><label>结果描述 <span class="hint">（量化：Ct / 灵敏度 / 曲线 / 稳定性…）</span></label>' +
      '<textarea data-sk="res"></textarea></div>' +
      '<div class="chips" data-sk="chips"></div>' +
      '<button class="chip" data-sk="addatt">＋ 添加附件（占位演示 · 不真上传）</button>' +
      '</div>';
  }
  function sb2RenderSkus(){
    var i = q('[data-f2="order"]').value, box = document.getElementById('sb2-skus');
    if(i === ''){
      box.innerHTML = '<div class="note-box">尚未关联订单 —— 选一个演示单号，下面会自动带出该单的全部货号。</div>';
      return;
    }
    var o = ORDERS[+i];
    box.innerHTML = '<div class="sub">从订单带出 ' + o.skus.length + ' 个货号（勾选本次跟进 → 逐货号写结果 + 挂附件）</div>' +
      o.skus.map(skuCard).join('');
  }
  function sb2Update(){
    var ord = q('[data-f2="order"]').value;
    var cards = qa('#sb2-skus .ordercard');
    var on = cards.filter(function(c){ return q('[data-sk="on"]',c).checked; });
    var ok1 = (ord !== '');
    var ok2 = on.length > 0 && on.every(function(c){
      return QUANT.test(T(q('[data-sk="res"]',c).value)); });
    var iss = T(V('[data-f2="issue"]')), cfb = T(V('[data-f2="cfb"]'));
    var ok3 = !!iss && !!cfb && iss !== cfb;
    var items = [
      { n:'① 关联真实订单', t:'结果必须挂在订单号上（不是手写货号）', ok:ok1, k:'①关联订单',
        why:'未选择订单' },
      { n:'② 结果量化', t:'每个勾选货号的结果描述都要含量化数据', ok:ok2, k:'②结果量化',
        why: on.length ? '有货号的结果缺量化' : '尚无货号结果' },
      { n:'③ 现象 / 反馈分栏', t:'「问题现象」与「客户反馈」是两个独立字段', ok:ok3, k:'③现象反馈分栏',
        why: (iss && cfb && iss === cfb) ? '两栏内容完全相同' : '两栏未分别填写' }
    ];
    paintLamps('sb2-lamps', items);
    paintScore('sb2-score', items, '链路完整：订单已关联 · 货号结果全部量化 · 现象与反馈已分栏');
    return items.filter(function(x){ return !x.ok; }).length;
  }
  function sb2AddAtt(card){
    var box = q('[data-sk="chips"]', card);
    var names = ['扩增曲线.png','原始报告.pdf','稳定性记录.xlsx','上机照片.jpg'];
    var n = box.children.length;
    var sp = document.createElement('span');
    sp.className = 'chip'; sp.textContent = names[n % names.length] + '（占位）';
    box.appendChild(sp);
  }
  function sb2FillGood(){
    q('[data-f2="order"]').value = '1'; sb2RenderSkus();
    var res = { 'FM2181-4':'扩增曲线正常，Ct 提前 1.2，NTC 无起线，扩增效率 98%',
                'FMD5071-4-DB':'复溶后 25℃ 放置 7 天无衰减，Ct 波动 <0.5',
                'FM0324-074901':'批间 CV<5%，复孔一致，重复性 3 次均通过' };
    qa('#sb2-skus .ordercard').forEach(function(c){
      var s = c.getAttribute('data-sku');
      q('[data-sk="res"]', c).value = res[s] || '扩增曲线正常，Ct 提前 1.2';
      q('[data-sk="on"]', c).checked = true;
      if(!q('[data-sk="chips"]', c).children.length) sb2AddAtt(c);
    });
    setV('[data-f2="issue"]', '上机出现轻微非特异扩增，样品孔荧光值偏低约 15%，复孔一致；客户原话「你们的 NTC 起得比原来晚」。');
    setV('[data-f2="cfb"]', '客户认可灵敏度，但要求先补齐常温稳定性数据再批量采购；关注交期与账期。');
    sb2Update();
  }
  function sb2FillPoor(){
    q('[data-f2="order"]').value = '1'; sb2RenderSkus();
    qa('#sb2-skus .ordercard').forEach(function(c){
      q('[data-sk="res"]', c).value = '客户测了，结果还行。';
      q('[data-sk="on"]', c).checked = true;
    });
    setV('[data-f2="issue"]', '客户反馈效果不太好，需要改进。');
    setV('[data-f2="cfb"]', '');
    sb2Update();
  }
  function sb2Reset(){
    q('[data-f2="order"]').value = '';
    q('[data-f2="valid"]').value = '有效反馈';
    setV('[data-f2="issue"]', ''); setV('[data-f2="cfb"]', '');
    sb2RenderSkus(); sb2Update();
  }

  /* ══════════ 沙盒三 · 竞品信息 ══════════ */
  var BRANDS = ['诺唯赞','翌圣','近岸蛋白','菲鹏生物','康为世纪','义翘神州','赛默飞','TaKaRa','罗氏'];
  var ALIAS = { '诺维赞':'诺唯赞','诺唯贊':'诺唯赞','诺唯赞生物':'诺唯赞','vazyme':'诺唯赞',
    'vazyme biotech':'诺唯赞','vazymebiotech':'诺唯赞','翊圣':'翌圣','yeasen':'翌圣',
    '近岸':'近岸蛋白','novoprotein':'近岸蛋白','novo protein':'近岸蛋白','菲鹏':'菲鹏生物',
    'fapon':'菲鹏生物','康为':'康为世纪','康为世纪生物':'康为世纪','cwbio':'康为世纪',
    '义翘':'义翘神州','sinobiological':'义翘神州','thermo':'赛默飞','thermofisher':'赛默飞',
    'thermo fisher':'赛默飞','takara':'TaKaRa','宝生物':'TaKaRa','roche':'罗氏','罗氏诊断':'罗氏' };
  var LIB = [
    { brand:'诺唯赞', sku:'X-Taq-2025', cust:'某某生物科技有限公司', perf:'灵敏度 5 copies/μL；延伸 1kb/s', fb:'交期快，单价高 15%' },
    { brand:'翌圣', sku:'YH-Probe-02', cust:'某某体外诊断股份有限公司', perf:'qPCR 预混液，批间 CV<3%', fb:'性价比高，适合常规筛查' },
    { brand:'近岸蛋白', sku:'NP-RT-3', cust:'某某基因医学检验实验室', perf:'逆转录酶，42℃ 耐热 30min', fb:'扩增效率稳定，供货周期偏长' }
  ];
  function normBrand(x){
    var s = T(x); if(!s) return null;
    var k = s.toLowerCase();
    if(ALIAS[k]) return ALIAS[k];
    for(var i=0;i<BRANDS.length;i++){ if(BRANDS[i].toLowerCase() === k) return BRANDS[i]; }
    return null;
  }
  function sb3Lib(){
    var box = document.getElementById('sb3-lib');
    if(!box || box.getAttribute('data-done')) return;
    box.innerHTML = '<table class="libtbl"><thead><tr><th>品牌</th><th>货号</th><th>客户</th><th>性能</th></tr></thead><tbody>' +
      LIB.map(function(r){ return '<tr><td><b>'+E(r.brand)+'</b></td><td>'+E(r.sku)+'</td><td>'+E(r.cust)+
        '</td><td>'+E(r.perf)+'</td></tr>'; }).join('') + '</tbody></table>';
    box.setAttribute('data-done','1');
  }
  function sb3NormalizeField(){
    var raw = T(V('[data-f3="brand"]'));
    var hit = normBrand(raw);
    if(raw && hit && hit !== raw) setV('[data-f3="brand"]', hit);   /* 别名 → 标准品牌 */
  }
  function sb3Update(){
    var raw = T(V('[data-f3="brand"]')), hit = normBrand(raw);
    var msg = document.getElementById('sb3-norm');
    if(!raw){ msg.className = 'normmsg'; msg.style.color = ''; msg.textContent = ''; }
    else if(hit){
      msg.className = 'normmsg hit'; msg.style.color = '';
      msg.innerHTML = (hit === raw)
        ? '✅ 标准品牌：<b>' + E(hit) + '</b>'
        : '🔄 已归一化：<b>' + E(raw) + '</b> → <b>' + E(hit) + '</b>（归到标准品牌，不产生脏键）';
    } else {
      msg.className = 'normmsg'; msg.style.color = '#B45309';
      msg.innerHTML = '⚠️ 未收录品牌：原文保留，<b>不进统计、不新增脏键</b>（可联系管理员补录标准名）';
    }
    var sku = T(V('[data-f3="sku"]')), cust = V('[data-f3="cust"]'), perf = T(V('[data-f3="perf"]'));
    var dup = null;
    if(hit && sku && cust){
      dup = LIB.filter(function(r){
        return r.brand === hit && r.sku.toLowerCase() === sku.toLowerCase() && r.cust === cust;
      })[0] || null;
    }
    var dm = document.getElementById('sb3-dedup');
    if(dup){
      dm.className = 'normmsg'; dm.style.color = '#B45309';
      dm.innerHTML = '🔁 重复命中：<b>' + E(dup.brand) + ' ＋ ' + E(dup.sku) + ' ＋ ' + E(dup.cust) +
        '</b> 已入库 → <b>跳过，不会重复入库</b>';
    } else if(hit && sku && cust){
      dm.className = 'normmsg hit'; dm.style.color = '';
      dm.innerHTML = '✅ 三元组未命中 → 可作为<b>新情报</b>入库';
    } else {
      dm.className = 'normmsg'; dm.style.color = '';
      dm.textContent = '填写「品牌 ＋ 货号 ＋ 客户」后自动查重（去重键 = 三者完全相同）';
    }
    var items = [
      { n:'① 品牌已归一化', t:'品牌必须来自标准名单（别名自动映射）', ok:(!!raw && !!hit), k:'①品牌归一化',
        why: raw ? '未收录品牌，请核对标准名' : '品牌为空' },
      { n:'② 非重复情报', t:'「品牌＋货号＋客户」未命中已入库记录', ok:!dup, k:'②重复检测',
        why:'三元组已存在，会被去重跳过' },
      { n:'③ 货号＋性能齐全', t:'缺少货号或性能参数 → 无法逐条对标', ok:(!!sku && !!perf), k:'③货号性能',
        why:'缺货号或缺性能参数' }
    ];
    paintLamps('sb3-lamps', items);
    paintScore('sb3-score', items, '情报合格：品牌已归一化 · 无重复 · 货号性能齐全');
    return items.filter(function(x){ return !x.ok; }).length;
  }
  function sb3FillGood(){
    setV('[data-f3="brand"]', '诺维赞');    /* 故意留别名不做替换，演示「诺维赞 → 诺唯赞」归一化提示 */
    setV('[data-f3="sku"]', 'X-Taq-2099');
    q('[data-f3="cust"]').value = '某某体外诊断股份有限公司';
    setV('[data-f3="perf"]', '灵敏度 3 copies/μL；延伸 1kb/s');
    setV('[data-f3="fb"]', '总价低、交期稳定，客户在常规筛查项目上复购');
    sb3Update();
  }
  function sb3FillPoor(){
    setV('[data-f3="brand"]', '诺唯赞');
    setV('[data-f3="sku"]', 'X-Taq-2025');          /* 与已入库记录相同 */
    q('[data-f3="cust"]').value = '某某生物科技有限公司';
    setV('[data-f3="perf"]', '');                    /* 缺性能 */
    setV('[data-f3="fb"]', '客户说还行。');
    sb3Update();
  }
  function sb3Reset(){
    setV('[data-f3="brand"]', ''); setV('[data-f3="sku"]', ''); setV('[data-f3="perf"]', '');
    setV('[data-f3="fb"]', ''); q('[data-f3="cust"]').value = '';
    sb3Update();
  }

  /* ---------- 统一刷新 ---------- */
  function upd(id){
    if(id === 'sb1') sb1Update();
    else if(id === 'sb2') sb2Update();
    else if(id === 'sb3') sb3Update();
  }

  /* ---------- 事件（委托） ---------- */
  document.addEventListener('input', function(e){
    var t = e.target;
    if(!t || !t.closest) return;
    var sb = t.closest('.sb'); if(!sb) return;
    if(composing) return;                       /* 拼音组合中：不重渲染 */
    /* 注意：input 阶段【不】改写品牌字段值 —— 让用户看得见「诺维赞 → 诺唯赞」的归一化提示；
       真正把字段值替换成标准品牌放在 change（失焦 / 回车 / 选中 datalist）阶段。 */
    upd(sb.id);
  });

  document.addEventListener('change', function(e){
    var t = e.target;
    if(!t || !t.closest) return;
    var sb = t.closest('.sb'); if(!sb) return;
    if(sb.id === 'sb2' && t.getAttribute('data-f2') === 'order') sb2RenderSkus();
    if(sb.id === 'sb1' && t.getAttribute('data-f1') === 'hascomp') sb1CompToggle();
    if(sb.id === 'sb3' && t.getAttribute('data-f3') === 'brand') sb3NormalizeField();
    upd(sb.id);
  });

  document.addEventListener('click', function(e){
    var t = e.target;
    if(!t || !t.closest) return;
    var sb = t.closest('.sb'); if(!sb) return;
    var id = sb.id;

    var del = t.closest('[data-del]');
    if(del){
      var row = del.closest('.row');
      if(row){
        var parent = row.parentNode;
        row.remove();
        if(!parent.children.length) parent.appendChild(
          parent.id === 'sb1-plan' ? plRow('','','') : pRow('',''));
      }
      upd(id); return;
    }
    var add = t.closest('[data-add1]');
    if(add){
      if(add.getAttribute('data-add1') === 'plan') document.getElementById('sb1-plan').appendChild(plRow('','',''));
      else document.getElementById('sb1-params').appendChild(pRow('',''));
      upd(id); return;
    }
    var att = t.closest('[data-sk="addatt"]');
    if(att){ sb2AddAtt(att.closest('.ordercard')); return; }
    var chip = t.closest('.chips .chip');
    if(chip && !chip.getAttribute('data-sk')){ chip.remove(); return; }

    var b1 = t.closest('[data-sb1]');
    if(b1){
      var a1 = b1.getAttribute('data-sb1');
      if(a1 === 'fill-good') sb1FillGood();
      else if(a1 === 'fill-poor') sb1FillPoor();
      else if(a1 === 'reset') sb1Reset();
      else if(a1 === 'feishu') sb1Feishu();
      return;
    }
    var b2 = t.closest('[data-sb2]');
    if(b2){
      var a2 = b2.getAttribute('data-sb2');
      if(a2 === 'fill-good') sb2FillGood();
      else if(a2 === 'fill-poor') sb2FillPoor();
      else if(a2 === 'reset') sb2Reset();
      return;
    }
    var b3 = t.closest('[data-sb3]');
    if(b3){
      var a3 = b3.getAttribute('data-sb3');
      if(a3 === 'fill-good') sb3FillGood();
      else if(a3 === 'fill-poor') sb3FillPoor();
      else if(a3 === 'reset') sb3Reset();
      return;
    }
  });

  /* ---------- 初始化（只写入沙盒自身容器） ---------- */
  function init(){
    sb1Rows([['','']], [['','','']]);
    sb1CompToggle();
    sb1Update();
    sb2RenderSkus();
    sb2Update();
    sb3Lib();
    sb3Update();
  }
  if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();
'''
