#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成「客户拉通大表 · 表现形式对比 DEMO」单文件页（自包含、零外链）。

目的：把用户定义的两层结构，各出 2–3 种**表现形态**，页内一键切换对比（不改定稿基线
      工具/客户拉通大表_demo.html）。

■ 第一区 · 第一层（一行一商机 · 按 4 个部门分组）—— 项目关键节点，3 种甘特形态
    形态1 里程碑轨道 ：一行一商机，8 个节点摆成圆点串（已完成为实心绿 / 进行中半实心蓝带脉冲 /
                       卡住红 / 未开始空心灰）；当前节点高亮加标签。最紧凑。
    形态2 阶段色条甘特：横轴 = 时间（月刻度），每个节点的 起→止 画色条，条长 = 耗时；
                       卡住 / 未回填 ID 用警示色；同一条按时间排列，看得出哪一环拖了多久。
    形态3 部门泳道甘特：按部门横向分泳道，行 = 商机，看部门节奏。
    → 三种形态共用同一批内嵌数据，切换即重渲染。

■ 第二区 · 第二层（点击第一层某行展开）—— 该商机全部跟进内容，按时间向右延展，2 种形态
    形态A 横向时间轴 ：活动 / 待办发起 / 部门反馈 / 闭环 / 订单，按日期从左向右排开（月份间距按真实天数拉开），
                       卡片显示 日期 + 类型色标 + 负责人 + 摘要；点击卡片展开完整内容（6 维结构化 / 反馈流水 / 待办 / 订单）。
    形态B 资源泳道矩阵：行 = 资源类型（技术支持测试项目单 / 研发项目单 / 售后 / 其他 / 活动），
                       列 = 时间（月），事件落在交叉格；空行显示「未触发」，跨部门并行与拖延一眼可见。
    交互：点第一层任意行 → 该行高亮 + 下方展开区渲染该商机；再点另一行切换；「收合」按钮收起。

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

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

# 复用基线生成器的节点集合（同一批口径）
import build_customer_thread_demo as BASE          # noqa  (仅取常量，不触发 main)

OUT = os.path.join(ROOT, '工具', '客户拉通大表_表现形式demo.html')

NODE_ORDER = list(BASE.NODE_COLS)                  # 陌生开发 / 方案匹配 / 中试 / 临床试验 / 已确认被用于注册 / 已转产 / 客户已获证 / 新证迭代
SHORT = {"陌生开发": "陌生开发", "方案匹配": "方案匹配", "中试": "中试", "临床试验": "临床",
         "已确认被用于注册": "注册", "已转产": "转产", "客户已获证": "获证", "新证迭代": "迭代"}
LANES = ["技术支持测试项目单", "研发项目单", "售后", "其他", "活动"]
TODAY = "2026-10-11"
T0, T1 = "2026-02-01", "2026-12-01"


def N(state, s=None, e=None, owner=None, unlinked=False):
    d = {"state": state}
    if s:
        d["start"] = s
    if e:
        d["end"] = e
    if owner:
        d["owner"] = owner
    if unlinked:
        d["unlinked"] = True
    return d


def EV(d, k, who, t, lane, **kw):
    e = {"d": d, "k": k, "who": who, "t": t, "lane": lane}
    e.update(kw)
    return e


SIX_SX = {
    "① 客户需求（分点）": "冻干后 37℃ 加速 7 天 Ct 漂移 ≤1；需兼容现有一步法 RT-qPCR 程序（不额外增时）。",
    "② 技术参数（货号+量化）": "MD2104 冻干保护剂 5%(W/V)；复溶后酶活回收 ≥95%；批内 CV ≤3%。",
    "③ 测试单结果跟进": "送样 5 组配方（TS-20260812-07），客户回传 3 组：A/B 组通过，C 组加速后 Ct 漂移 1.4（超阈）。",
    "④ 问题现象（含客户原话）": "C 组第 6 天曲线抬不起来；客户原话：「怀疑保护剂析晶」。",
    "⑤ 客户反馈": "认可 A/B 组，要求就 C 组补一批调整海藻糖配比后复测。",
    "⑥ 竞对信息（品牌+量化）": "竞品（品牌A）同条件 Ct 漂移 0.9；我方 A 组 0.6，已优于竞品。",
}

OPPS = [
    # ── 大客户部 ─────────────────────────────────────────────
    {"dept": "大客户部", "cid": "6878118191", "cust": "圣湘生物科技股份有限公司",
     "opp": "多重呼吸道病原体冻干体系开发", "sales": "赵云浩", "scale": "¥1,200,000", "tech": "李工",
     "nodes": {"陌生开发": N("已完成", "2026-02-10", "2026-03-05", "赵云浩"),
               "方案匹配": N("已完成", "2026-03-10", "2026-05-12", "赵云浩"),
               "中试": N("已完成", "2026-06-01", "2026-08-10", "王工"),
               "临床试验": N("进行中", "2026-09-01", None, "赵云浩")},
     "events": [
         EV("2026-06-05", "活动", "赵云浩", "需求确认拜访：明确冻干后 37℃ 加速 7 天 Ct 漂移 ≤1", "活动",
            six={"① 客户需求（分点）": "冻干后 37℃ 加速 7 天 Ct 漂移 ≤1；兼容现有一歩法 RT-qPCR 程序。",
                 "② 技术参数（货号+量化）": "待定货号，先按 5%(W/V) 保护剂试配。",
                 "③ 测试单结果跟进": "尚未送样。",
                 "④ 问题现象（含客户原话）": "客户原话：「上一批加速第 7 天曲线起跳偏高」。",
                 "⑤ 客户反馈": "同意先做 3 组小样。",
                 "⑥ 竞对信息（品牌+量化）": "现用品牌A，Ct 漂移 0.9。"}),
         EV("2026-06-18", "订单", "赵云浩", "SO-20260618-011 · 货号 MD2104 · 送样 2 kg", "技术支持测试项目单",
            ord={"单号": "SO-20260618-011", "货号": "MD2104", "数量": "2 kg", "备注": "免费送样"}),
         EV("2026-06-25", "待办发起", "赵云浩", "研发按客户配方重做 C 组保护剂配比", "研发项目单",
            todo={"事项": "重做 C 组配方并附加速数据", "负责人": "王工", "时间节点": "2026-07-15", "状态": "已完成"}),
         EV("2026-08-10", "部门反馈", "王工", "研发部反馈：中试配方完成，A/B 组冻干成型良好", "研发项目单"),
         EV("2026-08-12", "活动", "李工", "技术支持送样 5 组配方并启动 37℃ 加速试验", "技术支持测试项目单"),
         EV("2026-09-15", "部门反馈", "李工", "技术支持反馈：A/B 组通过，C 组加速第 6 天 Ct 漂移 1.4（超阈）", "技术支持测试项目单"),
         EV("2026-10-08", "活动", "赵云浩", "测试结果跟进：客户回传 3 组数据，要求补做 C 组", "技术支持测试项目单",
            six=SIX_SX,
            fb=[{"who": "王工", "t": "2026-10-08", "tx": "研发已排期，10-12 前出调整后配方"},
                {"who": "李工", "t": "2026-10-09", "tx": "技术支持准备好加速箱位，随时接样"}]),
         EV("2026-10-10", "闭环", "赵云浩", "客户认可 A/B 组，C 组补做进入研发排期（本活动闭环）", "其他"),
     ]},
    {"dept": "大客户部", "cid": "6878118210", "cust": "深圳市新产业生物医学工程股份有限公司",
     "opp": "磁珠法核酸提取原料替换评估", "sales": "徐卓凡", "scale": "¥860,000", "tech": "王工",
     "nodes": {"陌生开发": N("已完成", "2026-06-15", "2026-08-30", "徐卓凡"),
               "方案匹配": N("已完成", "2026-09-01", "2026-09-20", "徐卓凡"),
               "中试": N("进行中", "2026-09-25", None, "王工")},
     "events": [
         EV("2026-06-20", "活动", "徐卓凡", "陌生拜访：客户提出磁珠结合效率对标进口需求", "活动"),
         EV("2026-07-05", "待办发起", "徐卓凡", "技术支持安排磁珠与进口品平行对比测试", "技术支持测试项目单",
            todo={"事项": "安排 3 批磁珠平行对比", "负责人": "王工", "时间节点": "2026-07-28", "状态": "已完成"}),
         EV("2026-08-01", "售后", "王工", "处理客户到货磁珠包装破损，补寄 1 批", "售后"),
         EV("2026-08-18", "订单", "徐卓凡", "SO-20260818-004 · 货号 MD2201 · 磁珠 1 kg", "技术支持测试项目单",
            ord={"单号": "SO-20260818-004", "货号": "MD2201", "数量": "1 kg", "备注": "对比测试用量"}),
         EV("2026-09-20", "部门反馈", "王工", "技术支持反馈：磁珠结合效率与进口持平（回收率 96% vs 95%）", "技术支持测试项目单"),
         EV("2026-09-28", "活动", "徐卓凡", "中试方案确认会：客户同意进入中试", "活动"),
     ]},
    # ── 销售拓展部 ───────────────────────────────────────────
    {"dept": "销售拓展部", "cid": "6878118250", "cust": "广州达安基因股份有限公司",
     "opp": "数字PCR扩增酶体系开发", "sales": "陈嘉怡", "scale": "¥540,000", "tech": "黄工",
     "nodes": {"陌生开发": N("已完成", "2026-05-01", "2026-06-20", "陈嘉怡"),
               "方案匹配": N("已完成", "2026-06-25", "2026-08-01", "陈嘉怡"),
               "中试": N("进行中", "2026-08-05", None, "黄工")},
     "events": [
         EV("2026-06-25", "活动", "陈嘉怡", "方案沟通会：确定数字 PCR 高保真酶体系指标", "活动"),
         EV("2026-07-10", "待办发起", "陈嘉怡", "研发提供高保真酶小样用于体系搭建", "研发项目单",
            todo={"事项": "提供 1E102 小样 500 U", "负责人": "黄工", "时间节点": "2026-07-25", "状态": "已完成"}),
         EV("2026-08-01", "订单", "陈嘉怡", "SO-20260801-002 · 货号 1E102 · 高保真酶 500 U", "技术支持测试项目单",
            ord={"单号": "SO-20260801-002", "货号": "1E102", "数量": "500 U", "备注": "中试用量"}),
         EV("2026-08-05", "活动", "黄工", "启动数字 PCR 体系验证测试", "技术支持测试项目单"),
         EV("2026-09-10", "部门反馈", "黄工", "技术支持反馈：扩增效率 98%，曲线均一性良好", "技术支持测试项目单"),
         EV("2026-09-25", "其他", "陈嘉怡", "客户要求提供该体系的定制化验证报告", "其他"),
         EV("2026-09-30", "活动", "陈嘉怡", "中试跟进：关注微滴分离度与假阳性率", "活动"),
     ]},
    {"dept": "销售拓展部", "cid": "待关联", "cust": "上海纳全生物医学科技有限公司",
     "opp": "RT单管全预混体系开发", "sales": "张文蝶", "scale": "¥320,000", "tech": "张工",
     "nodes": {"方案匹配": N("已完成", "2026-09-01", "2026-09-05", "张文蝶"),
               "中试": N("卡住", "2026-09-20", None, "张工", unlinked=True)},
     "events": [
         EV("2026-09-05", "活动", "张文蝶", "方案匹配完成：确定全预混体系配方路线", "活动"),
         EV("2026-09-08", "活动", "张工", "技术支持送样：全预混体系 3 组", "技术支持测试项目单"),
         EV("2026-09-20", "待办发起", "张文蝶", "研发调整全预混体系配方（37℃ 加速不稳定）", "研发项目单",
            todo={"事项": "调整全预混配方稳定性", "负责人": "张工", "时间节点": "2026-10-15", "状态": "进行中"}),
         EV("2026-09-30", "部门反馈", "张工", "技术支持反馈：37℃ 加速 5 天酶活衰减 12%，等待研发新配方", "技术支持测试项目单"),
         EV("2026-10-08", "其他", "张文蝶", "客户连续两周未回复，商机停滞；客户编码待关联", "其他"),
     ]},
    # ── 生物制药销售部 ───────────────────────────────────────
    {"dept": "生物制药销售部", "cid": "6878118301", "cust": "上海复宏汉霖生物技术股份有限公司",
     "opp": "质控用酶原料导入验证", "sales": "周敏", "scale": "¥450,000", "tech": "赵工",
     "nodes": {"陌生开发": N("已完成", "2026-06-01", "2026-09-02", "周敏"),
               "方案匹配": N("进行中", "2026-09-25", None, "周敏")},
     "events": [
         EV("2026-06-05", "活动", "周敏", "陌生拜访：客户质控部门提出酶原料国产化诉求", "活动"),
         EV("2026-07-20", "待办发起", "周敏", "研发评估质控酶在客户体系中的适用性", "研发项目单",
            todo={"事项": "评估质控酶活与批间差", "负责人": "赵工", "时间节点": "2026-08-30", "状态": "已完成"}),
         EV("2026-09-02", "部门反馈", "赵工", "研发反馈：质控酶活满足客户内控标准", "研发项目单"),
         EV("2026-09-25", "活动", "周敏", "方案匹配启动：对接客户 QA 与采购", "活动"),
         EV("2026-10-05", "订单", "周敏", "SO-20261005-009 · 货号 1D303 · 质控酶 200 U", "技术支持测试项目单",
            ord={"单号": "SO-20261005-009", "货号": "1D303", "数量": "200 U", "备注": "验证用量"}),
     ]},
    {"dept": "生物制药销售部", "cid": "6878118312", "cust": "百济神州（广州）生物科技有限公司",
     "opp": "生物药QC残留DNA检测酶导入", "sales": "孙立", "scale": "¥600,000", "tech": "李工",
     "nodes": {"陌生开发": N("已完成", "2026-04-10", "2026-05-30", "孙立"),
               "方案匹配": N("已完成", "2026-06-01", "2026-08-15", "孙立"),
               "中试": N("已完成", "2026-08-20", "2026-09-28", "李工"),
               "临床试验": N("进行中", "2026-10-01", None, "孙立")},
     "events": [
         EV("2026-04-15", "活动", "孙立", "陌生开发拜访：QC 残留 DNA 检测方法学导入", "活动"),
         EV("2026-06-10", "待办发起", "孙立", "技术支持做残留 DNA 检测限对比", "技术支持测试项目单",
            todo={"事项": "对比检测限与回收率", "负责人": "李工", "时间节点": "2026-07-30", "状态": "已完成"}),
         EV("2026-08-15", "部门反馈", "李工", "技术支持反馈：检测限 0.1 pg/µL，回收率 92%", "技术支持测试项目单"),
         EV("2026-09-28", "活动", "孙立", "中试完成评审：客户认可数据", "活动"),
         EV("2026-10-01", "活动", "孙立", "临床试验阶段启动（客户送样进入方法学验证）", "活动"),
         EV("2026-10-09", "售后", "李工", "客户反馈运输温控异常，已安排补寄 1 批", "售后"),
     ]},
    # ── 国际业务部 ───────────────────────────────────────────
    {"dept": "国际业务部", "cid": "待关联", "cust": "海外经销商 BioSun Trading（示例）",
     "opp": "核酸提取试剂盒 OEM 询价", "sales": "Kevin", "scale": "US$60,000", "tech": "李工",
     "nodes": {"陌生开发": N("已完成", "2026-09-15", "2026-09-18", "Kevin"),
               "方案匹配": N("进行中", "2026-09-25", None, "Kevin")},
     "events": [
         EV("2026-09-18", "活动", "Kevin", "询价邮件 + 视频会议：确认 OEM 试剂盒规格", "活动"),
         EV("2026-09-25", "待办发起", "Kevin", "国际业务准备 OEM 报价与技术规格书", "其他",
            todo={"事项": "出 OEM 报价单 + 规格书", "负责人": "Kevin", "时间节点": "2026-10-10", "状态": "进行中"}),
         EV("2026-10-06", "活动", "Kevin", "方案匹配：发送技术规格书，等待客户反馈", "活动"),
     ]},
    {"dept": "国际业务部", "cid": "待关联", "cust": "NordiLab Diagnostics（示例）",
     "opp": "RT-qPCR 全预混体系 OEM 询价", "sales": "Kevin", "scale": "US$95,000", "tech": "李工",
     "nodes": {"陌生开发": N("已完成", "2026-07-01", "2026-08-20", "Kevin", unlinked=True),
               "方案匹配": N("卡住", "2026-09-01", None, "Kevin")},
     "events": [
         EV("2026-07-01", "活动", "Kevin", "首次接触：客户明确全预混 OEM 需求", "活动"),
         EV("2026-08-20", "部门反馈", "Kevin", "陌生开发完成：客户进入供应商名单（客户编码待关联）", "其他"),
         EV("2026-08-25", "待办发起", "Kevin", "研发提供全预混样品用于客户内部测试", "研发项目单",
            todo={"事项": "寄送全预混样品 2 套", "负责人": "李工", "时间节点": "2026-09-15", "状态": "已完成"}),
         EV("2026-09-01", "活动", "Kevin", "方案匹配启动：对接客户采购与技术", "活动"),
         EV("2026-09-20", "其他", "Kevin", "客户方采购负责人变动，方案匹配停滞（卡住）", "其他"),
     ]},
]

DATA = {
    "nodes": NODE_ORDER,
    "short": SHORT,
    "lanes": LANES,
    "today": TODAY,
    "t0": T0,
    "t1": T1,
    "depts": ["大客户部", "销售拓展部", "生物制药销售部", "国际业务部"],
    "opps": OPPS,
}

HINTS = {
    "1": "形态1 · 里程碑轨道：把 8 个节点当「进度刻度」排成圆点串，一眼看进展到第几环；最省横向空间，适合管理例会快速过一盘。",
    "2": "形态2 · 阶段色条甘特：横轴是日历，条长 = 该阶段实际耗时；节点之间留白越大 = 拖延越久，适合复盘「哪一环卡了多久」。",
    "3": "形态3 · 部门泳道甘特：按 4 个部门分泳道横排，看部门节奏差异与资源错峰，适合部门负责人横向对标。",
}

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;
  --done:#16A34A; --doing:#1852B6; --stuck:#C0392B; --todo:#B9C2CE;
  --warn-bg:#FEF3C7; --warn-bd:#F59E0B; --warn-ink:#92400E;
}
*{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:2080px;margin:0 auto;padding:16px 20px 40px}
h1{font-size:21px;margin:0;font-weight:800}
h2{font-size:15px;margin:0;font-weight:800;color:var(--pri-d)}
.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}

.sec{background:#fff;border:1px solid var(--line);border-radius:14px;padding:14px 16px 18px;margin-top:14px;
  box-shadow:0 1px 3px rgba(16,42,82,.05)}
.sec-hd{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:10px}
.sec-hd .sp{flex:1}
.switcher{display:flex;gap:6px;flex-wrap:wrap}
.sw{border:1px solid var(--line);background:#eef3fb;color:var(--sub);border-radius:9px;padding:8px 14px;
  font-size:12.5px;font-weight:700;cursor:pointer;transition:.14s}
.sw:hover{color:var(--pri);border-color:var(--pri)}
.sw.on{background:var(--pri);color:#fff;border-color:var(--pri);box-shadow:0 1px 4px rgba(24,82,182,.32)}
.sw.close{background:#fff;color:var(--stuck);border-color:#f3b6b0;margin-left:6px}
.sw.close:hover{background:#fdeeee}

.legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;background:#f7faff;border:1px dashed #b9cbe8;
  border-radius:10px;padding:8px 13px;margin-bottom:12px;font-size:12px;color:#33465f;font-weight:600}
.legend .it{display:inline-flex;align-items:center;gap:6px}
.legend .k{width:12px;height:12px;border-radius:50%;display:inline-block;border:2px solid transparent}
.legend .k.done{background:var(--done)}
.legend .k.doing{background:var(--doing);box-shadow:0 0 0 4px rgba(24,82,182,.22)}
.legend .k.stuck{background:var(--stuck)}
.legend .k.todo{background:#fff;border-color:var(--todo)}
.legend .swatch{width:22px;height:11px;border-radius:3px;display:inline-block}

.gantt-box{overflow:auto;border:1px solid var(--line);border-radius:12px;background:#fff;
  width:fit-content;max-width:100%}
.gantt-box svg{display:block}
.hint{font-size:12px;color:var(--sub);margin-top:9px;line-height:1.7;background:#f7faff;
  border-left:4px solid var(--pri);border-radius:0 8px 8px 0;padding:7px 12px}

/* SVG 文本 */
text.ax{font-size:11.5px;font-weight:700;fill:#33465f;font-family:inherit}
text.deptname{font-size:12.5px;font-weight:800;fill:#0d3a85;font-family:inherit}
text.cust{font-size:12px;font-weight:700;fill:#1a2434;font-family:inherit}
text.opp{font-size:10.5px;fill:#5b6b82;font-family:inherit}
text.mlbl{font-size:11px;font-weight:700;fill:#5b6b82;font-family:inherit}
text.bar{font-size:10px;font-weight:700;fill:#fff;font-family:inherit}
text.bar.alt{fill:#0d3a85}
text.curlab{font-size:10.5px;font-weight:800;font-family:inherit}
text.lane{font-size:12px;font-weight:800;fill:#0d3a85;font-family:inherit}
line.rail{stroke:#dbe3ee;stroke-width:2}
line.grid{stroke:#eef2f8;stroke-width:1}
line.today{stroke:#F59E0B;stroke-width:1.6;stroke-dasharray:4 3}
circle.halo{opacity:.28;transform-box:fill-box;transform-origin:center;animation:haloP 1.6s ease-out infinite}
@keyframes haloP{0%{transform:scale(.75);opacity:.42}70%{transform:scale(1.5);opacity:0}100%{opacity:0}}
circle.curring{animation:ringP 1.8s ease-in-out infinite}
@keyframes ringP{50%{stroke-opacity:.25}}
rect.rowhit{cursor:pointer}
rect.rowhit:hover{fill:#f2f7ff}
rect.rowhit.sel{fill:#e8f1fe}
rect.bar{shape-rendering:crispEdges}

/* 展开区 */
.expand-box{border:1px solid var(--line);border-radius:12px;background:#fbfdff;min-height:120px;padding:0}
.ph{padding:44px 20px;text-align:center;color:var(--mut);font-size:13.5px}
.ex-head{background:linear-gradient(90deg,#eaf1fd,#f8fbff);border-bottom:1px solid var(--line);
  padding:11px 15px;border-radius:12px 12px 0 0}
.ex-title{font-size:14.5px;font-weight:800;color:var(--pri-d)}
.ex-meta{font-size:12px;color:var(--sub);margin-top:4px}
.ex-kpis{font-size:11.5px;color:var(--pri);margin-top:4px;font-weight:700}

/* 形态A 横向时间轴 */
.tl-wrap{padding:14px 15px 18px;overflow-x:auto}
.tl-dir{font-size:11.5px;color:var(--mut);margin-bottom:10px;font-weight:700}
.tl-track{display:flex;align-items:flex-start;min-height:230px;padding-bottom:6px}
.tl-gap{flex:0 0 auto;height:2px;background:linear-gradient(90deg,#c9d8ee,#9db9e2);margin-top:74px;border-radius:2px}
.ev{flex:0 0 auto;width:216px;background:#fff;border:1px solid var(--line);border-left-width:4px;
  border-radius:10px;padding:9px 11px;cursor:pointer;transition:.15s;box-shadow:0 1px 3px rgba(16,42,82,.05)}
.ev:hover{border-color:#b9cbe8;box-shadow:0 3px 10px rgba(16,42,82,.10)}
.ev.sel{border-color:var(--pri);box-shadow:0 0 0 3px rgba(24,82,182,.22)}
.ev .ev-top{display:flex;align-items:center;justify-content:space-between;gap:6px}
.ev .ev-date{font-size:11.5px;font-weight:800;color:var(--pri-d);font-variant-numeric:tabular-nums}
.ev .ev-kind{font-size:10px;font-weight:800;border-radius:5px;padding:1px 7px;color:#fff}
.ev .ev-who{font-size:11px;color:var(--sub);margin-top:5px;font-weight:600}
.ev .ev-t{font-size:12px;color:var(--ink);margin-top:4px;line-height:1.55}
.ev .ev-more{font-size:10.5px;color:var(--mut);margin-top:7px;font-weight:700}
.ev.sel .ev-more{color:var(--pri)}
.tl-detail{margin-top:14px;border:1px solid var(--line);border-radius:10px;background:#fff;
  padding:12px 14px;min-height:64px;box-shadow:0 1px 3px rgba(16,42,82,.05)}
.tl-dhint{color:var(--mut);font-size:12.5px;font-weight:600}
.tl-dhead{font-size:13px;font-weight:800;color:var(--pri-d);display:flex;align-items:center;gap:9px;
  padding-bottom:8px;margin-bottom:8px;border-bottom:1px dashed var(--line)}
.tl-dhead .ev-kind{font-size:10.5px;font-weight:800;border-radius:5px;padding:2px 9px;color:#fff}
.tl-dbody{font-size:12px;color:#33465f;line-height:1.75}
.ev-detail b,.tl-dbody b{color:var(--pri-d)}
.kv{margin:2px 0}
.fbline{background:#f7faff;border-radius:6px;padding:5px 8px;margin-top:5px}
.ev.k-act{border-left-color:var(--doing)}   .ev.k-act .ev-kind{background:var(--doing)}
.ev.k-todo{border-left-color:#7C3AED}       .ev.k-todo .ev-kind{background:#7C3AED}
.ev.k-fb{border-left-color:#0891B2}         .ev.k-fb .ev-kind{background:#0891B2}
.ev.k-close{border-left-color:var(--done)}  .ev.k-close .ev-kind{background:var(--done)}
.ev.k-ord{border-left-color:#D97706}        .ev.k-ord .ev-kind{background:#D97706}

/* 形态B 资源泳道矩阵 */
.mx-wrap{padding:14px 15px 18px;overflow-x:auto}
.mx{display:grid;gap:0;min-width:760px;border:1px solid var(--line);border-radius:10px;overflow:hidden}
.mx .mx-head{background:#e6eefb;color:var(--pri-d);font-size:11.5px;font-weight:800;padding:7px 10px;
  border-bottom:1px solid var(--line);border-right:1px solid var(--line)}
.mx .mx-lane{background:#f5f8fd;color:var(--pri-d);font-size:12px;font-weight:800;padding:9px 11px;
  border-bottom:1px solid var(--line);border-right:1px solid var(--line);display:flex;align-items:center;gap:7px}
.mx .mx-lane .n{background:var(--pri);color:#fff;border-radius:9px;font-size:10px;padding:0 6px}
.mx .mx-cell{border-bottom:1px solid var(--line);border-right:1px solid var(--line);padding:6px 7px;
  min-height:44px;display:flex;flex-direction:column;gap:4px;background:#fff}
.mx .mx-empty{grid-column:1/-1;border-bottom:1px solid var(--line);padding:9px 12px;color:var(--mut);
  font-size:11.5px;font-weight:600;background:repeating-linear-gradient(45deg,#fbfcfe,#fbfcfe 8px,#f4f7fb 8px,#f4f7fb 16px)}
.mx-chip{font-size:10.5px;font-weight:700;border-radius:6px;padding:3px 7px;color:#fff;line-height:1.35;
  cursor:pointer;white-space:normal;word-break:break-all}
.mx-chip.k-act{background:var(--doing)}
.mx-chip.k-todo{background:#7C3AED}
.mx-chip.k-fb{background:#0891B2}
.mx-chip.k-close{background:var(--done)}
.mx-chip.k-ord{background:#D97706}
</style>
</head>
<body>
<div class="wrap">
  <div class="demo-bar"><span class="dot"></span>
    DEMO 示例数据 · 客户名为真实公司名，商机 / 日期 / 负责人 / 金额 / 事件内容均为<b>示例值</b>，未上线；
    本页<b>不改动</b>定稿基线《客户拉通大表_demo.html》，纯表现形态对比。
  </div>
  <div class="head-row">
    <h1>客户拉通大表 · 表现形式对比 DEMO</h1>
    <span class="sub">第一层甘特 3 形态 × 第二层时间轴 2 形态 ｜ 同一批内嵌数据，页内一键切换</span>
  </div>

  <!-- ============ 第一区 ============ -->
  <section class="sec">
    <div class="sec-hd">
      <h2>第一区 · 第一层｜项目关键节点的甘特表现形态</h2>
      <span class="sp"></span>
      <div class="switcher" id="formSwitch">
        <button class="sw on" data-f="1">形态1 · 里程碑轨道</button>
        <button class="sw" data-f="2">形态2 · 阶段色条甘特</button>
        <button class="sw" data-f="3">形态3 · 部门泳道甘特</button>
      </div>
    </div>
    <div class="legend">
      <span class="it"><i class="k done"></i>已完成</span>
      <span class="it"><i class="k doing"></i>进行中</span>
      <span class="it"><i class="k stuck"></i>卡住</span>
      <span class="it"><i class="k todo"></i>未开始</span>
      <span class="it" style="margin-left:6px">节点顺序：陌生开发 → 方案匹配 → 中试 → 临床试验 → 已确认被用于注册 → 已转产 → 客户已获证 → 新证迭代</span>
      <span class="it"><i class="swatch" style="background:var(--stuck)"></i>警示：卡住 / 未回填 ID</span>
    </div>
    <div class="gantt-box" id="ganttBox"></div>
    <div class="hint" id="formHint"></div>
  </section>

  <!-- ============ 第二区 ============ -->
  <section class="sec" id="sec2">
    <div class="sec-hd">
      <h2>第二区 · 第二层｜点击第一层任意一行 → 展开跟进内容（按时间向右延展）</h2>
      <span class="sp"></span>
      <div class="switcher" id="viewSwitch">
        <button class="sw on" data-v="A">形态A · 横向时间轴</button>
        <button class="sw" data-v="B">形态B · 资源泳道矩阵</button>
        <button class="sw close" id="btnCollapse">收合 ▴</button>
      </div>
    </div>
    <div class="expand-box" id="expandBox">
      <div class="ph">👆 点击上方甘特中的任意一行 → 此处展开该商机的全部跟进内容</div>
    </div>
  </section>
</div>

<script>
const DATA = __DATA__;
const HINTS = __HINTS__;

/* ============ 常量 ============ */
const NODES = DATA.nodes;
const SHORT = DATA.short;
const LANES = DATA.lanes;
const OPP = DATA.opps;
const TODAY = DATA.today;
const DAY = 86400000;
const STC = {'已完成':'#16A34A', '进行中':'#1852B6', '卡住':'#C0392B', '未开始':'#B9C2CE'};
const KCLS = {'活动':'k-act', '待办发起':'k-todo', '部门反馈':'k-fb', '闭环':'k-close', '订单':'k-ord'};

/* ============ 状态 ============ */
let curForm = 1;         // 第一区形态 1/2/3
let curView = 'A';       // 第二区形态 A/B
let selIdx = null;       // 选中的商机下标
let tlSel = null;        // 形态A 选中的事件下标

/* ============ 工具 ============ */
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 d2t(s){ if(!s) return null; const a=s.split('-').map(Number); return Date.UTC(a[0],a[1]-1,a[2]); }
function nodeCell(o,n){ return (o.nodes||{})[n] || null; }
function nodeState(o,n){ const c=nodeCell(o,n); return c ? c.state : '未开始'; }
function curNode(o){
  let idx=-1;
  NODES.forEach(function(n,i){ const st=nodeState(o,n); if(st==='进行中'||st==='卡住') idx=i; });
  if(idx<0) NODES.forEach(function(n,i){ if(nodeState(o,n)==='已完成') idx=i; });
  return idx;
}
function groups(){
  const out=[];
  DATA.depts.forEach(function(d){
    const rows=[];
    OPP.forEach(function(o,i){ if(o.dept===d) rows.push({o:o, i:i}); });
    if(rows.length) out.push({dept:d, rows:rows});
  });
  return out;
}
function short10(s,n){ s=String(s||''); return s.length>n ? s.slice(0,n)+'…' : s; }
/* SVG 守卫：内联 SVG 内禁 title / br 标签（会静默吞图形） */
window.__svgErr = 0;
function svgGuard(s){ if(new RegExp('<ti'+'tle[\\s>]','i').test(s) || new RegExp('<b'+'r[\\s>]','i').test(s)) window.__svgErr++; return s; }

/* ============ 第一区：甘特 3 形态（同一批数据重渲染） ============ */
const LBL = 340, TL = 900, GAP = 16;
const W = LBL + TL + GAP;
const HEADH = 46, ROWH = 34, DEPTH = 26;

function railSVG(){
  const n = NODES.length;
  const x0 = LBL + 46, x1 = LBL + TL - 46;
  const step = (x1 - x0) / (n - 1);
  let y = HEADH, out = '';
  out += '<rect x="0" y="0" width="'+W+'" height="'+HEADH+'" fill="#eef3fb"/>';
  out += '<line x1="'+LBL+'" y1="0" x2="'+LBL+'" y2="'+HEADH+'" stroke="#dbe3ee"/>';
  out += '<text x="12" y="27" class="deptname">客户 / 商机 与 销售员</text>';
  NODES.forEach(function(nd,i){
    const cx = x0 + i*step;
    out += '<text x="'+cx+'" y="27" text-anchor="middle" class="ax">'+esc(SHORT[nd])+'</text>';
  });
  out += '<text x="'+(W-10)+'" y="41" text-anchor="end" class="mlbl">节点顺序 = 流程时间顺序 →</text>';
  groups().forEach(function(g){
    out += '<rect x="0" y="'+y+'" width="'+W+'" height="'+DEPTH+'" fill="#f5f8fd"/>';
    out += '<text x="12" y="'+(y+17)+'" class="deptname">'+esc(g.dept)+'　（'+g.rows.length+' 个商机）</text>';
    y += DEPTH;
    g.rows.forEach(function(rw){
      const o = rw.o, k = rw.i, cy = y + ROWH/2, sel = (k===selIdx);
      out += '<rect class="rowhit'+(sel?' sel':'')+'" data-i="'+k+'" x="0" y="'+y+'" width="'+W+'" height="'+ROWH+'" fill="'+(sel?'#e8f1fe':'#ffffff')+'"/>';
      out += '<text x="12" y="'+(y+14)+'" class="cust">'+esc(short10(o.cust,20))+'</text>';
      out += '<text x="12" y="'+(y+28)+'" class="opp">'+esc(o.sales)+' · '+esc(short10(o.opp,22))+'</text>';
      out += '<line class="rail" x1="'+x0+'" y1="'+cy+'" x2="'+x1+'" y2="'+cy+'"/>';
      const ci = curNode(o);
      NODES.forEach(function(nd,i){
        const cx = x0 + i*step, st = nodeState(o,nd), col = STC[st];
        if(st==='进行中') out += '<circle class="halo" cx="'+cx+'" cy="'+cy+'" r="11" fill="'+col+'"/>';
        if(st==='未开始') out += '<circle cx="'+cx+'" cy="'+cy+'" r="6.5" fill="#fff" stroke="'+col+'" stroke-width="2"/>';
        else              out += '<circle cx="'+cx+'" cy="'+cy+'" r="7" fill="'+col+'" stroke="#fff" stroke-width="2"/>';
        if(st==='卡住')   out += '<circle class="curring" cx="'+cx+'" cy="'+cy+'" r="12" fill="none" stroke="'+col+'" stroke-width="2"/>';
        if(i===ci){
          out += '<circle class="curring" cx="'+cx+'" cy="'+cy+'" r="12" fill="none" stroke="#1852B6" stroke-width="2"/>';
          const lab = '当前 · '+SHORT[nd];
          const lw = lab.length*12 + 20;
          let lx = cx + 20, anchor = 'start';
          if(lx + lw > W - 12){ lx = cx - 20; anchor = 'end'; }
          out += '<g><rect x="'+(anchor==='start'? lx-10 : lx-lw+10)+'" y="'+(cy-9)+'" width="'+lw+'" height="18" rx="9" fill="#1852B6"/>'
               + '<text x="'+lx+'" y="'+(cy+4)+'" text-anchor="'+anchor+'" fill="#fff" class="curlab">'+esc(lab)+'</text></g>';
        }
      });
      y += ROWH;
    });
  });
  return svgGuard('<svg width="'+W+'" height="'+(y+8)+'" viewBox="0 0 '+W+' '+(y+8)+'" xmlns="http://www.w3.org/2000/svg">'+out+'</svg>');
}

function barSVG(swim){
  const t0 = d2t(DATA.t0), t1 = d2t(DATA.t1), tnow = d2t(TODAY);
  const TX = function(d){ return LBL + (d - t0)/(t1 - t0)*TL; };
  let y = HEADH, out = '';
  /* 月份刻度 */
  out += '<rect x="0" y="0" width="'+W+'" height="'+HEADH+'" fill="#eef3fb"/>';
  out += '<line x1="'+LBL+'" y1="0" x2="'+LBL+'" y2="'+HEADH+'" stroke="#dbe3ee"/>';
  out += '<text x="12" y="27" class="deptname">'+(swim?'部门泳道 / 商机':'客户 / 商机 与 销售员')+'</text>';
  const m0 = new Date(t0).getUTCFullYear()*12 + new Date(t0).getUTCMonth();
  const m1 = new Date(t1).getUTCFullYear()*12 + new Date(t1).getUTCMonth();
  for(let m=m0; m<=m1; m++){
    const yy = Math.floor(m/12), mm = m%12;
    const x = TX(Date.UTC(yy, mm, 1));
    out += '<line class="grid" x1="'+x+'" y1="'+HEADH+'" x2="'+x+'" y2="100000"/>';
    out += '<text x="'+(x+4)+'" y="30" class="mlbl">'+(mm+1)+'月</text>';
  }
  const xt = TX(tnow);
  out += '<line class="today" x1="'+xt+'" y1="'+HEADH+'" x2="'+xt+'" y2="100000"/>';
  out += '<text x="'+(xt+3)+'" y="42" class="mlbl" fill="#B45309">今天 '+TODAY.slice(5)+'</text>';

  function barRow(o, k, ytop){
    const cy = ytop + ROWH/2;
    for(let i=0;i<NODES.length;i++){
      const nd = NODES[i], c = nodeCell(o, nd);
      if(!c || !c.start) continue;
      const xa = TX(d2t(c.start));
      let e;
      if(c.end) e = d2t(c.end);
      else if(c.state==='进行中' || c.state==='卡住') e = tnow;   /* 未收口：条延伸到「今天」 */
      else e = d2t(c.start) + 4*DAY;
      const xb = Math.max(TX(e), xa + 5), bw = xb - xa;
      const col = STC[c.state];
      const stroke = c.unlinked ? ' stroke="#C0392B" stroke-width="2" stroke-dasharray="4 3"' : '';
      out += '<rect class="bar" x="'+xa+'" y="'+(cy-8)+'" width="'+bw+'" height="16" rx="3" fill="'+col+'"'+stroke+'/>';
      if(bw > 34) out += '<text class="bar" x="'+(xa+7)+'" y="'+(cy+4)+'">'+esc(SHORT[nd])+'</text>';
      if(c.unlinked) out += '<text x="'+(xa+1)+'" y="'+(cy-11)+'" class="mlbl" fill="#C0392B">待关联 ID</text>';
    }
  }

  groups().forEach(function(g){
    if(swim){
      const yStart = y;
      out += '<rect x="0" y="'+y+'" width="'+W+'" height="'+DEPTH+'" fill="#e3ebf7"/>';
      out += '<text x="12" y="'+(y+17)+'" class="lane">泳道：'+esc(g.dept)+'</text>';
      y += DEPTH;
      g.rows.forEach(function(rw){
        const o = rw.o, k = rw.i, sel = (k===selIdx);
        out += '<rect x="0" y="'+y+'" width="'+LBL+'" height="'+ROWH+'" fill="'+(sel?'#dbe8fb':'#f8fbff')+'"/>';
        out += '<rect x="0" y="'+y+'" width="4" height="'+ROWH+'" fill="#1852B6"/>';
        out += '<rect class="rowhit'+(sel?' sel':'')+'" data-i="'+k+'" x="0" y="'+y+'" width="'+W+'" height="'+ROWH+'" fill="'+'none'+'"/>';
        out += '<text x="14" y="'+(y+14)+'" class="cust">'+esc(short10(o.cust,20))+'</text>';
        out += '<text x="14" y="'+(y+28)+'" class="opp">'+esc(o.sales)+' · '+esc(short10(o.opp,22))+'</text>';
        barRow(o, k, y);
        y += ROWH;
      });
      out += '<rect x="0" y="'+yStart+'" width="'+W+'" height="'+(y-yStart)+'" fill="none" stroke="#dbe3ee"/>';
    } else {
      out += '<rect x="0" y="'+y+'" width="'+W+'" height="'+DEPTH+'" fill="#f5f8fd"/>';
      out += '<text x="12" y="'+(y+17)+'" class="deptname">'+esc(g.dept)+'　（'+g.rows.length+' 个商机）</text>';
      y += DEPTH;
      g.rows.forEach(function(rw){
        const o = rw.o, k = rw.i, sel = (k===selIdx);
        out += '<rect class="rowhit'+(sel?' sel':'')+'" data-i="'+k+'" x="0" y="'+y+'" width="'+W+'" height="'+ROWH+'" fill="'+(sel?'#e8f1fe':'#ffffff')+'"/>';
        out += '<text x="12" y="'+(y+14)+'" class="cust">'+esc(short10(o.cust,20))+'</text>';
        out += '<text x="12" y="'+(y+28)+'" class="opp">'+esc(o.sales)+' · '+esc(short10(o.opp,22))+'</text>';
        barRow(o, k, y);
        y += ROWH;
      });
    }
  });
  return svgGuard('<svg width="'+W+'" height="'+(y+8)+'" viewBox="0 0 '+W+' '+(y+8)+'" xmlns="http://www.w3.org/2000/svg">'+out+'</svg>');
}

function renderGantt(){
  const svg = (curForm===1) ? railSVG() : barSVG(curForm===3);
  $('ganttBox').innerHTML = svg;
  $('formHint').textContent = HINTS[String(curForm)];
  $('ganttBox').querySelectorAll('rect.rowhit').forEach(function(r){
    r.onclick = function(){ selectOpp(parseInt(r.getAttribute('data-i'),10)); };
  });
  window.__formInfo = {form:curForm,
    svgNodes:(curForm===1) ? $('ganttBox').querySelectorAll('circle').length
                           : $('ganttBox').querySelectorAll('rect.bar').length,
    rows:$('ganttBox').querySelectorAll('rect.rowhit').length};
}

/* ============ 第二区：展开（两种形态） ============ */
function kcls(k){ return KCLS[k] || 'k-act'; }
function detailHTML(e){
  let h = '';
  h += '<div class="kv"><b>资源泳道：</b>'+esc(e.lane)+'　｜　<b>负责人：</b>'+esc(e.who)+'</div>';
  if(e.six){ Object.keys(e.six).forEach(function(k2){ h += '<div class="kv"><b>'+esc(k2)+'：</b>'+esc(e.six[k2])+'</div>'; }); }
  if(e.todo){ h += '<div class="kv"><b>待办事项：</b>'+esc(e.todo['事项'])+'</div>'
    + '<div class="kv"><b>负责人 / 时间节点 / 状态：</b>'+esc(e.todo['负责人'])+' / '+esc(e.todo['时间节点'])+' / '+esc(e.todo['状态'])+'</div>'; }
  if(e.ord){ h += '<div class="kv"><b>关联订单：</b>'+esc(e.ord['单号'])+'　<b>货号：</b>'+esc(e.ord['货号'])
    + '　<b>数量：</b>'+esc(e.ord['数量'])+'　<b>备注：</b>'+esc(e.ord['备注'])+'</div>'; }
  if(e.fb){ h += '<div class="kv"><b>反馈流水：</b></div>';
    e.fb.forEach(function(f){ h += '<div class="fbline">'+esc(f.t)+' · '+esc(f.who)+'：'+esc(f.tx)+'</div>'; }); }
  if(!e.six && !e.todo && !e.ord && !e.fb) h += '<div class="kv">（本条为单点记录，无附加结构化内容）</div>';
  return h;
}
function timelineHTML(o){
  const evs = o.events.slice().sort(function(a,b){ return d2t(a.d)-d2t(b.d); });
  let h = '<div class="tl-wrap"><div class="tl-dir">时间轴方向 →（左早右晚；卡片间距按真实天差拉开）｜ 点击任意事件卡 → 下方显示完整内容</div><div class="tl-track">';
  evs.forEach(function(e,i){
    if(i>0){
      const g = Math.max(8, Math.min(90, (d2t(e.d)-d2t(evs[i-1].d))/DAY*2.4));
      h += '<div class="tl-gap" style="flex:0 0 '+g+'px"></div>';
    }
    h += '<div class="ev '+kcls(e.k)+(i===tlSel?' sel':'')+'" data-e="'+i+'">'
      + '<div class="ev-top"><span class="ev-date">'+esc(e.d)+'</span><span class="ev-kind">'+esc(e.k)+'</span></div>'
      + '<div class="ev-who">负责人：'+esc(e.who)+'</div>'
      + '<div class="ev-t">'+esc(e.t)+'</div>'
      + '<div class="ev-more">'+(i===tlSel?'▼ 已展开完整内容':'点击查看完整内容 ▸')+'</div></div>';
  });
  h += '</div><div class="tl-detail" id="tlDetail"></div></div>';
  return h;
}
function renderTimelineDetail(o){
  const box = $('tlDetail');
  if(!box) return;
  const evs = o.events.slice().sort(function(a,b){ return d2t(a.d)-d2t(b.d); });
  if(tlSel==null){
    box.innerHTML = '<div class="tl-dhint">👆 点击上方任意事件卡 → 此处显示该事件的完整内容（6 维结构化 / 反馈流水 / 待办 / 订单）</div>';
    return;
  }
  const e = evs[tlSel];
  box.innerHTML = '<div class="tl-dhead"><span class="ev-kind '+kcls(e.k)+'">'+esc(e.k)+'</span>'
    + esc(e.d)+' · 负责人 '+esc(e.who)+'</div><div class="tl-dbody">'+detailHTML(e)+'</div>';
}
function oppMonths(o){
  const set = {};
  o.events.forEach(function(e){ set[e.d.slice(0,7)] = 1; });
  return Object.keys(set).sort();
}
function matrixHTML(o){
  const ms = oppMonths(o);
  let h = '<div class="mx-wrap"><div class="mx" style="grid-template-columns:170px repeat('+ms.length+',minmax(0,1fr))">';
  h += '<div class="mx-head">资源类型 ＼ 时间</div>';
  ms.forEach(function(m){ h += '<div class="mx-head">'+esc(m.slice(2).replace('-','/'))+'</div>'; });
  LANES.forEach(function(lane){
    const evs = o.events.filter(function(e){ return e.lane===lane; });
    if(!evs.length){
      h += '<div class="mx-lane">'+esc(lane)+'</div>';
      h += '<div class="mx-empty" style="grid-column:span '+ms.length+'">未触发（本商机未触发该资源协作 / 活动）</div>';
      return;
    }
    h += '<div class="mx-lane">'+esc(lane)+'<span class="n">'+evs.length+'</span></div>';
    ms.forEach(function(m){
      const cell = evs.filter(function(e){ return e.d.slice(0,7)===m; });
      h += '<div class="mx-cell">';
      cell.forEach(function(e){
        h += '<span class="mx-chip '+kcls(e.k)+'" title="'+esc(e.d+' '+e.who+' '+e.t)+'">'
          + esc(e.d.slice(5))+' '+esc(e.k)+'：'+esc(short10(e.t,16))+'</span>';
      });
      h += '</div>';
    });
  });
  h += '</div></div>';
  return h;
}
function renderExpand(){
  const box = $('expandBox');
  if(selIdx==null){
    box.innerHTML = '<div class="ph">👆 点击上方甘特中的任意一行 → 此处展开该商机的全部跟进内容</div>';
    window.__expandInfo = {sel:null};
    return;
  }
  const o = OPP[selIdx];
  const evs = o.events.slice().sort(function(a,b){ return d2t(a.d)-d2t(b.d); });
  const head = '<div class="ex-head">'
    + '<div class="ex-title">'+esc(o.cust)+' · '+esc(o.opp)+'</div>'
    + '<div class="ex-meta">'+esc(o.dept)+' ｜ 销售员 '+esc(o.sales)+' ｜ 商机规模 '+esc(o.scale)
    + ' ｜ 技术支持 '+esc(o.tech)+' ｜ 客户编码 '+esc(o.cid)+'</div>'
    + '<div class="ex-kpis">共 '+evs.length+' 条跟进事件 · 首 '+evs[0].d+' · 末 '+evs[evs.length-1].d
    + ' ｜ 当前形态：'+(curView==='A'?'A · 横向时间轴':'B · 资源泳道矩阵')+'</div></div>';
  box.innerHTML = head + (curView==='A' ? timelineHTML(o) : matrixHTML(o));
  if(curView==='A'){
    renderTimelineDetail(o);
    box.querySelectorAll('.ev').forEach(function(el){
      el.onclick = function(){
        tlSel = parseInt(el.getAttribute('data-e'),10);
        box.querySelectorAll('.ev').forEach(function(x){ x.classList.remove('sel'); });
        el.classList.add('sel');
        renderTimelineDetail(o);
        var m = el.querySelector('.ev-more'); if(m) m.textContent = '▼ 已展开完整内容';
      };
    });
  }
  window.__expandInfo = {sel:selIdx, view:curView, cust:o.cust,
    events:box.querySelectorAll('.ev').length,
    chips:box.querySelectorAll('.mx-chip').length,
    detailOpen: (curView==='A' && tlSel!=null)};
}
function selectOpp(i){
  selIdx = i;
  tlSel = null;
  renderGantt();
  renderExpand();
  $('sec2').scrollIntoView({behavior:'smooth', block:'nearest'});
}

/* ============ 交互绑定 ============ */
$('formSwitch').querySelectorAll('.sw').forEach(function(b){
  b.onclick = function(){
    $('formSwitch').querySelectorAll('.sw').forEach(function(x){ x.classList.remove('on'); });
    b.classList.add('on');
    curForm = parseInt(b.getAttribute('data-f'),10);
    renderGantt();
  };
});
$('viewSwitch').querySelectorAll('.sw[data-v]').forEach(function(b){
  b.onclick = function(){
    $('viewSwitch').querySelectorAll('.sw[data-v]').forEach(function(x){ x.classList.remove('on'); });
    b.classList.add('on');
    curView = b.getAttribute('data-v');
    renderExpand();
  };
});
$('btnCollapse').onclick = function(){ selIdx = null; renderGantt(); renderExpand(); };

/* init */
renderGantt();
renderExpand();
window.__demoOK = {
  forms:[1,2,3], views:['A','B'],
  opps:OPP.length, nodes:NODES.length, lanes:LANES.length,
  events:OPP.reduce(function(a,o){ return a+o.events.length; },0),
  depts:DATA.depts
};
window.__svgGuard = function(){ return window.__svgErr === 0; };
</script>
</body>
</html>
"""


def main():
    html = HTML.replace('__DATA__', json.dumps(DATA, ensure_ascii=False))
    html = html.replace('__HINTS__', json.dumps(HINTS, ensure_ascii=False))
    html = BK.inject(html, theme='light', tag='客户拉通大表 · 表现形式 DEMO')

    # 静态守卫：内联 SVG 禁 <title>/<br>（本页 SVG 由 JS 构建 → 校验 JS 模板串）
    assert html.count('<title') == 1, '出现多余 <title>（SVG 内禁 <title>）'
    assert '<title>' in html and '客户拉通大表 · 表现形式对比 DEMO' in html, '文档标题缺失'
    for pat in ('<title', '<br></br>'):
        assert pat not in HTML.replace('<title>客户拉通大表 · 表现形式对比 DEMO（示例数据）</title>', ''), \
            '模板含非法 SVG 片段：%s' % pat

    with io.open(OUT, 'w', encoding='utf-8') as f:
        f.write(html)
    v = BK.verify(html)
    print('✅ 写入 %s（%d 字符 / %.1f KB）' % (OUT, len(html), len(html.encode('utf-8'))/1024.0))
    print('   品牌自检:', v)
    print('   第一区：部门 %d / 商机 %d / 节点 %d → 3 种甘特形态'
          % (len(DATA['depts']), len(OPPS), len(NODE_ORDER)))
    print('   第二区：资源泳道 %d 类 / 事件合计 %d 条 → 2 种时间轴形态'
          % (len(LANES), sum(len(o['events']) for o in OPPS)))
    for o in OPPS:
        print('     · %-10s %-22s %s 事件 %d 条' % (o['dept'], o['cust'][:12], o['sales'], len(o['events'])))


if __name__ == '__main__':
    main()
