#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成「客户拉通大表」DEMO 单文件页（自包含、零外链）—— **两层设计版**。

严格对齐用户 2026-10-11 给出的《客户拉通大表第一层及第二层demo》xlsx：

■ 第一层 = 项目推移表（一行一商机）· 三层表头
    第 1 层（带名）  ：第一层｜项目推移表
    第 2 层（组名跨列合并）：
        组1「项目基本信息」 ：部门｜客户编码｜客户名称｜商机名称｜销售员｜商机规模
        组2「项目关键节点（可向右延展）」：陌生开发｜方案匹配｜中试｜临床试验｜
                                            已确认被用于注册｜已转产｜客户已获证｜新证迭代
        组3「资源协助（可向右延展）」：由销售在第二层**手动多选**触发（技术支持测试项目单/研发项目单/售后/其他），每选一类→加一列（列名带序号 N）
    第 3 层（列名）；行按 5 个部门分组（大客户部/销售拓展部/生命科学销售部/生物制药销售部/国际业务部），行头可折叠。

■ 第二层 = 活动录入表（一行一活动）· 三层表头
    组1：部门｜客户编码｜客户名称｜商机名称｜竞品信息｜销售员｜技术支持｜商机规模
    组2「活动信息更新（可持续向右延展）」：日期｜拜访对象/联系人｜关联订单｜货号｜活动类型（下拉）
    组3「待办事项」：事项｜负责人｜时间节点

■ 两条联动（页面内真实演示）
    · 两层联动：提交一条活动 → 第一层对应商机行的「项目关键节点」被推进；「资源协助」列由销售**手动多选**触发（每选一类加一列）。
    · 三轨落盘：本地记录（活动编号）/ CloudBase 写入 JSON / 飞书描述（一键复制）。
    · 活动类型=「测试结果跟进」时，必须结构化描述 6 个维度（缺一不可），否则禁止提交并高亮缺哪项。
    · 「其他」资源协作勾选后必须填写「其他资源协作说明」，否则禁止提交。

硬约束：零外部依赖（禁 CDN/fetch/外部字体），示例数据内嵌；内联 SVG 禁 <title>/<br>。
品牌页眉/页脚：脚本/brand_kit.py。
"""
import os, sys, json, io, re

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)                       # Hermes输出-工作类
sys.path.insert(0, os.path.join(ROOT, '脚本'))
import brand_kit as BK                             # noqa

OUT = os.path.join(ROOT, '工具', '客户拉通大表_demo.html')

# ── 列定义（表驱动）──────────────────────────────────────────
BASE_COLS  = ["部门", "客户编码", "客户名称", "商机名称", "销售员", "商机规模"]
NODE_COLS  = ["陌生开发", "方案匹配", "中试", "临床试验",
              "已确认被用于注册", "已转产", "客户已获证", "新证迭代"]
RES_COLS   = ["技术支持测试项目单1"]        # 资源协助列由销售手动多选触发后按序号延展；此列为示例已有列
ACT_TYPES  = ["测试结果跟进", "采购跟进", "客户档案完善"]
# 第二层「触发的资源协作（多选）」四类 —— 逐字取自用户口径；服务端 type 枚举见 SUPPORT_ENUM
SUPPORT_TYPES = ["技术支持测试项目单", "研发项目单", "售后", "其他"]
SUPPORT_ENUM  = {"技术支持测试项目单": "tech_test", "研发项目单": "rnd_project",
                 "售后": "after_sales", "其他": "other"}

# 第一层（一行一商机）
def sc(state, date=None, owner=None, act=None, unlinked=False):
    d = {"state": state}
    if date:     d["date"] = date
    if owner:    d["owner"] = owner
    if act:      d["act"] = act
    if unlinked: d["unlinked"] = True
    return d

LAYER1_DEPTS = [
    {"name": "大客户部", "rows": [
        {"cid": "6878118191", "cust": "圣湘生物科技股份有限公司", "opp": "多重呼吸道病原体冻干体系开发",
         "sales": "赵云浩", "scale": "¥1,200,000", "tech": "李工", "comp": "某进口冻干保护剂（品牌A）",
         "nodes": {"方案匹配": sc("已完成", "2026-05-12", "赵云浩"),
                   "中试": sc("已完成", "2026-08-10", "王工"),
                   "临床试验": sc("进行中", "2026-10-08", "赵云浩")},
         "res": {"技术支持测试项目单1": sc("已完成", "2026-08-12", "李工")}},
        {"cid": "6878118210", "cust": "深圳市新产业生物医学工程股份有限公司", "opp": "磁珠法核酸提取原料替换评估",
         "sales": "徐卓凡", "scale": "¥860,000", "tech": "王工", "comp": "进口磁珠（品牌B）",
         "nodes": {"陌生开发": sc("已完成", "2026-08-30", "徐卓凡"),
                   "方案匹配": sc("已完成", "2026-09-20", "徐卓凡")},
         "res": {}},
    ]},
    {"name": "销售拓展部", "rows": [
        {"cid": "6878118250", "cust": "广州达安基因股份有限公司", "opp": "数字PCR扩增酶体系开发",
         "sales": "陈嘉怡", "scale": "¥540,000", "tech": "黄工", "comp": "进口高保真酶（品牌C）",
         "nodes": {"方案匹配": sc("已完成", "2026-08-01", "陈嘉怡"),
                   "中试": sc("进行中", "2026-09-30", "黄工")},
         "res": {"技术支持测试项目单1": sc("已完成", "2026-08-18", "李工")}},
        {"cid": "6878118220", "cust": "上海纳全生物医学科技有限公司", "opp": "RT单管全预混体系开发",
         "sales": "张文蝶", "scale": "¥320,000", "tech": "张工", "comp": "某国产全预混体系（品牌D）",
         "unlinked": True,
         "nodes": {"方案匹配": sc("已完成", "2026-09-05", "张文蝶"),
                   "中试": sc("进行中", "2026-09-20", "张工", unlinked=True)},
         "res": {"技术支持测试项目单1": sc("进行中", "2026-09-08", "张工")}},
    ]},
    {"name": "生命科学销售部", "rows": [
        {"cid": "待关联", "cust": "华南农业大学（示例课题组）", "opp": "circRNA 体外转录试剂盒试用",
         "sales": "林思远", "scale": "¥86,000", "tech": "苏工", "comp": "进口 T7 酶（品牌E）",
         "unlinked": True,
         "nodes": {"陌生开发": sc("已完成", "2026-09-10", "林思远")},
         "res": {"技术支持测试项目单1": sc("进行中", "2026-09-28", "苏工", unlinked=True)}},
        {"cid": "待关联", "cust": "中山大学（示例课题组）", "opp": "mRNA 帽类似物体系选型",
         "sales": "林思远", "scale": "¥120,000", "tech": "苏工", "comp": "进口帽类似物（品牌F）",
         "nodes": {"方案匹配": sc("进行中", "2026-10-01", "林思远")}, "res": {}},
    ]},
    {"name": "生物制药销售部", "rows": [
        {"cid": "6878118301", "cust": "上海复宏汉霖生物技术股份有限公司", "opp": "质控用酶原料导入验证",
         "sales": "周敏", "scale": "¥450,000", "tech": "赵工", "comp": "进口质控酶（品牌G）",
         "nodes": {"陌生开发": sc("已完成", "2026-09-02", "周敏"),
                   "方案匹配": sc("进行中", "2026-09-25", "周敏")},
         "res": {}},
    ]},
    {"name": "国际业务部", "rows": [
        {"cid": "待关联", "cust": "海外经销商 BioSun Trading（示例）", "opp": "核酸提取试剂盒 OEM 询价",
         "sales": "Kevin", "scale": "US$60,000", "tech": "李工", "comp": "本地代理商（品牌H）",
         "unlinked": True,
         "nodes": {"陌生开发": sc("已完成", "2026-09-18", "Kevin")}, "res": {}},
    ]},
]

# 第二层（一行一活动）· 已录入示例
ACTIVITIES = [
    {"actId": "ACT-20261010-001", "dept": "大客户部", "cid": "6878118191",
     "cust": "圣湘生物科技股份有限公司", "opp": "多重呼吸道病原体冻干体系开发",
     "comp": "某进口冻干保护剂（品牌A）", "sales": "赵云浩", "tech": "李工", "scale": "¥1,200,000",
     "date": "2026-10-10", "contact": "张工", "order": "SO-20261009-006", "sku": "MD2104",
     "type": "测试结果跟进",
     "d1": "客户希望冻干后 37℃ 加速 7 天 Ct 漂移 ≤1；且需兼容其现有一步法 RT-qPCR 程序。",
     "d2": "MD2104 冻干保护剂 5%（W/V）；复溶后酶活回收 ≥95%；批内 CV ≤3%。",
     "d3": "送样 5 组配方，客户已回传 3 组数据：A/B 组通过，C 组加速后 Ct 漂移 1.4（超阈）。",
     "d4": "客户原话：“C 组第 6 天开始曲线抬不起来，怀疑保护剂析晶。”",
     "d5": "客户认可 A/B 组，要求就 C 组补一批调整海藻糖配比后复测。",
     "d6": "竞品（品牌A）同条件 Ct 漂移 0.9；我方 A 组 0.6，已优于竞品。",
     "todo": "研发按新配比重做 C 组配方 1 批并附加速数据，交付客户复测。",
     "supportTypes": [{"type": "研发项目单", "note": ""}],
     "owner": "本人", "due": "2026-10-20"},
    {"actId": "ACT-20261009-002", "dept": "销售拓展部", "cid": "6878118250",
     "cust": "广州达安基因股份有限公司", "opp": "数字PCR扩增酶体系开发",
     "comp": "进口高保真酶（品牌C）", "sales": "陈嘉怡", "tech": "黄工", "scale": "¥540,000",
     "date": "2026-10-09", "contact": "赵经理", "order": "SO-20261008-003", "sku": "1E102",
     "type": "采购跟进",
     "d1": "", "d2": "", "d3": "", "d4": "", "d5": "", "d6": "",
     "todo": "跟进客户数字 PCR 验证进度并推动二次报价。",
     "supportTypes": [{"type": "技术支持测试项目单", "note": ""}, {"type": "售后", "note": ""}],
     "owner": "本人", "due": "2026-10-18"},
]

DATA = {
    "layer1": {"depts": LAYER1_DEPTS, "nodeCols": NODE_COLS, "resCols": RES_COLS, "baseCols": BASE_COLS},
    "activities": ACTIVITIES,
    "meta": {"actTypes": ACT_TYPES, "supportTypes": SUPPORT_TYPES, "supportEnum": SUPPORT_ENUM},
}

ICONS = {
    "plus": '<svg viewBox="0 0 24 24" class="ico" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>',
    "col":  '<svg viewBox="0 0 24 24" class="ico" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="1.6"/><path d="M9.5 5.5v13M15 5.5v13"/></svg>',
    "row":  '<svg viewBox="0 0 24 24" class="ico" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="1.6"/><path d="M3.5 10.5h17M3.5 14.5h17"/></svg>',
}

HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>客户拉通大表 · DEMO（两层设计 · 示例数据）</title>
<style>
:root{
  --pri:#1852B6; --pri-d:#0d3a85; --bg:#f4f7fb; --card:#fff; --line:#e3e9f2;
  --ink:#1a2434; --sub:#5b6b82; --mut:#93a1b5;
  --st-todo-bg:#eef1f5; --st-todo-ink:#64748b; --st-todo-bd:#d5dbe4;
  --st-doing-bg:#e3edfd; --st-doing-ink:#1852B6; --st-doing-bd:#b9cbe8;
  --st-done-bg:#dcf5e6;  --st-done-ink:#0f7a3d; --st-done-bd:#a6ddba;
  --st-stuck-bg:#fde2e2; --st-stuck-ink:#b42318; --st-stuck-bd:#f3b6b0;
  --warn-bg:#FEF3C7; --warn-bd:#F59E0B; --warn-ink:#92400E; --bad:#C0392B; --ok:#16A34A;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
  font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:1760px;margin:0 auto;padding:16px 20px 34px}
h1{font-size:21px;margin:0;font-weight:800}
.demo-bar{background:linear-gradient(90deg,#FEF3C7,#FDE68A);border:1px solid var(--warn-bd);
  color:var(--warn-ink);border-radius:10px;padding:9px 14px;margin:12px 0;font-size:13px;font-weight:600;
  display:flex;align-items:center;gap:9px}
.demo-bar .dot{width:9px;height:9px;border-radius:50%;background:var(--warn-bd);flex:none;animation:bl 1.4s infinite}
@keyframes bl{50%{opacity:.35}}
.head-row{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-top:6px}
.head-row .sub{color:var(--sub);font-size:13px}
.ico{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}

.tabs{display:flex;gap:6px;margin:14px 0 0;border-bottom:2px solid var(--line)}
.tab{border:1px solid var(--line);border-bottom:none;background:#eef3fb;color:var(--sub);
  border-radius:10px 10px 0 0;padding:10px 18px;font-size:13.5px;font-weight:700;cursor:pointer;
  margin-bottom:-2px;transition:.14s}
.tab:hover{color:var(--pri)}
.tab.on{background:#fff;color:var(--pri);border-color:var(--pri);border-bottom:2px solid #fff;
  box-shadow:0 -2px 0 var(--pri) inset}

.panel{background:#fff;border:1px solid var(--line);border-top:none;border-radius:0 0 12px 12px;
  padding:14px 16px 18px;box-shadow:0 1px 3px rgba(16,42,82,.05)}
.toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin:2px 0 12px}
.toolbar .spacer{flex:1}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--pri);background:var(--pri);
  color:#fff;border-radius:9px;padding:8px 14px;font-size:13px;font-weight:600;cursor:pointer;transition:.14s}
.btn:hover{background:var(--pri-d);border-color:var(--pri-d)}
.btn.ghost{background:#fff;color:var(--pri)}
.btn.ghost:hover{background:#eef3fb}
.btn.sm{padding:6px 11px;font-size:12.5px}
.mini{border:1px solid var(--pri);background:#fff;color:var(--pri);border-radius:7px;padding:3px 9px;
  font-size:11.5px;cursor:pointer;font-weight:600}
.tip{font-size:11.5px;color:var(--mut)}
.logic{background:#f7faff;border:1px dashed #b9cbe8;border-radius:10px;padding:9px 13px;margin:0 0 12px;
  font-size:12px;color:#33465f;line-height:1.75}
.logic b{color:var(--pri-d)}

/* 表格 */
.tablewrap{overflow:auto;max-height:64vh;border:1px solid var(--line);border-radius:12px;background:#fff;
  box-shadow:0 1px 3px rgba(16,42,82,.05)}
table.l1,table.l2{border-collapse:separate;border-spacing:0;table-layout:fixed;font-size:12px}
table.l1 th,table.l1 td,table.l2 th,table.l2 td{border-right:1px solid var(--line);border-bottom:1px solid var(--line);
  padding:5px 8px;vertical-align:top;text-align:left;background:#fff}
table.l1 thead th,table.l2 thead th{position:sticky;z-index:6;text-align:center;white-space:nowrap;color:#33465f}
table.l1 thead tr.h1 th,table.l2 thead tr.h1 th{top:0;height:26px;background:#dfe9f8;color:var(--pri-d);
  font-size:12.5px;font-weight:800;letter-spacing:.4px;text-align:left;padding-left:12px}
table.l1 thead tr.h2 th,table.l2 thead tr.h2 th{top:26px;height:30px;font-size:12px;font-weight:700}
table.l1 thead tr.h3 th,table.l2 thead tr.h3 th{top:56px;height:34px;font-size:11.5px;font-weight:700}
.band-base{background:#e6eefb;color:var(--pri-d)}
.band-node{background:#fdeedd;color:#9a5b09}
.band-act{background:#e6f6ef;color:#0f7a3d}
.band-res{background:#efe7fb;color:#5b21b6}
.band-todo{background:#e8ecf3;color:#475569}
table.l1 th.stick,table.l1 td.stick,table.l2 th.stick,table.l2 td.stick{position:sticky;background:#fff;z-index:2;
  box-shadow:2px 0 4px -2px rgba(16,42,82,.16)}
table.l1 thead th.stick,table.l2 thead th.stick{z-index:7;background:#e3ebf7}
table.l1 td.stick{background:#fbfcfe}
table.l1 tbody tr:hover td{background:#f7faff}
table.l1 tbody tr:hover td.stick{background:#eef4fd}
td.c-client{font-weight:700}
td.c-opp{font-weight:600;color:var(--pri-d)}
.cellstate{display:inline-block;border-radius:5px;padding:1px 7px;font-size:11px;font-weight:700;
  border:1px solid transparent;white-space:nowrap}
.st-todo{background:var(--st-todo-bg);color:var(--st-todo-ink);border-color:var(--st-todo-bd)}
.st-doing{background:var(--st-doing-bg);color:var(--st-doing-ink);border-color:var(--st-doing-bd)}
.st-done{background:var(--st-done-bg);color:var(--st-done-ink);border-color:var(--st-done-bd)}
.st-stuck{background:var(--st-stuck-bg);color:var(--st-stuck-ink);border-color:var(--st-stuck-bd)}
.celldate{display:block;font-size:10.5px;color:var(--mut);font-variant-numeric:tabular-nums;margin-top:2px}
.cellowner{display:block;font-size:10.5px;color:var(--sub);margin-top:1px}
.cellact{display:inline-block;font-size:9.5px;color:#0d3a85;background:#e6eefb;border-radius:4px;
  padding:0 4px;margin-top:2px;font-variant-numeric:tabular-nums}
.cellnone{color:#c7d0dc;font-size:13px}
.warn{background:var(--warn-bg);border:1px solid var(--warn-bd);color:var(--warn-ink);
  font-size:10px;font-weight:700;border-radius:5px;padding:0 5px;white-space:nowrap;display:inline-block;margin-top:2px}
tr.deptrow td{background:#eef3fb;font-weight:800;color:var(--pri-d);font-size:12.5px;cursor:pointer;z-index:3}
tr.deptrow td .cnt{font-weight:600;color:var(--sub);font-size:11px;margin-left:8px}
tr.deptrow td .tri{display:inline-block;transition:.15s;margin-right:5px}
tr.deptrow.collapsed td .tri{transform:rotate(-90deg)}
.flash{animation:fl 1.6s ease}
@keyframes fl{0%{background:#fff2c2}30%{background:#ffe58a}100%{background:transparent}}
tbody tr.flash td{background:#fff7d6}

/* 表单 */
.form-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:#fff;margin-bottom:14px}
.grp-title{font-size:12.5px;font-weight:800;color:var(--pri-d);margin:14px 0 8px;padding-left:9px;
  border-left:4px solid var(--pri)}
.fgrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 14px}
@media(max-width:980px){.fgrid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.fgrid label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--sub);font-weight:600}
.fgrid input,.fgrid select{border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12.5px;
  background:#f9fbfe;color:var(--ink);font-family:inherit}
.fgrid input:focus,.fgrid select:focus{outline:none;border-color:var(--pri);background:#fff}
.six{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 14px;margin-top:4px}
@media(max-width:980px){.six{grid-template-columns:1fr}}
.six label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--sub);font-weight:600}
.six textarea{border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12.5px;min-height:64px;
  resize:vertical;background:#f9fbfe;color:var(--ink);font-family:inherit}
.six textarea:focus{outline:none;border-color:var(--pri);background:#fff}
.six.required textarea.bad{border-color:var(--bad);background:#fdeeee;box-shadow:0 0 0 2px #f6c6c0 inset}
.six-hd{font-size:12.5px;font-weight:800;color:var(--pri-d);margin:14px 0 6px;display:flex;align-items:center;gap:9px}
.req-tag{font-size:10.5px;font-weight:700;color:var(--bad);background:#fdeeee;border:1px solid #f3b6b0;
  border-radius:5px;padding:1px 7px}
.form-err{display:none;margin-top:12px;background:#fdeeee;border:1px solid #f3b6b0;color:var(--bad);
  border-radius:9px;padding:9px 13px;font-size:12.5px;font-weight:600}
.form-err.on{display:block}
.form-ok{display:none;margin-top:12px;background:#e6f7ee;border:1px solid #a6ddba;color:#0f7a3d;
  border-radius:9px;padding:9px 13px;font-size:12.5px;font-weight:600}
.form-ok.on{display:block}
.submit-row{margin-top:14px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}

/* 弹层 */
.mask{position:fixed;inset:0;background:rgba(16,42,82,.20);z-index:60;display:none}
.mask.open{display:block}
.modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.98);width:min(860px,94vw);
  max-height:88vh;background:#fff;border-radius:14px;box-shadow:0 18px 60px rgba(16,42,82,.3);
  z-index:70;display:none;flex-direction:column;overflow:hidden}
.modal.open{display:flex}
.mh{padding:14px 18px;border-bottom:1px solid var(--line);background:#f7faff;display:flex;align-items:center}
.mh h3{margin:0;font-size:15px;color:var(--pri-d);flex:1}
.mh .x{border:none;background:#eef3fb;border-radius:8px;width:30px;height:30px;font-size:16px;color:var(--sub);cursor:pointer}
.mb{padding:14px 18px;overflow:auto}
.mb section{margin-bottom:16px}
.mb h4{margin:0 0 8px;font-size:13px;color:var(--pri-d);display:flex;align-items:center;gap:9px}
.mb pre{margin:0;background:#0f1b2e;color:#d6e3f7;border-radius:10px;padding:11px 13px;font-size:11.5px;
  line-height:1.6;overflow:auto;max-height:230px;white-space:pre-wrap;word-break:break-word;
  font-family:"SF Mono",Menlo,Consolas,monospace}
.collapsed pre{display:none}
.checklist{list-style:none;margin:0;padding:0;font-size:12.5px;line-height:1.9;color:#33465f}
.checklist li:before{content:"☐ ";color:var(--pri);font-weight:700}
.toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);background:#0d3a85;color:#fff;
  border-radius:9px;padding:9px 18px;font-size:13px;opacity:0;transition:.25s;z-index:99;pointer-events:none}
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
.note{font-size:11.5px;color:var(--mut);margin-top:10px;line-height:1.7}

/* 资源协作多选 */
.res-pick{display:flex;flex-wrap:wrap;gap:10px 20px;align-items:center;
  background:#f7faff;border:1px dashed #b9cbe8;border-radius:10px;padding:11px 14px}
.res-pick .chk{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;font-weight:700;color:#33465f;cursor:pointer}
.res-pick .chk input[type=checkbox]{width:15px;height:15px;accent-color:var(--pri);cursor:pointer}
.res-pick .note{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--sub);
  font-weight:600;flex:1 1 300px;margin:0}
.res-pick .note input{border:1px solid var(--line);border-radius:8px;padding:6px 9px;font-size:12.5px;
  background:#fff;color:var(--ink);font-family:inherit;flex:1;min-width:180px}
.res-pick .note input:focus{outline:none;border-color:var(--pri)}
.res-pick .note input.bad{border-color:var(--bad);background:#fdeeee;box-shadow:0 0 0 2px #f6c6c0 inset}
</style>
</head>
<body>
<div class="wrap">
  <div class="demo-bar"><span class="dot"></span>
    DEMO 示例数据 · 客户名为真实公司名，商机/日期/负责人/金额/活动内容均为<b>示例值</b>，未上线。
  </div>
  <div class="head-row">
    <h1>客户拉通大表 · 两层设计 DEMO</h1>
    <span class="sub">① 第一层｜项目推移表（一行一商机） ｜ ② 第二层｜活动录入与待办（一行一活动）</span>
  </div>

  <div class="tabs">
    <button class="tab on" data-t="l1">① 第一层｜项目推移表</button>
    <button class="tab" data-t="l2">② 第二层｜活动录入与待办</button>
  </div>

  <!-- ===== 第一层 ===== -->
  <div class="panel" id="panel-l1">
    <div class="logic">
      <b>第一层逻辑</b>：① 根据销售活动的<b>基本信息、关联商机</b>，自动抓取本层项目基本信息；
      ② 项目基本信息中的<b>竞对信息可手动维护</b>；③ <b>项目关键节点可手动维护</b>；
      ④ <b>资源协助由销售在第二层手动多选触发</b>（技术支持测试项目单 / 研发项目单 / 售后 / 其他；每选一类 → 本组加一列，列名带序号 N）。
    </div>
    <div class="toolbar">
      <button class="btn" id="btnAddOpp">＋ 新增商机</button>
      <span class="tip">行向下延展（按部门分组，行头可折叠）</span>
      <span class="spacer"></span>
      <span class="tip" id="l1ColInfo"></span>
      <button class="btn ghost" id="btnAddNode">＋ 新增节点列</button>
      <button class="btn ghost" id="btnAddRes">＋ 新增资源协助列</button>
    </div>
    <div class="tablewrap" id="l1Wrap">
      <table class="l1" id="l1Table"></table>
    </div>
    <div class="note">
      三层表头（组名跨列合并）：<b>第1层</b> 表名带 ｜ <b>第2层</b> 项目基本信息 / 项目关键节点（可向右延展）/ 资源协助（可向右延展）｜ <b>第3层</b> 列名。
      左侧「项目基本信息」列<b>冻结</b>（横向滚动不动）；行按 5 个部门分组，点行头折叠。黄色「待关联」= 该行/该格 ID 未回填（append-only 允许 null）。
    </div>
  </div>

  <!-- ===== 第二层 ===== -->
  <div class="panel" id="panel-l2" style="display:none">
    <div class="form-card">
      <div class="grp-title">活动基本信息（抓取第一层项目基本信息）</div>
      <div class="fgrid">
        <label>部门<select id="f_dept"></select></label>
        <label>客户编码<input id="f_cid" placeholder="6878...（未回填留空→待关联）"></label>
        <label>客户名称<select id="f_cust"></select></label>
        <label>商机名称<select id="f_opp"></select></label>
        <label>竞品信息<input id="f_comp" placeholder="品牌+量化（可手动维护）"></label>
        <label>销售员<select id="f_sales"></select></label>
        <label>技术支持<input id="f_tech" value="李工"></label>
        <label>商机规模<input id="f_scale" placeholder="¥0"></label>
      </div>

      <div class="grp-title">活动信息更新（可持续向右延展）</div>
      <div class="fgrid">
        <label>日期<input id="f_date" value="2026-10-11"></label>
        <label>拜访对象/联系人<input id="f_contact" value="张工"></label>
        <label>关联订单<input id="f_order" placeholder="SO-..."></label>
        <label>货号<input id="f_sku" placeholder="MD2104"></label>
        <label>活动类型
          <select id="f_type"></select>
        </label>
        <label>本次推进节点（可选·回写第一层）
          <select id="f_node"></select>
        </label>
        <label>&nbsp;
          <button class="mini" id="btnQC" type="button">填写质量质检检查单 ▸</button>
        </label>
      </div>

      <div class="six-hd">6 维结构化描述
        <span class="req-tag">活动类型=「测试结果跟进」时 6 项<b>必填</b></span></div>
      <div class="six" id="sixdim">
        <label>① 客户需求（分点）<textarea id="st1" placeholder="分点列出客户诉求"></textarea></label>
        <label>② 技术参数（货号+量化）<textarea id="st2" placeholder="货号 + 量化指标"></textarea></label>
        <label>③ 测试单结果跟进<textarea id="st3" placeholder="测试单编号 + 结果"></textarea></label>
        <label>④ 问题现象（含客户原话）<textarea id="st4" placeholder="现象 + 客户原话"></textarea></label>
        <label>⑤ 客户反馈<textarea id="st5" placeholder="客户态度/结论"></textarea></label>
        <label>⑥ 竞对信息（品牌+量化）<textarea id="st6" placeholder="竞品品牌 + 量化对比"></textarea></label>
      </div>

      <div class="grp-title">待办事项（做什么、怎么做、做到什么程度、关联哪些资源）</div>
      <div class="fgrid">
        <label>事项<input id="f_todo" placeholder="做什么 / 怎么做 / 做到什么程度 / 关联哪些资源"></label>
        <label>负责人<input id="f_owner" value="本人（可添加其他人）"></label>
        <label>时间节点<input id="f_due" placeholder="2026-10-20（到具体日期）"></label>
      </div>

      <div class="grp-title">触发的资源协作（多选 · 可都不选）</div>
      <div class="res-pick" id="resPick">
        <label class="chk"><input type="checkbox" data-res value="技术支持测试项目单"><span>技术支持测试项目单</span></label>
        <label class="chk"><input type="checkbox" data-res value="研发项目单"><span>研发项目单</span></label>
        <label class="chk"><input type="checkbox" data-res value="售后"><span>售后</span></label>
        <label class="chk"><input type="checkbox" data-res value="其他" id="resOther"><span>其他</span></label>
        <label class="chk note" id="otherNoteWrap" style="display:none">其他资源协作说明
          <input id="f_othernote" placeholder="必填：说明“其他”的具体资源协作内容"></label>
      </div>
      <div class="note" style="margin-top:6px">
        勾选后提交：每选中的一类 → 第一层对应商机行「资源协助」组<span>新增一列</span>（列名 技术支持测试项目单N / 研发项目单N / 售后N / 其他N，N 为该类已有列数+1），
        并在单元格写入 状态（待承接）+ 日期 + 发起人 + 活动编号。选「其他」须填说明。
      </div>

      <div class="form-err" id="formErr"></div>
      <div class="form-ok" id="formOk"></div>
      <div class="submit-row">
        <button class="btn" id="btnSubmit">提交 · 三轨落盘 + 回写第一层</button>
        <span class="tip">提交后：① 本地记录（活动编号）② CloudBase 写入 JSON ③ 飞书描述（一键复制）；并自动回写第一层对应商机行。</span>
      </div>
    </div>

    <div class="grp-title">已录入活动（第二层 · 一行一活动 · 三层表头）</div>
    <div class="tablewrap" id="l2Wrap">
      <table class="l2" id="l2Table"></table>
    </div>
    <div class="note">
      组1 基本信息 ｜ 组2 活动信息更新（可持续向右延展）｜ 组3 待办事项；「活动编号」为三轨落盘本地记录主键。
    </div>
  </div>
</div>

<!-- 三轨落盘弹层 -->
<div class="mask" id="mask"></div>
<div class="modal" id="trackModal">
  <div class="mh"><h3>提交结果 · 三轨落盘</h3><button class="x" id="trackX">✕</button></div>
  <div class="mb">
    <section>
      <h4>① 本地记录（活动编号 <span id="tActId" style="color:#0d3a85"></span>）</h4>
      <pre id="trackLocal"></pre>
    </section>
    <section id="secCB">
      <h4>② CloudBase 写入 JSON（activities / support_todos / dept_feedback）
        <button class="mini" id="btnToggleCB" type="button">收起 / 展开</button></h4>
      <pre id="trackCB"></pre>
    </section>
    <section>
      <h4>③ 飞书描述（一键复制）
        <button class="mini" id="btnCopyFeishu" type="button">复制</button></h4>
      <pre id="trackFeishu"></pre>
    </section>
  </div>
</div>

<!-- 质检检查单弹层 -->
<div class="modal" id="qcModal">
  <div class="mh"><h3>填写质量质检检查单（自检）</h3><button class="x" id="qcX">✕</button></div>
  <div class="mb">
    <ul class="checklist">
      <li>活动类型=「测试结果跟进」时，6 维结构化描述<b>逐项非空</b>（缺一不可）。</li>
      <li>① 客户需求：<b>分点</b>列出，可执行、可验收。</li>
      <li>② 技术参数：必须含<b>货号 + 量化指标</b>（如回收率/ CV / Ct 漂移）。</li>
      <li>③ 测试单结果跟进：写清<b>测试单编号 + 结果</b>（通过/超阈）。</li>
      <li>④ 问题现象：含<b>客户原话</b>（加引号）。</li>
      <li>⑤ 客户反馈：客户结论 / 下一步诉求。</li>
      <li>⑥ 竞对信息：<b>品牌 + 量化</b>对比，禁止“感觉差不多”。</li>
      <li>待办事项：写清做什么 / 怎么做 / 做到什么程度 / 关联哪些资源；负责人与<b>具体日期</b>齐全。</li>
      <li>关联订单 / 货号：与 ERP 口径一致，未回填的 ID 允许留空（前端标「待关联」）。</li>
    </ul>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
const DATA = __DATA__;
const ICONS = __ICONS__;

/* ================= 状态 ================= */
let L1 = JSON.parse(JSON.stringify(DATA.layer1));
let nodeCols = L1.nodeCols.slice();
let resCols  = L1.resCols.slice();
let activities = JSON.parse(JSON.stringify(DATA.activities));
let actSeq = activities.length;
let nodeSeq = 0, resSeq = 0;
let collapsed = new Set();
const NODES = ["陌生开发","方案匹配","中试","临床试验","已确认被用于注册","已转产","客户已获证","新证迭代"];
const STCLS = {'未开始':'todo','待承接':'todo','进行中':'doing','已完成':'done','卡住':'stuck'};

function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){
  return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function $(id){return document.getElementById(id);}

/* ================= 第一层 ================= */
function baseCols(){return [
  {key:'dept', title:'部门',    w:76,  group:'base'},
  {key:'cid',  title:'客户编码', w:96,  group:'base'},
  {key:'cust', title:'客户名称', w:176, group:'base'},
  {key:'opp',  title:'商机名称', w:200, group:'base'},
  {key:'sales',title:'销售员',   w:72,  group:'base'},
  {key:'scale',title:'商机规模', w:92,  group:'base'}
];}
function allCols(){
  const cols = baseCols().slice();
  nodeCols.forEach(n=>cols.push({key:'nd:'+n, title:n, w:92, group:'node', node:n}));
  resCols.forEach(r=>cols.push({key:'rs:'+r, title:r, w:150, group:'res', res:r}));
  return cols;
}
function baseOffsets(cols){
  let acc=0; const off={};
  cols.forEach(c=>{ if(c.group==='base'){ off[c.key]=acc; acc+=c.w; } });
  return off;
}
function l1Cell(r,c){
  if(c.group==='node') return (r.nodes||{})[c.node];
  if(c.group==='res')  return (r.res||{})[c.res];
  return null;
}
function cellHTML(cell){
  if(!cell) return '<span class="cellnone">—</span>';
  let h='<span class="cellstate st-'+(STCLS[cell.state]||'todo')+'">'+esc(cell.state)+'</span>';
  if(cell.date)  h+='<span class="celldate">'+esc(cell.date)+'</span>';
  if(cell.owner) h+='<span class="cellowner">'+esc(cell.owner)+'</span>';
  if(cell.act)   h+='<span class="cellact">'+esc(cell.act)+'</span>';
  if(cell.unlinked) h+='<span class="warn">待关联 ID</span>';
  return h;
}
function l1Head(cols,off){
  const b=cols.filter(c=>c.group==='base'), n=cols.filter(c=>c.group==='node'), r=cols.filter(c=>c.group==='res');
  let h1='<tr class="h1"><th colspan="'+cols.length+'">第一层｜项目推移表 · 一行一商机（按部门分组，行头可折叠；横向滚动，左侧信息列冻结）</th></tr>';
  let h2='<tr class="h2">'
    + '<th class="band band-base stick" colspan="'+b.length+'" style="left:0">项目基本信息</th>'
    + '<th class="band band-node" colspan="'+n.length+'">项目关键节点（可向右延展）</th>'
    + '<th class="band band-res" colspan="'+r.length+'">资源协助（可向右延展）</th></tr>';
  let h3='<tr class="h3">';
  b.forEach(c=>h3+='<th class="stick" data-col="'+c.key+'" style="left:'+off[c.key]+'px">'+esc(c.title)+'</th>');
  n.forEach(c=>h3+='<th data-col="'+c.key+'">'+esc(c.title)+'</th>');
  r.forEach(c=>h3+='<th data-col="'+c.key+'">'+esc(c.title)+'</th>');
  h3+='</tr>';
  return '<thead>'+h1+h2+h3+'</thead>';
}
function renderL1(){
  const cols=allCols(), off=baseOffsets(cols), t=$('l1Table');
  t.style.width = cols.reduce((a,c)=>a+c.w,0)+'px';
  const cg='<colgroup>'+cols.map(c=>'<col style="width:'+c.w+'px">').join('')+'</colgroup>';
  let body='<tbody>';
  L1.depts.forEach(d=>{
    const open = !collapsed.has(d.name);
    body+='<tr class="deptrow'+(open?'':' collapsed')+'" data-dept="'+esc(d.name)+'">'
        + '<td class="stick" colspan="'+cols.length+'" style="left:0">'
        + '<span class="tri">▾</span>'+esc(d.name)+'<span class="cnt">'+d.rows.length+' 个商机</span></td></tr>';
    if(!open) return;
    d.rows.forEach(r=>{
      body+='<tr data-opp="'+esc(r.opp)+'" data-cust="'+esc(r.cust)+'">';
      cols.forEach(c=>{
        if(c.group==='base'){
          let v = r[c.key]==null?'':r[c.key];
          if(c.key==='cust'){ v = esc(v)+ (r.unlinked?' <span class="warn">待关联</span>':''); }
          else v = esc(v);
          const cl = c.key==='cust'?'c-client':(c.key==='opp'?'c-opp':'');
          const lead = (c.key==='cust')?' lead':'';
          body+='<td class="stick'+lead+' '+cl+'" style="left:'+off[c.key]+'px">'+v+'</td>';
        }else{
          body+='<td>'+cellHTML(l1Cell(r,c))+'</td>';
        }
      });
      body+='</tr>';
    });
  });
  body+='</tbody>';
  t.innerHTML = cg + l1Head(cols,off) + body;
  t.querySelectorAll('tr.deptrow').forEach(tr=>{
    tr.onclick=()=>{ const n=tr.dataset.dept;
      if(collapsed.has(n)) collapsed.delete(n); else collapsed.add(n); renderL1(); };
  });
  const nRows = L1.depts.filter(d=>!collapsed.has(d.name)).reduce((a,d)=>a+d.rows.length,0);
  $('l1ColInfo').textContent = '共 '+cols.length+' 列（节点 '+nodeCols.length+' · 资源协助 '+resCols.length+'）· 可见商机 '+nRows+' 行';
  window.__l1 = {cols:cols.length, nodeCols:nodeCols.length, resCols:resCols.length, resNames:resCols.slice(),
                 rows:L1.depts.reduce((a,d)=>a+d.rows.length,0)};
}

/* ================= 第二层列表 ================= */
function renderL2(){
  const cols=[
    {key:'actId',  title:'活动编号', w:140, group:'base'},
    {key:'dept',   title:'部门',     w:90,  group:'base'},
    {key:'cid',    title:'客户编码', w:96,  group:'base'},
    {key:'cust',   title:'客户名称', w:180, group:'base'},
    {key:'opp',    title:'商机名称', w:190, group:'base'},
    {key:'comp',   title:'竞品信息', w:150, group:'base'},
    {key:'sales',  title:'销售员',   w:72,  group:'base'},
    {key:'tech',   title:'技术支持', w:72,  group:'base'},
    {key:'scale',  title:'商机规模', w:96,  group:'base'},
    {key:'date',   title:'日期',     w:88,  group:'act'},
    {key:'contact',title:'拜访对象/联系人', w:112, group:'act'},
    {key:'order',  title:'关联订单', w:120, group:'act'},
    {key:'sku',    title:'货号',     w:90,  group:'act'},
    {key:'type',   title:'活动类型', w:104, group:'act'},
    {key:'todo',   title:'事项',     w:220, group:'todo'},
    {key:'owner',  title:'负责人',   w:90,  group:'todo'},
    {key:'due',    title:'时间节点', w:96,  group:'todo'}
  ];
  const t=$('l2Table');
  t.style.width = cols.reduce((a,c)=>a+c.w,0)+'px';
  const cg='<colgroup>'+cols.map(c=>'<col style="width:'+c.w+'px">').join('')+'</colgroup>';
  const b=cols.filter(c=>c.group==='base'), a=cols.filter(c=>c.group==='act'), td=cols.filter(c=>c.group==='todo');
  let h1='<tr class="h1"><th colspan="'+cols.length+'">第二层｜活动录入表 · 一行一活动（提交后三轨落盘：本地记录 / CloudBase / 飞书描述）</th></tr>';
  let h2='<tr class="h2">'
    + '<th class="band band-base stick" colspan="'+b.length+'" style="left:0">项目基本信息（抓取）</th>'
    + '<th class="band band-act" colspan="'+a.length+'">活动信息更新（可持续向右延展）</th>'
    + '<th class="band band-todo" colspan="'+td.length+'">待办事项</th></tr>';
  let h3='<tr class="h3">'+cols.map(c=>'<th>'+esc(c.title)+'</th>').join('')+'</tr>';
  let body='<tbody>';
  activities.forEach(m=>{
    body+='<tr>';
    cols.forEach(c=>{
      let v=m[c.key]==null?'':m[c.key];
      const cl = c.key==='cust'?'c-client':(c.key==='opp'?'c-opp':'');
      if(c.key==='cid' && (!v || v==='待关联')) v=esc(v||'')+' <span class="warn">待关联</span>';
      else v=esc(v);
      body+='<td class="'+cl+'">'+v+'</td>';
    });
    body+='</tr>';
  });
  body+='</tbody>';
  t.innerHTML = cg + '<thead>'+h1+h2+h3+'</thead>' + body;
  window.__l2rows = activities.length;
}

/* ================= 表单 ================= */
function fillSelect(sel, vals, keepEmpty){
  sel.innerHTML='';
  if(keepEmpty) sel.appendChild(new Option('（未选择）',''));
  vals.forEach(v=>sel.appendChild(new Option(v,v)));
}
function initForm(){
  const depts = L1.depts.map(d=>d.name);
  const custs = [].concat.apply([], L1.depts.map(d=>d.rows.map(r=>r.cust)));
  const opps  = [].concat.apply([], L1.depts.map(d=>d.rows.map(r=>r.opp)));
  const sales = [].concat.apply([], L1.depts.map(d=>d.rows.map(r=>r.sales)));
  fillSelect($('f_dept'), depts);
  fillSelect($('f_cust'), custs);
  fillSelect($('f_opp'), opps);
  fillSelect($('f_sales'), [...new Set(sales)]);
  fillSelect($('f_type'), DATA.meta.actTypes);
  fillSelect($('f_node'), ['不推进'].concat(NODES));
  $('f_type').value='测试结果跟进';
  $('f_node').value='不推进';
  $('resOther').onchange=()=>{ $('otherNoteWrap').style.display = $('resOther').checked ? 'flex' : 'none'; };
  $('f_dept').onchange=()=>{ /* 部门切换：仅联动展示，不强制过滤 */ };
  $('f_cust').onchange=()=>{
    const c=$('f_cust').value;
    const hit = [].concat.apply([], L1.depts.map(d=>d.rows)).find(r=>r.cust===c);
    if(hit){ $('f_opp').value=hit.opp; $('f_dept').value=hit.dept; $('f_sales').value=hit.sales;
             $('f_cid').value=hit.cid; $('f_comp').value=hit.comp||''; $('f_scale').value=hit.scale||''; }
  };
}

/* ================= 6 维校验 + 提交 ================= */
const DIMS=[['st1','① 客户需求（分点）'],['st2','② 技术参数（货号+量化）'],['st3','③ 测试单结果跟进'],
            ['st4','④ 问题现象（含客户原话）'],['st5','⑤ 客户反馈'],['st6','⑥ 竞对信息（品牌+量化）']];

/* 收集「触发的资源协作（多选）」——逐字四类 */
function collectSupport(){
  const picked=[];
  document.querySelectorAll('#resPick input[data-res]').forEach(cb=>{
    if(cb.checked) picked.push({type:cb.value, note: cb.value==='其他' ? $('f_othernote').value.trim() : ''});
  });
  return picked;
}

function validate(){
  const type=$('f_type').value;
  const six=$('sixdim'); six.classList.remove('required');
  DIMS.forEach(d=>$(d[0]).classList.remove('bad'));
  $('f_othernote').classList.remove('bad');
  $('formErr').classList.remove('on'); $('formErr').textContent='';
  const errs=[];
  if(type==='测试结果跟进'){
    six.classList.add('required');
    const missing=[];
    DIMS.forEach(d=>{ if(!$(d[0]).value.trim()){ missing.push(d[1]); $(d[0]).classList.add('bad'); } });
    if(missing.length){
      errs.push('活动类型「测试结果跟进」必须结构化描述 6 个维度，缺少必填维度 —— '+missing.join('、')+'。');
    }
  }
  if($('resOther').checked && !$('f_othernote').value.trim()){
    $('f_othernote').classList.add('bad');
    errs.push('已勾选「其他」资源协作，必须填写「其他资源协作说明」（说明其具体资源协作内容）。');
  }
  if(errs.length){
    $('formErr').textContent='提交被拦截：'+errs.join(' ');
    $('formErr').classList.add('on');
    return false;
  }
  return true;
}

function pad3(n){return String(n).padStart(3,'0');}
function pad2(n){return String(n).padStart(2,'0');}
function todayStr(){return $('f_date').value.replace(/-/g,'') || '20261011';}

function buildTracks(m){
  const SUPP_ENUM = DATA.meta.supportEnum || {};
  const picked = m.supportTypes || [];
  const todoDocs = picked.map((st,i)=>({
    todo_id: 'TD-'+m.actId+'-'+pad2(i+1), act_id: m.actId,
    customer: m.cust, opportunity: m.opp,
    type: SUPP_ENUM[st.type] || 'other', type_label: st.type, note: st.note || '',
    todo: m.todo, owner: m.owner, due: m.due, state: '待承接', assignee_dept: '（待指派）'
  }));
  const local = JSON.stringify(m, null, 2);
  const cb = JSON.stringify({
    "collection": "activities",
    "doc": {"id":"FS-"+m.actId, "act_id":m.actId, "submitted_by":m.sales, "sales":m.sales,
            "dept":m.dept, "customer":m.cust, "customer_id":m.cid, "opportunity":m.opp,
            "competitor":m.comp, "support_tech":m.tech, "scale":m.scale,
            "date":m.date, "contact":m.contact, "order_no":m.order, "sku":m.sku,
            "type":m.type, "structured_6d":{d1:m.d1,d2:m.d2,d3:m.d3,d4:m.d4,d5:m.d5,d6:m.d6},
            "support_types": picked.map(st=>st.type),
            "created_at":"2026-10-11T00:00:00+08:00"},
    "support_todos": {"collection":"support_todos", "docs": todoDocs},
    "dept_feedback": {"collection":"dept_feedback",
            "doc":{"feedback_id":"FB-"+m.actId, "act_id":m.actId, "actor":m.sales,
                   "action":m.type+" · "+(m.todo||'—'), "visible":"本部门+技术支持"}}
  }, null, 2);
  const resLines = picked.length
    ? picked.map((st,i)=>{
        const d = todoDocs[i];
        return '  '+(i+1)+'. '+st.type
             + (st.type==='其他' && st.note ? ('（说明：'+st.note+'）') : '')
             + ' → 承接待办 TD-'+m.actId+'-'+pad2(i+1)+'（type='+d.type+'，state=待承接，负责人 '+(m.owner||'—')+'，时间节点 '+(m.due||'—')+'）';
      }).join('\n')
    : '  （本次未触发资源协作）';
  const feishu =
    '【活动录入】'+m.actId+'\n'
   +'部门：'+m.dept+' ｜ 销售员：'+m.sales+' ｜ 技术支持：'+m.tech+'\n'
   +'客户：'+m.cust+'（客户编码 '+(m.cid||'待关联')+'）\n'
   +'商机：'+m.opp+' ｜ 商机规模：'+m.scale+'\n'
   +'竞品信息：'+(m.comp||'—')+'\n'
   +'日期：'+m.date+' ｜ 拜访对象/联系人：'+m.contact+' ｜ 关联订单：'+(m.order||'—')+' ｜ 货号：'+(m.sku||'—')+'\n'
   +'活动类型：'+m.type+'\n'
   +'—— 本次触发的资源协作（销售手动多选）——\n'
   +resLines+'\n'
   +'—— 6 维结构化 ——\n'
   +'① 客户需求（分点）：'+(m.d1||'（非必填）')+'\n'
   +'② 技术参数（货号+量化）：'+(m.d2||'（非必填）')+'\n'
   +'③ 测试单结果跟进：'+(m.d3||'（非必填）')+'\n'
   +'④ 问题现象（含客户原话）：'+(m.d4||'（非必填）')+'\n'
   +'⑤ 客户反馈：'+(m.d5||'（非必填）')+'\n'
   +'⑥ 竞对信息（品牌+量化）：'+(m.d6||'（非必填）')+'\n'
   +'—— 待办事项 ——\n'
   +'事项：'+(m.todo||'—')+'\n负责人：'+m.owner+' ｜ 时间节点：'+(m.due||'—')+'\n'
   +'（三轨落盘：本地记录 / CloudBase.activities / 飞书描述）';
  return {local:local, cb:cb, feishu:feishu};
}

/* 两层联动：提交一条活动 → 第一层对应商机行新增条目 */
function writeBackL1(m){
  let hit=null;
  L1.depts.forEach(d=>d.rows.forEach(r=>{ if(!hit && (r.opp===m.opp || r.cust===m.cust)) hit={d:d,r:r}; }));
  if(!hit) return null;
  const r=hit.r, summary=[];
  if(m.nodePush && m.nodePush!=='不推进'){
    r.nodes = r.nodes||{};
    r.nodes[m.nodePush] = {state:'已完成', date:m.date, owner:m.sales, act:m.actId};
    summary.push('项目关键节点「'+m.nodePush+'」已推进（'+m.date+' / '+m.sales+' / '+m.actId+'）');
  }
  // 「触发的资源协作（多选）」→ 每选中的一类，新增一列（列名 类型N，N=该类已有列数+1）
  const added=[];
  (m.supportTypes||[]).forEach(st=>{
    const base=st.type;
    const exist=resCols.filter(c=>c.slice(0,base.length)===base);
    const nextName=base+(exist.length+1);
    if(resCols.indexOf(nextName)<0) resCols.push(nextName);
    r.res = r.res||{};
    r.res[nextName] = {state:'待承接', date:m.date, owner:m.sales, act:m.actId};
    added.push(nextName);
    summary.push('资源协助「'+nextName+'」+1（新增列，挂 '+m.actId+'）');
  });
  renderL1();
  const tr=document.querySelector('#l1Table tbody tr[data-opp="'+m.opp.replace(/"/g,'\\"')+'"]');
  if(tr){ tr.classList.add('flash'); }
  return {row:tr, summary:summary, added:added};
}

$('btnSubmit').onclick=()=>{
  $('formOk').classList.remove('on');
  if(!validate()){ $('formErr').scrollIntoView({block:'center',behavior:'smooth'}); return; }
  actSeq++;
  const actId='ACT-'+todayStr()+'-'+pad3(actSeq);
  const m={
    actId:actId, dept:$('f_dept').value, cid:$('f_cid').value.trim(), cust:$('f_cust').value,
    opp:$('f_opp').value, comp:$('f_comp').value.trim(), sales:$('f_sales').value, tech:$('f_tech').value.trim(),
    scale:$('f_scale').value.trim(), date:$('f_date').value, contact:$('f_contact').value.trim(),
    order:$('f_order').value.trim(), sku:$('f_sku').value.trim(), type:$('f_type').value,
    d1:$('st1').value.trim(), d2:$('st2').value.trim(), d3:$('st3').value.trim(),
    d4:$('st4').value.trim(), d5:$('st5').value.trim(), d6:$('st6').value.trim(),
    todo:$('f_todo').value.trim(), owner:$('f_owner').value.trim(), due:$('f_due').value.trim(),
    nodePush:$('f_node').value, supportTypes: collectSupport()
  };
  const trk=buildTracks(m);
  $('tActId').textContent=m.actId;
  $('trackLocal').textContent=trk.local;
  $('trackCB').textContent=trk.cb;
  $('trackFeishu').textContent=trk.feishu;
  window.__lastFeishu=trk.feishu;
  // ① 本地记录 ② CloudBase ③ 飞书描述
  const wb=writeBackL1(m);
  activities.push(m);
  renderL2();
  $('trackModal').classList.add('open'); $('mask').classList.add('open');
  window.__lastSubmit={actId:actId, type:m.type, supportTypes:m.supportTypes,
    linked: !!wb, summary: wb? wb.summary : [], addedCols: wb? wb.added : []};
  $('formOk').textContent='已提交：'+m.actId+'（三轨落盘）'
    + (wb? '；并回写第一层：'+wb.summary.join('；') : '；未匹配到第一层商机行（未回写）');
  $('formOk').classList.add('on');
  // 重置 6 维、待办与资源协作多选
  DIMS.forEach(d=>$(d[0]).value='');
  $('f_todo').value='';
  document.querySelectorAll('#resPick input[data-res]').forEach(cb=>{ cb.checked=false; });
  $('f_othernote').value='';
  $('otherNoteWrap').style.display='none';
};

function closeModals(){ $('trackModal').classList.remove('open'); $('qcModal').classList.remove('open'); $('mask').classList.remove('open'); }
$('trackX').onclick=closeModals; $('mask').onclick=closeModals; $('qcX').onclick=closeModals;
$('btnQC').onclick=()=>{ $('qcModal').classList.add('open'); $('mask').classList.add('open'); };
$('btnToggleCB').onclick=()=>{ $('secCB').classList.toggle('collapsed'); };
$('btnCopyFeishu').onclick=()=>{
  const t=$('trackFeishu').textContent;
  const done=()=>toast('飞书描述已复制到剪贴板');
  if(navigator.clipboard && navigator.clipboard.writeText){
    navigator.clipboard.writeText(t).then(done, ()=>fallbackCopy(t, done));
  } else fallbackCopy(t, done);
};
function fallbackCopy(t, cb){
  const ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.left='-9999px';
  document.body.appendChild(ta); ta.focus(); ta.select();
  try{ document.execCommand('copy'); }catch(e){}
  document.body.removeChild(ta); cb();
}
let toastT=null;
function toast(msg){ const el=$('toast'); el.textContent=msg; el.classList.add('on');
  clearTimeout(toastT); toastT=setTimeout(()=>el.classList.remove('on'), 1800); }

/* 列/行延展 */
$('btnAddNode').onclick=()=>{ nodeSeq++; nodeCols.push('新增节点列'+nodeSeq); renderL1(); };
$('btnAddRes').onclick =()=>{ resSeq++;  resCols.push('新增资源协助列'+resSeq); renderL1(); };
$('btnAddOpp').onclick =()=>{
  const d=L1.depts[0];
  collapsed.delete(d.name);
  const nodes={}; nodeCols.forEach(n=>nodes[n]={state:'未开始'});
  d.rows.push({cid:'待关联', cust:'（新增商机示例）客户 '+(d.rows.length+1), opp:'新增商机 '+(L1.depts.reduce((a,x)=>a+x.rows.length,0)+1),
    sales:$('f_sales').value||'待分配', scale:'—', tech:'—', comp:'—', unlinked:true, nodes:nodes, res:{}});
  renderL1();
};

/* Tab 切换 */
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
  document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
  b.classList.add('on');
  $('panel-l1').style.display = b.dataset.t==='l1' ? '' : 'none';
  $('panel-l2').style.display = b.dataset.t==='l2' ? '' : 'none';
});

/* init */
initForm();
renderL1();
renderL2();
window.__demoOK = {layer:'two-layer', l1:{cols:allCols().length, rows:L1.depts.reduce((a,d)=>a+d.rows.length,0)},
                   l2rows:activities.length, actTypes:DATA.meta.actTypes, supportTypes:DATA.meta.supportTypes};
</script>
</body>
</html>
"""


def main():
    html = HTML.replace('__DATA__', json.dumps(DATA, ensure_ascii=False))
    html = html.replace('__ICONS__', json.dumps(ICONS, ensure_ascii=False))
    html = BK.inject(html, theme='light', tag='客户拉通大表 · DEMO')
    for m in re.findall(r'<svg.*?</svg>', html, re.S):
        assert '<title' not in m, 'SVG 内含 <title>：%s' % m[:60]
        assert '<br' not in m, 'SVG 内含 <br>：%s' % m[:60]
    with io.open(OUT, 'w', encoding='utf-8') as f:
        f.write(html)
    v = BK.verify(html)
    print('✅ 写入 %s（%d 字符）' % (OUT, len(html)))
    print('   品牌自检:', v)
    print('   第一层：部门 %d / 商机 %d / 节点列 %d / 资源协助列 %d'
          % (len(LAYER1_DEPTS), sum(len(d['rows']) for d in LAYER1_DEPTS), len(NODE_COLS), len(RES_COLS)))
    print('   第二层：已录入活动 %d 行 / 活动类型 %d 种' % (len(ACTIVITIES), len(ACT_TYPES)))
    print('   资源协作触发：销售手动多选 %s（服务端 type 枚举 %s）'
          % ('/'.join(SUPPORT_TYPES), '/'.join(SUPPORT_ENUM.values())))


if __name__ == '__main__':
    main()
