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

用户 2026-10-11 选定：「第一层用形态1（里程碑轨道），第二层用形态B（资源泳道矩阵）」。
本生成器把两处表现合并进**定稿基线**：

■ 第一层（Tab① 第一层｜项目推移表）—— 视图可切换
    · **默认 = 轨道视图（形态1 里程碑轨道）**：一行一商机，8 个关键节点排成圆点串
      （已完成实心绿 / 进行中半实心蓝带脉冲 / 卡住红 / 未开始空心灰），当前节点高亮加标签；
      按 4 个部门分组（行头可折叠）；**点行 → 下方展开区渲染第二层**。
    · **表格视图**：切回原三层表头宽表（项目基本信息 / 项目关键节点 / 资源协助），
      左侧信息列冻结、行头可折叠、「＋新增商机 / ＋新增节点列 / ＋新增资源协助列」照旧。

■ 第二层（点击第一层某行 → 展开区）—— **资源泳道矩阵（形态B）**
    行 = 资源类型（技术支持测试项目单 / 研发项目单 / 售后 / 其他 / 活动，共 5 条泳道），
    列 = 时间（月份），事件落在交叉格，跨月向右延展；空泳道显示「未触发」。

■ Tab② 第二层｜活动录入与待办 —— 原样保留：
    活动录入表单（抓取第一层项目基本信息 + 活动信息更新 + 待办事项）、
    「触发的资源协作（多选）」四类手动多选、6 维结构化校验、三轨落盘（本地 / CloudBase / 飞书描述）、
    已录入活动三层表头列表、「待关联」黄标。

数据统一来自 `脚本/ct_data.py`（与经营仪表盘同一批示例数据）。
硬约束：零外部依赖（禁 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 ct_data as CT                               # noqa  （唯一数据源）

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

# 向后兼容：表现形式 demo 生成器会 `import build_customer_thread_demo as BASE` 取本常量
BASE_COLS = CT.BASE_COLS
NODE_COLS = CT.NODE_COLS
RES_COLS = CT.RES_COLS
ACT_TYPES = CT.ACT_TYPES
SUPPORT_TYPES = CT.SUPPORT_TYPES
SUPPORT_ENUM = CT.SUPPORT_ENUM
NODES = CT.NODE_COLS

DATA = CT.data()

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;
  --done:#16A34A; --doing:#1852B6; --stuck:#C0392B; --todo:#B9C2CE;
  --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:1900px;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)}
/* 视图切换 */
.vswitch{display:flex;gap:6px}
.vb{border:1px solid var(--line);background:#eef3fb;color:var(--sub);border-radius:9px;padding:8px 15px;
  font-size:12.5px;font-weight:700;cursor:pointer;transition:.14s}
.vb:hover{color:var(--pri);border-color:var(--pri)}
.vb.on{background:var(--pri);color:#fff;border-color:var(--pri);box-shadow:0 1px 4px rgba(24,82,182,.32)}
.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)}
.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}

/* 表格 */
.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}

/* ── 轨道视图 SVG 文本 ── */
text.railax{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.curlab{font-size:10.5px;font-weight:800;font-family:inherit}
line.rail{stroke:#dbe3ee;stroke-width:2}
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}

/* ── 第二层 资源泳道矩阵 ── */
.expand-box{border:1px solid var(--line);border-radius:12px;background:#fbfdff;min-height:110px;margin-top:12px}
.ph{padding:40px 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}
.mx-wrap{padding:14px 15px 18px;overflow-x:auto}
.mx{display:grid;gap:0;min-width:720px;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;
  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}

/* ── 周会决议（三岗各填一栏）── */
.wr-note{font-size:11.5px;color:var(--sub);margin:0 0 10px;line-height:1.75;background:#f7faff;
  border:1px dashed #b9cbe8;border-radius:10px;padding:9px 13px}
.wr-note code{background:#eef3fb;color:var(--pri-d);border-radius:4px;padding:0 4px;
  font-family:"SF Mono",Menlo,Consolas,monospace;font-size:11px}
.wr-admin{margin:0 0 10px}
.wr-admin .chk{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;font-weight:700;color:#33465f;cursor:pointer}
.wr-admin .chk input[type=checkbox]{width:15px;height:15px;accent-color:var(--pri);cursor:pointer}
.wr-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
@media(max-width:900px){.wr-grid{grid-template-columns:1fr}}
.wr-col{border:1px solid var(--line);border-radius:12px;padding:11px 12px;background:#fbfdff;
  display:flex;flex-direction:column;gap:8px}
.wr-col.locked{background:#f4f6f9}
.wr-hd{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.wr-role{font-size:12.5px;font-weight:800;color:#fff;background:var(--pri);border-radius:8px;padding:2px 11px}
.wr-col[data-role="tech"] .wr-role{background:#0891B2}
.wr-col[data-role="rnd"] .wr-role{background:#7C3AED}
.wr-lock{font-size:11px;color:var(--mut);font-weight:700}
.wr-lock.on{color:#0f7a3d}
.wr-act{margin-left:auto}
.wr-col[data-role="tech"] .wr-act{border-color:#0891B2;color:#0891B2}
.wr-col[data-role="rnd"] .wr-act{border-color:#7C3AED;color:#7C3AED}
.wr-ta{border:1px solid var(--line);border-radius:8px;padding:8px 9px;font-size:12.5px;min-height:88px;
  resize:vertical;background:#fff;color:var(--ink);font-family:inherit;line-height:1.6}
.wr-ta:focus{outline:none;border-color:var(--pri);background:#fff}
.wr-ta:disabled{background:#eef1f5;color:var(--mut);cursor:not-allowed}
.wr-opp{display:flex;flex-direction:column;gap:4px;font-size:11.5px;color:var(--sub);font-weight:600}
.wr-opp select{border:1px solid var(--line);border-radius:8px;padding:6px 8px;font-size:12.5px;
  background:#fff;color:var(--ink);font-family:inherit}
.wr-state{font-size:11.5px;font-weight:700;color:var(--warn-ink);background:var(--warn-bg);
  border:1px solid var(--warn-bd);border-radius:7px;padding:3px 9px;display:inline-block;align-self:flex-start}
.wr-state.done{color:#0f7a3d;background:#e6f7ee;border-color:#a6ddba}
.band-wr{background:#e0f2fe;color:#0369a1}
/* ── 精简版第二层：只读继承 / 描述模板 / 待办多行 / 段级校验 ── */
.ro{background:#eef1f5 !important;color:#5b6b82;font-weight:700;cursor:default}
.desc{width:100%;min-height:190px;border:1px solid var(--line);border-radius:10px;padding:10px 12px;
  font-size:12.5px;line-height:1.7;background:#f9fbfe;color:var(--ink);resize:vertical;
  font-family:"SF Mono",Menlo,Consolas,monospace;white-space:pre-wrap}
.desc:focus{outline:none;border-color:var(--pri);background:#fff}
.desc.bad{border-color:var(--bad);background:#fdeeee;box-shadow:0 0 0 2px #f6c6c0 inset}
.segstat{font-size:11.5px;color:var(--sub);margin-top:6px;line-height:1.8}
.segstat .sg{margin-right:12px;font-weight:700}
.segstat .sg.bad{color:#b42318}
.td-list{display:flex;flex-direction:column;gap:8px;margin-top:6px}
.td-row{display:grid;grid-template-columns:2.2fr 1fr 1fr 64px;gap:8px;align-items:center}
.td-row input{border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:12.5px;
  background:#f9fbfe;color:var(--ink);font-family:inherit}
.td-row input:focus{outline:none;border-color:var(--pri);background:#fff}
.sub-title{font-size:12px;font-weight:800;color:var(--pri-d);margin:14px 0 6px;padding-left:9px;
  border-left:4px solid #7C3AED}
.grp-title .mini{margin-left:10px;vertical-align:middle}

/* ── §13.1 商机性质 chip + 筛选 ── */
.natf-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;background:#f7faff;border:1px dashed #b9cbe8;
  border-radius:10px;padding:7px 13px;margin:0 0 12px;font-size:12px;color:#33465f;font-weight:600}
.natf{border:1px solid var(--line);background:#eef3fb;color:var(--sub);border-radius:8px;padding:4px 11px;
  font-size:12px;font-weight:700;cursor:pointer;transition:.14s}
.natf:hover{color:var(--pri);border-color:var(--pri)}
.natf.on{background:var(--pri);color:#fff;border-color:var(--pri)}
.natf .n{font-weight:800;margin-left:5px;font-variant-numeric:tabular-nums}
.natchip{display:inline-block;border-radius:5px;padding:0 6px;font-size:10.5px;font-weight:800;
  border:1px solid transparent;white-space:nowrap;line-height:1.7;vertical-align:middle}
.natchip[data-nat="opportunistic"]{background:#FDE7D0;color:#9a4a09;border-color:#F0B27A}
.natchip[data-nat="continuous"]{background:#DCF5E6;color:#0f7a3d;border-color:#a6ddba}
.natchip[data-nat="unknown"]{background:#EEF1F5;color:#64748b;border-color:#d5dbe4}
td.c-opp .natchip{margin-left:6px}
text.nattxt{font-size:10px;font-weight:800;font-family:inherit}
g.natchip-svg{pointer-events:none}

/* ── §13 风险预警区 ── */
.risk-area{margin-top:16px}
.risk-head{font-size:14px;font-weight:800;color:var(--pri-d);margin:2px 0 10px;padding-left:9px;
  border-left:4px solid var(--bad)}
.risk-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start}
@media(max-width:1180px){.risk-grid{grid-template-columns:1fr}}
.risk-card{border:1px solid var(--line);border-radius:12px;background:#fbfdff;overflow:hidden;
  box-shadow:0 1px 3px rgba(16,42,82,.05)}
.risk-card .rc-hd{background:linear-gradient(90deg,#eaf1fd,#f8fbff);border-bottom:1px solid var(--line);
  padding:10px 13px;display:flex;align-items:center;gap:9px}
.risk-card .rc-hd h3{margin:0;font-size:13.5px;color:var(--pri-d);flex:1}
.risk-card .rc-hd .rc-n{font-size:11.5px;font-weight:800;color:#fff;background:var(--pri);border-radius:9px;padding:1px 9px}
.risk-list{padding:8px 10px 12px}
.risk-item{border:1px solid var(--line);border-radius:10px;padding:9px 11px;margin-bottom:9px;background:#fff;cursor:pointer}
.risk-item:last-child{margin-bottom:0}
.risk-item.open{background:#f7faff;border-color:#b9cbe8}
.risk-item .ri-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.risk-item .lv{font-size:10.5px;font-weight:800;border-radius:6px;padding:1px 8px;border:1px solid transparent;white-space:nowrap}
.risk-item .lv.red{background:#fde2e2;color:#b42318;border-color:#f3b6b0}
.risk-item .lv.yellow{background:var(--warn-bg);color:var(--warn-ink);border-color:var(--warn-bd)}
.risk-item .lv.green{background:#dcf5e6;color:#0f7a3d;border-color:#a6ddba}
.risk-item .ri-rule{font-size:10.5px;color:var(--mut);font-weight:700}
.risk-item .ri-cust{font-size:12.5px;font-weight:800;color:var(--ink)}
.risk-item .ri-reason{font-size:11.5px;color:#33465f;margin-top:5px;line-height:1.7}
.risk-item .ri-reason b{color:var(--pri-d)}
.risk-item .ri-toggle{font-size:11px;color:var(--pri);font-weight:700;margin-top:5px}
.risk-basis{display:none;margin-top:7px;border-top:1px dashed #cdd8e6;padding-top:7px}
.risk-item.open .risk-basis{display:block}
.risk-basis table{border-collapse:collapse;width:100%;font-size:11px}
.risk-basis td{border:1px solid var(--line);padding:3px 7px;vertical-align:top}
.risk-basis td.k{background:#f5f8fd;color:var(--pri-d);font-weight:700;width:38%}
.rp-bar{display:flex;gap:3px;margin-top:7px}
.rp-seg{flex:1;min-width:0;height:26px;border-radius:4px;background:#eef1f5;border:1px solid var(--line);
  font-size:9.5px;color:var(--sub);display:flex;align-items:center;justify-content:center;font-weight:700;
  text-align:center;line-height:1.05;padding:0 2px;overflow:hidden}
.rp-seg.done{background:#dcf5e6;color:#0f7a3d;border-color:#a6ddba}
.rp-seg.cur{background:#e3edfd;color:var(--pri-d);border-color:#b9cbe8;box-shadow:0 0 0 2px rgba(24,82,182,.16)}
.rp-seg.cur.warn{background:var(--warn-bg);color:var(--warn-ink);border-color:var(--warn-bd);box-shadow:0 0 0 2px rgba(245,158,11,.22)}
.rp-seg.cur.bad{background:#fde2e2;color:#b42318;border-color:#f3b6b0;box-shadow:0 0 0 2px rgba(192,57,43,.2)}
.rp-meta{font-size:11px;color:var(--sub);margin-top:6px;display:flex;gap:14px;flex-wrap:wrap}
.rp-meta b{color:var(--pri-d)}
.risk-foot{font-size:11px;color:var(--sub);background:#f7faff;border:1px dashed #b9cbe8;border-radius:9px;
  padding:8px 11px;margin-top:9px;line-height:1.75}
.risk-foot b{color:var(--pri-d)}
</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">① 第一层｜项目推移表（默认<b>轨道视图</b>，可切表格视图） ｜ ② 第二层｜活动录入与待办</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">
      <div class="vswitch" id="viewSwitch">
        <button class="vb on" data-v="rail">🚀 轨道视图</button>
        <button class="vb" data-v="table">▦ 表格视图</button>
      </div>
      <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="legend" id="railLegend">
      <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">点击任意一行 → 下方展开该商机的<b>资源泳道矩阵</b></span>
    </div>

    <div class="natf-bar" id="natfBar">
      <span>商机性质筛选（§13.1）：</span>
      <button class="natf on" data-nat="all" type="button">全部<span class="n" id="nfAll">0</span></button>
      <button class="natf" data-nat="opportunistic" type="button">机会性<span class="n" id="nfOp">0</span></button>
      <button class="natf" data-nat="continuous" type="button">持续性<span class="n" id="nfCo">0</span></button>
      <button class="natf" data-nat="unknown" type="button">待判<span class="n" id="nfUn">0</span></button>
      <span style="flex:1"></span>
      <span>图例：<span class="natchip" data-nat="opportunistic">机会性</span>
        <span class="natchip" data-nat="continuous">持续性</span>
        <span class="natchip" data-nat="unknown">待判</span></span>
    </div>

    <div class="gantt-box" id="railWrap"><div id="railBox"></div></div>

    <div class="tablewrap" id="l1Wrap" style="display:none">
      <table class="l1" id="l1Table"></table>
    </div>

    <div class="expand-box" id="expandBox">
      <div class="ph">👆 点击上方轨道中的任意一行 → 此处展开该商机的资源泳道矩阵（第二层跟进内容，按时间向右延展）</div>
    </div>

    <div class="note" id="l1Note">
      轨道视图：一行一商机，8 个关键节点排成圆点串，当前节点高亮；按 4 个部门分组（点行头折叠）。
      <b>表格视图</b>：三层表头（组名跨列合并）：<b>第1层</b> 表名带 ｜ <b>第2层</b> 项目基本信息 / 项目关键节点（可向右延展）/ 资源协助（可向右延展）｜ <b>第3层</b> 列名。
      黄色「待关联」= 该行/该格 ID 未回填（append-only 允许 null）。
    </div>

    <div class="risk-area" id="riskArea">
      <div class="risk-head">⚠ 风险预警区（§13）· 机会性商机流失 + 客诉→替换→采购恢复周期</div>
      <div class="risk-grid">
        <div class="risk-card" id="cardOppLoss">
          <div class="rc-hd"><h3>① 机会性商机流失预警</h3><span class="rc-n" id="oppLossN">0</span></div>
          <div class="risk-list" id="oppLossList"></div>
          <div class="risk-foot" id="oppLossFoot"></div>
        </div>
        <div class="risk-card" id="cardReplace">
          <div class="rc-hd"><h3>② 客诉 → 替换 → 采购恢复周期</h3><span class="rc-n" id="replN">0</span></div>
          <div class="risk-list" id="replList"></div>
          <div class="risk-foot" id="replFoot"></div>
        </div>
      </div>
    </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_cust"></select></label>
        <label>商机名称（选择）<select id="f_opp"></select></label>
        <label>部门（只读继承）<input id="f_dept_ro" class="ro" readonly></label>
        <label>客户编码（只读继承 · 未回填→待关联）<input id="f_cid_ro" class="ro" readonly></label>
        <label>商机规模（只读继承）<input id="f_scale_ro" class="ro" readonly></label>
        <label>销售员（只读继承）<input id="f_sales_ro" class="ro" readonly></label>
        <label>技术支持（只读继承 · 待办默认协同人）<input id="f_tech_ro" class="ro" readonly></label>
        <label>日期<input id="f_date" value="2026-10-11"></label>
        <label>拜访对象/联系人<input id="f_contact" value="张工"></label>
        <label>本次推进节点（回写第一层 · 可选）<select id="f_node"></select></label>
      </div>

      <div class="grp-title">① 活动类型</div>
      <div class="fgrid">
        <label>活动类型<select id="f_type"></select></label>
      </div>

      <div class="grp-title">② 活动描述（按活动类型切换模板）
        <button class="mini" id="btnTpl" type="button">插入模板</button>
      </div>
      <textarea id="f_desc" class="desc" placeholder="选择活动类型后自动套用模板；也可点「插入模板」重新插入。"></textarea>
      <div class="segstat" id="segStat"></div>

      <div class="grp-title">③ 关联订单 / 测试单（可选 · 单字段）</div>
      <div class="fgrid">
        <label>关联订单/测试单编号（未填留空 → 标「待关联」）<input id="f_order" placeholder="SO-... / TS-..."></label>
      </div>

      <div class="grp-title">④ 待办事项（可多条 · 负责人默认=技术支持协同人）</div>
      <div class="td-list" id="todoRows"></div>
      <div class="submit-row" style="margin-top:8px">
        <button class="mini" id="btnAddTodo" type="button">＋ 添加一条待办</button>
        <button class="mini" id="btnQC" type="button">填写质量质检检查单 ▸</button>
      </div>

      <div class="sub-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="grp-title">周会决议（三岗各填一栏 · 销售 / 技术支持 / 研发）</div>
      <div class="wr-note">
        每位岗位在本栏填写本周会议决议，<b>一行一条</b>，格式：<code>决议事项 ｜ 负责人 ｜ 时间节点</code>（可多行）；
        点「以本岗身份填写」后方可编辑本人这一栏（<b>管理员不受限</b>，可编辑三栏）。提交后<b>每条决议落盘到待办库</b>
        <code>support_todos</code>，带 岗位 / 负责人 / 时间节点 / 关联商机 / 来源（周会决议）/ 活动编号。
      </div>
      <div class="wr-admin">
        <label class="chk"><input type="checkbox" id="wrAdmin"><span>管理员模式（不受限：可编辑三栏）</span></label>
      </div>
      <div class="wr-grid" id="wrPanel">
        <div class="wr-col locked" data-role="sales" data-rolelabel="销售">
          <div class="wr-hd"><span class="wr-role">销售</span><span class="wr-lock" data-lock>🔒 本岗未解锁</span>
            <button class="mini wr-act" data-act="sales" type="button">以本岗身份填写</button></div>
          <textarea class="wr-ta" data-ta="sales" placeholder="决议事项 ｜ 负责人 ｜ 时间节点（一行一条，可多行）" disabled></textarea>
          <label class="wr-opp">关联商机<select data-opp="sales"></select></label>
          <div class="wr-state" data-state="sales">本岗未提交</div>
        </div>
        <div class="wr-col locked" data-role="tech" data-rolelabel="技术支持">
          <div class="wr-hd"><span class="wr-role">技术支持</span><span class="wr-lock" data-lock>🔒 本岗未解锁</span>
            <button class="mini wr-act" data-act="tech" type="button">以本岗身份填写</button></div>
          <textarea class="wr-ta" data-ta="tech" placeholder="决议事项 ｜ 负责人 ｜ 时间节点（一行一条，可多行）" disabled></textarea>
          <label class="wr-opp">关联商机<select data-opp="tech"></select></label>
          <div class="wr-state" data-state="tech">本岗未提交</div>
        </div>
        <div class="wr-col locked" data-role="rnd" data-rolelabel="研发">
          <div class="wr-hd"><span class="wr-role">研发</span><span class="wr-lock" data-lock>🔒 本岗未解锁</span>
            <button class="mini wr-act" data-act="rnd" type="button">以本岗身份填写</button></div>
          <textarea class="wr-ta" data-ta="rnd" placeholder="决议事项 ｜ 负责人 ｜ 时间节点（一行一条，可多行）" disabled></textarea>
          <label class="wr-opp">关联商机<select data-opp="rnd"></select></label>
          <div class="wr-state" data-state="rnd">本岗未提交</div>
        </div>
      </div>
      <div class="submit-row">
        <button class="btn" id="btnWrSubmit">提交周会决议 → 落盘待办库</button>
        <span class="tip">三岗各写各栏（权限演示）；提交后每条决议 → 一条 support_todos 待办，并展示「本次落盘 N 条待办」结果面板（含可一键复制的飞书描述）。</span>
      </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>活动类型=「测试结果跟进」时，活动描述的<b>必填段逐段非空</b>（客户需求 / 宝锐方案 / 测试结果 / 问题现象 / 客户反馈 / 待办事项）。</li>
      <li>【客户需求】分点列出，含应用场景 / 技术平台 / 项目状态 / 时间节点 / 量化参数要求 / 竞对情况。</li>
      <li>【宝锐方案】含货号+名称、关联订单/测试单编号、测试条件（体系 / 仪器 / 程序）。</li>
      <li>【测试结果】逐项：指标 → 实测值 → 判定；结论写通过 / 超阈（超阈须写偏差方向与幅度）。</li>
      <li>【问题现象】含<b>客户原话</b>（加引号）。</li>
      <li>【客户反馈 / 下一步诉求】写清客户结论与诉求。</li>
      <li>【待办事项】做什么 / 怎么做 / 做到什么程度 / 关联资源 / 负责人 / 日期。</li>
      <li>关联订单 / 测试单编号与 ERP 口径一致，未回填允许留空（前端标「待关联」）。</li>
    </ul>
  </div>
</div>

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

<!-- 周会决议 · 落盘待办库 弹层 -->
<div class="modal" id="wrModal">
  <div class="mh"><h3>周会决议 · 落盘待办库（support_todos）</h3><button class="x" id="wrX">✕</button></div>
  <div class="mb">
    <section>
      <h4>本次落盘 <span id="wrCount" style="color:#0d3a85">0</span> 条待办</h4>
      <ul class="checklist" id="wrList"></ul>
    </section>
    <section id="wrSecCB">
      <h4>support_todos 写入 JSON（按岗位分字段 · 去重键 todo_id）
        <button class="mini" id="wrToggleCB" type="button">收起 / 展开</button></h4>
      <pre id="wrCB"></pre>
    </section>
    <section>
      <h4>飞书描述（一键复制）
        <button class="mini" id="wrCopy" type="button">复制</button></h4>
      <pre id="wrFeishu"></pre>
    </section>
  </div>
</div>

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

/* ================= 常量 ================= */
const NODES = ["陌生开发","方案匹配","中试","临床试验","已确认被用于注册","已转产","客户已获证","新证迭代"];
const SHORT = DATA.short || {};
const LANES = DATA.lanes;
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'};
const STCLS = {'未开始':'todo','待承接':'todo','进行中':'doing','已完成':'done','卡住':'stuck'};
/* §13.1 商机性质 chip */
const NATLABEL = {opportunistic:'机会性', continuous:'持续性', unknown:'待判'};
const NATFILL  = {opportunistic:'#FDE7D0', continuous:'#DCF5E6', unknown:'#EEF1F5'};
const NATSTROKE= {opportunistic:'#F0B27A', continuous:'#a6ddba', unknown:'#d5dbe4'};
const NATINK   = {opportunistic:'#9a4a09', continuous:'#0f7a3d', unknown:'#64748b'};
function natOf(o){ return o && o.nature ? o.nature : 'unknown'; }
function natChipHTML(r){
  const nat = natOf(r);
  return ' <span class="natchip" data-nat="'+nat+'" title="'+esc(r.nature_basis||'')+'">'+NATLABEL[nat]+'</span>';
}

/* ================= 状态 ================= */
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();
let curView = 'rail';      // rail | table
let selIdx = null;         // 轨道视图选中的商机（全局下标）
let ROWS = [];             // 扁平化行（与全局下标一致）
let natFilter = 'all';     // §13.1 商机性质筛选：all | opportunistic | continuous | unknown

/* ===== 待办库（support_todos 统一内存态）· 周会决议（三岗各填一栏） ===== */
let SUPPORT_TODOS = [];
let weeklyResolutions = {sales:[], tech:[], rnd:[]};
let wrOpen = {sales:false, tech:false, rnd:false};
let wrSeq = 0;
const WR_ROLES = [['sales','销售'],['tech','技术支持'],['rnd','研发']];
window.__supportTodos = SUPPORT_TODOS;
window.__weeklyResolutions = weeklyResolutions;

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 short10(s,n){ s=String(s||''); return s.length>n ? s.slice(0,n)+'…' : s; }
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 rowMatchNat(o){ return natFilter==='all' || natOf(o)===natFilter; }
function nodeGroups(){
  const out=[];
  L1.depts.forEach(function(d){
    const rows=d.rows.filter(rowMatchNat);
    if(rows.length) out.push({dept:d.name, rows:rows});
  });
  return out;
}
/* 建立扁平行索引 */
function rebuildFlat(){
  ROWS = [];
  L1.depts.forEach(function(d){ d.rows.forEach(function(r){ r.__i = ROWS.length; ROWS.push(r); }); });
}
rebuildFlat();

/* 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;
}

/* ================= 第一层 · 轨道视图（形态1 里程碑轨道） ================= */
const LBL = 340, TL = 900, GAP = 16;
const W = LBL + TL + GAP;
const HEADH = 46, ROWH = 34, DEPTH = 26;

function natChipSVG(o, rx, cy){
  const nat = natOf(o), lab = NATLABEL[nat];
  const w = lab.length*11 + 18, x = rx - w;
  return '<g class="natchip-svg" data-nat="'+nat+'">'
    + '<rect x="'+x+'" y="'+(cy-9)+'" width="'+w+'" height="18" rx="9" fill="'+NATFILL[nat]+'" stroke="'+NATSTROKE[nat]+'"/>'
    + '<text x="'+(x+w/2)+'" y="'+(cy+4)+'" text-anchor="middle" fill="'+NATINK[nat]+'" class="nattxt">'+esc(lab)+'</text></g>';
}
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="railax">'+esc(SHORT[nd]||nd)+'</text>';
  });
  out += '<text x="'+(W-10)+'" y="41" text-anchor="end" class="opp">节点顺序 = 流程时间顺序 →</text>';
  nodeGroups().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(o){
      const k = o.__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]||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>';
        }
      });
      out += natChipSVG(o, LBL - 8, cy);
      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 renderRail(){
  const box = $('railBox');
  if(!box) return;
  box.innerHTML = railSVG();
  box.querySelectorAll('rect.rowhit').forEach(function(r){
    r.onclick = function(){ selectOpp(parseInt(r.getAttribute('data-i'),10)); };
  });
  window.__railInfo = {
    rows: box.querySelectorAll('rect.rowhit').length,
    circles: box.querySelectorAll('circle').length,
    curlab: box.querySelectorAll('.curlab').length,
    rails: box.querySelectorAll('line.rail').length,
    depts: nodeGroups().length
  };
}

/* ================= 第一层 · 表格视图（原三层表头宽表） ================= */
function baseCols(){return [
  {key:'dept', title:'部门',    w:76,  group:'base'},
  {key:'cid',  title:'客户编码', w:96,  group:'base'},
  {key:'cust', title:'客户名称', w:186, group:'base'},
  {key:'opp',  title:'商机名称', w:200, group:'base'},
  {key:'sales',title:'销售员',   w:72,  group:'base'},
  {key:'scale',title:'商机规模', w:96,  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.start && cell.start!==cell.date) h+='<span class="celldate">起 '+esc(cell.start)+'</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');
  if(!t) return;
  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 rows = d.rows.filter(rowMatchNat);
    if(!rows.length) return;
    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">'+rows.length+' 个商机</span></td></tr>';
    if(!open) return;
    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 if(c.key==='opp'){ v = esc(v) + natChipHTML(r); }
          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(function(a,d){ return a + d.rows.filter(rowMatchNat).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)};
}

/* ================= 第二层 · 资源泳道矩阵（形态B） ================= */
function kcls(k){ return KCLS[k] || 'k-act'; }
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)+'<span class="n">0</span></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(!box) return;
  if(selIdx==null || curView!=='rail'){
    box.innerHTML = '<div class="ph">👆 点击上方轨道中的任意一行 → 此处展开该商机的资源泳道矩阵（第二层跟进内容，按时间向右延展）</div>';
    window.__expandInfo = {sel:null, view:curView};
    return;
  }
  const o = ROWS[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]?evs[0].d:'—')+' · 末 '+(evs.length?evs[evs.length-1].d:'—')
    + ' ｜ 第二层形态：B · 资源泳道矩阵</div></div>';
  box.innerHTML = head + matrixHTML(o);
  window.__expandInfo = {sel:selIdx, view:curView, cust:o.cust,
    lanes:box.querySelectorAll('.mx-lane').length,
    chips:box.querySelectorAll('.mx-chip').length,
    months:oppMonths(o).length};
}
function selectOpp(i){
  selIdx = i;
  renderRail();
  renderExpand();
  const ex = $('expandBox');
  if(ex) ex.scrollIntoView({behavior:'smooth', block:'nearest'});
}

/* ================= 视图切换 ================= */
function setView(v){
  curView = v;
  document.querySelectorAll('#viewSwitch .vb').forEach(function(b){
    b.classList.toggle('on', b.getAttribute('data-v')===v);
  });
  $('railWrap').style.display = (v==='rail') ? '' : 'none';
  $('l1Wrap').style.display  = (v==='table') ? '' : 'none';
  $('railLegend').style.display = (v==='rail') ? '' : 'none';
  if(v==='rail'){ renderRail(); renderExpand(); }
  else { renderL1(); }
}
document.querySelectorAll('#viewSwitch .vb').forEach(function(b){
  b.onclick = function(){ setView(b.getAttribute('data-v')); };
});

/* ================= 第二层表单（原样保留） ================= */
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 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)));
  fillSelect($('f_cust'), custs);
  fillSelect($('f_opp'), opps);
  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_cust').onchange=inheritance;
  $('f_opp').onchange=inheritance;
  $('f_type').onchange=onTypeChange;
  $('f_desc').oninput=updateSegStat;
  $('btnTpl').onclick=function(){ $('f_desc').value=TPLS[$('f_type').value]||''; updateSegStat(); };
  $('btnAddTodo').onclick=function(){ addTodoRow(); };
  addTodoRow();
  inheritance();
  $('f_desc').value=TPLS['测试结果跟进']||'';
  updateSegStat();
}
/* —— 只读继承：从第一层带出（不占录入位）—— */
function flatRows(){ return [].concat.apply([], L1.depts.map(d=>d.rows)); }
function inheritance(){
  const c=$('f_cust').value;
  const hit=flatRows().find(r=>r.cust===c);
  if(!hit) return;
  $('f_opp').value=hit.opp;
  $('f_dept_ro').value=hit.dept||'';
  $('f_cid_ro').value=(hit.cid==='待关联')?'':(hit.cid||'');
  $('f_scale_ro').value=hit.scale||'';
  $('f_sales_ro').value=hit.sales||'';
  $('f_tech_ro').value=hit.tech||'';
  document.querySelectorAll('#todoRows .td-owner').forEach(function(inp){ if(!inp.value) inp.value=hit.tech||''; });
}
/* —— 活动描述模板（按活动类型切换 · 右上角「插入模板」）—— */
const TPLS = __TPLS__;
const REQ_SEGS = __REQSEGS__;
function onTypeChange(){
  const cur=$('f_desc').value;
  const isTpl=Object.keys(TPLS).some(function(k){ return TPLS[k]===cur; });
  if(!cur.trim() || isTpl){ $('f_desc').value=TPLS[$('f_type').value]||''; }
  else { toast('已保留你的编辑；如需套用新模板请点「插入模板」'); }
  updateSegStat();
}
function parseDesc(txt){
  const out={}, re=new RegExp('【([^】]+)】','g'); let m; const hits=[];
  while((m=re.exec(txt))){ hits.push({name:m[1].trim(), start:m.index, hdrEnd:re.lastIndex}); }
  hits.forEach(function(it,i){
    const end=(i+1<hits.length)? hits[i+1].start : txt.length;
    out[it.name]=txt.slice(it.hdrEnd, end).trim();
  });
  return out;
}
function segHit(segs, k){
  const hit=Object.keys(segs).filter(function(n){ return n.indexOf(k)>=0; })[0];
  return !!(hit && segs[hit]);
}
function missingSegs(){
  const segs=parseDesc($('f_desc').value);
  return REQ_SEGS.filter(function(k){ return !segHit(segs, k); });
}
function updateSegStat(){
  const el=$('segStat'); if(!el) return;
  const type=$('f_type').value, segs=parseDesc($('f_desc').value);
  if(type!=='测试结果跟进'){ el.innerHTML='当前类型「'+esc(type)+'」按模板填写即可（无强制段）。'; return; }
  el.innerHTML='必填段：'+REQ_SEGS.map(function(k){
    const ok=segHit(segs, k);
    return '<span class="sg'+(ok?'':' bad')+'">'+(ok?'● 已填 ':'○ 缺 ')+'【'+esc(k)+'】</span>';
  }).join('');
}
function addTodoRow(vals){
  const wrap=$('todoRows'); if(!wrap) return;
  const defOwner=($('f_tech_ro')&&$('f_tech_ro').value)||'';
  const row=document.createElement('div'); row.className='td-row';
  row.innerHTML='<input class="td-item" placeholder="事项：做什么 / 怎么做 / 做到什么程度 / 关联资源">'
    +'<input class="td-owner" placeholder="负责人" value="'+esc((vals&&vals.owner)||defOwner)+'">'
    +'<input class="td-due" placeholder="2026-10-20">'
    +'<button class="mini td-del" type="button">删除</button>';
  if(vals){ row.querySelector('.td-item').value=vals.item||''; row.querySelector('.td-due').value=vals.due||''; }
  row.querySelector('.td-del').onclick=function(){ wrap.removeChild(row); };
  wrap.appendChild(row);
}
function collectTodos(){
  const out=[];
  document.querySelectorAll('#todoRows .td-row').forEach(function(r){
    const item=r.querySelector('.td-item').value.trim();
    const owner=r.querySelector('.td-owner').value.trim();
    const due=r.querySelector('.td-due').value.trim();
    if(item||owner||due) out.push({item:item||'（待定）', owner:owner||'（待定）', due:due||'（待定）'});
  });
  return out;
}
window.__descSegs=function(){ return parseDesc($('f_desc').value); };
window.__segMissing=function(){ return missingSegs(); };
window.__tpl=function(t){ return TPLS[t]||''; };
window.__reqSegs=REQ_SEGS;
window.__todoCount=function(){ return document.querySelectorAll('#todoRows .td-row').length; };

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(){
  $('formErr').classList.remove('on'); $('formErr').textContent='';
  $('f_desc').classList.remove('bad');
  $('f_othernote').classList.remove('bad');
  const type=$('f_type').value;
  const errs=[];
  if(type==='测试结果跟进'){
    const miss=missingSegs();
    if(miss.length){
      $('f_desc').classList.add('bad');
      errs.push('活动类型「测试结果跟进」的活动描述缺少必填段 —— '+miss.map(function(k){return '【'+k+'】';}).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');
    window.__lastErr=$('formErr').textContent;
    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 todos = m.todos || [];
  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 || '',
    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,
            "desc_template":m.type, "desc_content":m.desc, "date":m.date, "contact":m.contact,
            "order_no":m.order, "type":m.type, "node_push":m.nodePush,
            "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+" · "+((todos[0]&&todos[0].item)||'—'), "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=待承接）';
      }).join('\n')
    : '  （本次未触发资源协作）';
  const todoLines = todos.length
    ? todos.map(function(t,i){ return (i+1)+'. '+(t.item||'—')+' ｜ 负责人 '+(t.owner||'—')+' ｜ 时间节点 '+(t.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.date+' ｜ 拜访对象/联系人：'+m.contact+' ｜ 关联订单/测试单：'+(m.order||'待关联')+'\n'
   +'活动类型：'+m.type+'\n'
   +'—— 活动描述 ——\n'+(m.desc||'（空）')+'\n'
   +'—— 本次触发的资源协作（销售手动多选）——\n'+resLines+'\n'
   +'—— 待办事项（可多条）——\n'+todoLines+'\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+'）');
  }
  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+'）');
  });
  rebuildFlat();
  renderL1();
  renderRail();
  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 todos=collectTodos();
  const m={
    actId:actId, dept:$('f_dept_ro').value, cid:$('f_cid_ro').value.trim(), cust:$('f_cust').value,
    opp:$('f_opp').value, sales:$('f_sales_ro').value, tech:$('f_tech_ro').value.trim(),
    scale:$('f_scale_ro').value.trim(), date:$('f_date').value, contact:$('f_contact').value.trim(),
    order:$('f_order').value.trim(), type:$('f_type').value, desc:$('f_desc').value.trim(),
    todos: todos, 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;
  const wb=writeBackL1(m);
  activities.push(m);
  (m.supportTypes||[]).forEach(function(st,i){
    SUPPORT_TODOS.push({todo_id:'TD-'+m.actId+'-'+pad2(i+1), act_id:m.actId,
      role:'（资源协作·'+st.type+'）', owner:(todos[0]&&todos[0].owner)||m.tech,
      due:(todos[0]&&todos[0].due)||'（待定）', opportunity:m.opp, source:'资源协作',
      item:(todos[0]&&todos[0].item)||'', state:'待承接'});
  });
  renderL2();
  $('trackModal').classList.add('open'); $('mask').classList.add('open');
  window.__lastSubmit={actId:actId, type:m.type, desc:m.desc, todos:m.todos, order:m.order,
    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');
  $('f_desc').value=TPLS[$('f_type').value]||'';
  $('todoRows').innerHTML=''; addTodoRow();
  document.querySelectorAll('#resPick input[data-res]').forEach(cb=>{ cb.checked=false; });
  $('f_othernote').value='';
  $('otherNoteWrap').style.display='none';
  $('f_order').value='';
  updateSegStat();
};

/* ================= 第二层活动列表 ================= */
function renderL2(){
  const cols=[
    {key:'actId', title:'活动编号', w:140, group:'base'},
    {key:'cust',  title:'客户名称', w:186, group:'base'},
    {key:'opp',   title:'商机名称', w:190, group:'base'},
    {key:'type',  title:'活动类型', w:104, group:'base'},
    {key:'order', title:'关联订单/测试单', w:132, group:'base'},
    {key:'desc',  title:'活动描述（模板化）', w:430, group:'desc'},
    {key:'todoItem', title:'事项', w:220, group:'todo'},
    {key:'owner', title:'负责人',   w:90,  group:'todo'},
    {key:'due',   title:'时间节点', w:96,  group:'todo'},
    {key:'wr_sales',title:'销售',    w:160, group:'wr'},
    {key:'wr_tech', title:'技术支持', w:160, group:'wr'},
    {key:'wr_rnd',  title:'研发',    w:160, group:'wr'}
  ];
  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'), d=cols.filter(c=>c.group==='desc');
  const td=cols.filter(c=>c.group==='todo'), wr=cols.filter(c=>c.group==='wr');
  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="'+d.length+'">活动描述（按类型模板）</th>'
    + '<th class="band band-todo" colspan="'+td.length+'">待办事项</th>'
    + '<th class="band band-wr" colspan="'+wr.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=>{
      if(c.group==='wr'){
        const role=c.key.slice(3);
        const hit=weeklyResolutions[role].filter(r=>r.opportunity===m.opp);
        body+='<td>'+(hit.length ? esc(hit.map(r=>r.text).join(' / '))
          : '<span class="cellnone" style="font-size:11.5px">本岗未提交</span>')+'</td>';
        return;
      }
      if(c.key==='desc'){
        body+='<td title="'+esc(m.desc||'')+'">'+esc(short10((m.desc||'').replace(/\n/g,' '),90))+'</td>'; return;
      }
      if(c.key==='todoItem'){
        body+='<td>'+((m.todos&&m.todos.length)
          ? esc(m.todos.map(function(x){return x.item;}).join(' / '))
          : '<span class="cellnone">—</span>')+'</td>'; return;
      }
      if(c.key==='order'){
        const v=m.order||'';
        body+='<td>'+(v? esc(v) : '<span class="warn">待关联</span>')+'</td>'; return;
      }
      let v=m[c.key]==null?'':m[c.key];
      const cl = c.key==='cust'?'c-client':(c.key==='opp'?'c-opp':'');
      body+='<td class="'+cl+'">'+esc(v)+'</td>';
    });
    body+='</tr>';
  });
  body+='</tbody>';
  t.innerHTML = cg + '<thead>'+h1+h2+h3+'</thead>' + body;
  window.__l2rows = activities.length;
}

/* ================= 周会决议（三岗各填一栏 → 落盘待办库） ================= */
function wrCol(role){ return document.querySelector('.wr-col[data-role="'+role+'"]'); }
function setRoleOpen(role, open){
  wrOpen[role]=open;
  const col=wrCol(role); if(!col) return;
  const ta=col.querySelector('.wr-ta'); ta.disabled=!open;
  col.classList.toggle('locked', !open);
  const lk=col.querySelector('.wr-lock');
  lk.textContent = open ? '🔓 本岗填写中' : '🔒 本岗未解锁';
  lk.classList.toggle('on', open);
  if(open) ta.focus();
}
function refreshWrState(){
  WR_ROLES.forEach(function(p){
    const st=document.querySelector('[data-state="'+p[0]+'"]');
    if(!st) return;
    const arr=weeklyResolutions[p[0]];
    st.textContent = arr.length ? ('已提交 '+arr.length+' 条') : '本岗未提交';
    st.classList.toggle('done', arr.length>0);
  });
}
function parseWrLines(role){
  const ta=document.querySelector('[data-ta="'+role+'"]');
  const sel=document.querySelector('[data-opp="'+role+'"]');
  const opp=(sel && sel.value) ? sel.value : '（未选择）';
  return ta.value.split('\n').map(function(x){ return x.trim(); }).filter(function(x){ return x; })
    .map(function(line){
      const parts=line.split(/\s*[｜|；;]\s*/);
      let item=parts[0]||line, owner=parts[1]||'', due=parts[2]||'';
      const dm=line.match(/\d{4}-\d{2}-\d{2}/);
      if(dm) due=dm[0];
      if(!owner) owner='（待定）';
      if(!due) due='（待定）';
      return {item:item, owner:owner, due:due, opportunity:opp};
    });
}
function initWeekly(){
  const opps=[].concat.apply([], L1.depts.map(function(d){ return d.rows.map(function(r){ return r.opp; }); }));
  WR_ROLES.forEach(function(p){
    const sel=document.querySelector('[data-opp="'+p[0]+'"]');
    if(sel) fillSelect(sel, opps, true);
  });
  document.querySelectorAll('.wr-act').forEach(function(btn){
    btn.onclick=function(){ setRoleOpen(btn.getAttribute('data-act'), true); };
  });
  const adm=$('wrAdmin');
  if(adm) adm.onchange=function(){ WR_ROLES.forEach(function(p){ setRoleOpen(p[0], adm.checked); }); };
  $('btnWrSubmit').onclick=submitWeekly;
  $('wrX').onclick=closeModals;
  $('wrToggleCB').onclick=function(){ $('wrSecCB').classList.toggle('collapsed'); };
  $('wrCopy').onclick=function(){
    const t=$('wrFeishu').textContent;
    const done=function(){ toast('周会决议 · 飞书描述已复制到剪贴板'); };
    if(navigator.clipboard && navigator.clipboard.writeText){
      navigator.clipboard.writeText(t).then(done, function(){ fallbackCopy(t, done); });
    } else fallbackCopy(t, done);
  };
  refreshWrState();
  window.__wrOK = {roles:WR_ROLES.map(function(p){ return p[0]; }), group:'周会决议',
                   rolesLabel:WR_ROLES.map(function(p){ return p[1]; })};
}
function submitWeekly(){
  const has=WR_ROLES.some(function(p){ return document.querySelector('[data-ta="'+p[0]+'"]').value.trim(); });
  if(!has){ toast('本岗未填写任何决议，未落盘待办库'); return; }
  wrSeq++;
  const wrActId='WR-'+todayStr()+'-'+pad2(wrSeq);
  const newTodos=[];
  WR_ROLES.forEach(function(p){
    const role=p[0], label=p[1];
    parseWrLines(role).forEach(function(it,i){
      const todo={todo_id:wrActId+'-'+role+'-'+pad2(i+1), act_id:wrActId, role:label, role_key:role,
        owner:it.owner, due:it.due, opportunity:it.opportunity, source:'周会决议', item:it.item, state:'待承接'};
      newTodos.push(todo); SUPPORT_TODOS.push(todo);
      weeklyResolutions[role].push({text:it.item, owner:it.owner, due:it.due,
        opportunity:it.opportunity, actId:wrActId});
    });
  });
  const total=newTodos.length;
  $('wrCount').textContent=total;
  $('wrList').innerHTML=newTodos.map(function(t){
    return '<li>【'+esc(t.role)+'】'+esc(t.item)+' ｜ 负责人 '+esc(t.owner)+' ｜ 时间节点 '+esc(t.due)
      + ' ｜ 关联商机 '+esc(t.opportunity)+'（待办 '+esc(t.todo_id)+'）</li>'; }).join('');
  $('wrCB').textContent=JSON.stringify({collection:'support_todos', docs:newTodos}, null, 2);
  const feishu='【周会决议 · 落盘待办库】'+wrActId+'\n'
    + '本次落盘 '+total+' 条待办（集合 support_todos）\n'
    + newTodos.map(function(t,i){ return (i+1)+'. 【'+t.role+'】'+t.item+' ｜ 负责人 '+t.owner
        + ' ｜ 时间节点 '+t.due+' ｜ 关联商机 '+t.opportunity; }).join('\n');
  $('wrFeishu').textContent=feishu;
  $('wrModal').classList.add('open'); $('mask').classList.add('open');
  window.__wrResult={actId:wrActId, count:total, todos:newTodos};
  WR_ROLES.forEach(function(p){ document.querySelector('[data-ta="'+p[0]+'"]').value=''; });
  refreshWrState();
  renderL2();
  toast('周会决议已落盘 '+total+' 条待办（support_todos）');
}

/* ================= 弹层 / Toast ================= */
function closeModals(){ $('trackModal').classList.remove('open'); $('qcModal').classList.remove('open');
  $('wrModal').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_ro').value||'待分配', scale:'—', tech:'—', comp:'—', unlinked:true, nodes:nodes, res:{}, events:[]});
  rebuildFlat(); renderL1(); renderRail();
};

/* ================= 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';
});

/* ================= §13 商机性质筛选 & 风险预警区 ================= */
function natCounts(){
  const c={all:0, opportunistic:0, continuous:0, unknown:0};
  L1.depts.forEach(function(d){ d.rows.forEach(function(r){
    c.all++; const n=natOf(r); c[n]=(c[n]||0)+1; }); });
  return c;
}
function renderNatFilter(){
  const c=natCounts();
  if($('nfAll')) $('nfAll').textContent=c.all;
  if($('nfOp'))  $('nfOp').textContent=c.opportunistic||0;
  if($('nfCo'))  $('nfCo').textContent=c.continuous||0;
  if($('nfUn'))  $('nfUn').textContent=c.unknown||0;
  document.querySelectorAll('#natfBar .natf').forEach(function(b){
    b.classList.toggle('on', b.getAttribute('data-nat')===natFilter); });
  window.__natFilter = natFilter;
}
function applyNatFilter(nat){
  natFilter = nat || 'all';
  renderNatFilter(); renderRail(); renderExpand(); renderL1();
}

/* ---- 风险工具函数 ---- */
function fmtInt(n){ return String(Math.round(n)).replace(/(\d)(?=(\d{3})+$)/g, '$1,'); }
function amtNum(s){
  if(s==null) return 0;
  s=String(s);
  const m=s.replace(/[,，\s]/g,'').match(/(\d+(?:\.\d+)?)/);
  if(!m) return 0;
  let v=parseFloat(m[1]);
  if(/US\$|\$|USD/i.test(s)) v*=(DATA.usd_cny||7.2);
  return v;
}
function dayDiff(a,b){ if(!a||!b) return null; return Math.round((d2t(a)-d2t(b))/DAY); }
function monDiff(a,b){ if(!a||!b) return 0; const A=a.split('-').map(Number), B=b.split('-').map(Number);
  return (A[0]*12+A[1]) - (B[0]*12+B[1]); }
function lastEventDate(o){ let m=''; (o.events||[]).forEach(function(e){ if(e.d>m) m=e.d; }); return m; }
function basisTable(pairs){
  let h='<table>';
  pairs.forEach(function(p){ h+='<tr><td class="k">'+esc(p[0])+'</td><td>'+esc(p[1]==null?'—':p[1])+'</td></tr>'; });
  return h+'</table>';
}
function bindRiskToggles(scope){
  (scope||document).querySelectorAll('.risk-item').forEach(function(it){
    it.onclick=function(){ it.classList.toggle('open'); };
  });
}

/* ---- 卡① 机会性商机流失预警 ---- */
function lossWarnings(){
  const N = (DATA.meta && DATA.meta.lossNMonths) || 3;
  const OD = (DATA.meta && DATA.meta.lossOverdueDays) || 60;
  const out=[];
  ((DATA.meta && DATA.meta.oppLossHistory) || []).forEach(function(h){
    out.push({level:'red', rule:'规则② · 过预计成交窗口 '+OD+' 天仍未成交',
      customer:h.customer, opp:h.opp, amount:h.amount, reason:h.reason, basis:h.basis||{}, kind:'hist'});
  });
  (ROWS||[]).forEach(function(o){
    if(natOf(o)!=='opportunistic') return;
    const le=lastEventDate(o), md = le ? monDiff(TODAY, le) : 999;
    if(md>=N){
      out.push({level:'yellow', rule:'规则① · 连续 '+md+' 个月无活动/无订单（阈值 '+N+' 个月）',
        customer:o.cust, opp:o.opp, amount:o.scale,
        reason:'机会性商机潜在断尾风险：最近活动 '+(le||'无')+'，连续 '+md+' 个月无跟进/无订单',
        basis:{'商机性质':'机会性','判定依据':o.nature_basis||'—','最近活动':le||'无',
               '连续无活动月数':md+'（阈值 '+N+'）','商机规模':o.scale||'—','销售员':o.sales||'—'},
        kind:'row'});
    }
  });
  return out;
}
function renderOppLoss(){
  const list=$('oppLossList'), foot=$('oppLossFoot');
  if(!list) return;
  const ws=lossWarnings();
  list.innerHTML = ws.length ? ws.map(function(w,i){
    const pairs=Object.keys(w.basis).map(function(k){ return [k, w.basis[k]]; });
    return '<div class="risk-item" data-lv="'+w.level+'" data-kind="'+w.kind+'" data-i="'+i+'">'
      + '<div class="ri-top"><span class="lv '+w.level+'">'+(w.level==='red'?'红警告':(w.level==='yellow'?'黄预警':'绿'))+'</span>'
      + '<span class="ri-cust">'+esc(w.customer)+'</span>'
      + '<span class="ri-rule">'+esc(w.rule)+'</span></div>'
      + '<div class="ri-reason"><b>'+esc(w.opp)+'</b> ｜ 金额 '+esc(w.amount||'—')+'<br>'+esc(w.reason)+'</div>'
      + '<div class="ri-toggle">▸ 点击查看依据字段</div>'
      + '<div class="risk-basis">'+basisTable(pairs)+'</div></div>';
  }).join('') : '<div class="ri-reason">当前无触发的机会性商机流失预警。</div>';
  $('oppLossN').textContent = ws.length;
  const total=ROWS.reduce(function(a,r){ return a+amtNum(r.scale); },0);
  const oppAmt=ROWS.filter(function(r){ return natOf(r)==='opportunistic'; }).reduce(function(a,r){ return a+amtNum(r.scale); },0);
  const ratio = total? (oppAmt/total*100) : 0;
  const TH=(DATA.meta && DATA.meta.lossPortfolioRatio)||30;
  const N=(DATA.meta && DATA.meta.lossNMonths)||3;
  const OD=(DATA.meta && DATA.meta.lossOverdueDays)||60;
  if(foot) foot.innerHTML='<b>规则</b>：①机会性商机连续 '+N+' 个月无活动/无订单 → 黄（潜在断尾风险）；'
    + '②上一年度机会性商机已过预计成交窗口 '+OD+' 天仍未成交 → 红；③机会性占本季度预计收入 >'+TH+'% → 组合风险提示。'
    + '<br><b>组合风险（规则③）</b>：机会性商机预计收入 ¥'+fmtInt(oppAmt)+' / 合计 ¥'+fmtInt(total)
    +' = '+ratio.toFixed(1)+'%（阈值 >'+TH+'%）→ '+(ratio>TH?'<b style="color:#b42318">触发组合风险提示</b>':'未触发');
  bindRiskToggles(list);
}

/* ---- 卡② 客诉 → 替换 → 采购恢复周期 ---- */
function replState(c){
  const stages=(c.stages||[]);
  let curIdx=-1;
  stages.forEach(function(s,i){ if(s.in && !s.out) curIdx=i; });
  const closed=!!c.purchase_recover_date;
  if(closed) curIdx=stages.length-1;
  let elapsed=null, alert='green';
  if(!closed && curIdx>=0){
    elapsed=dayDiff(TODAY, stages[curIdx].in);
    const Y=(DATA.meta && DATA.meta.replYellowDays)||30, R=(DATA.meta && DATA.meta.replRedDays)||60;
    alert = elapsed>R ? 'red' : (elapsed>Y ? 'yellow' : 'green');
  }
  return {curIdx:curIdx, closed:closed, elapsed:elapsed, alert:alert};
}
function renderReplace(){
  const list=$('replList'), foot=$('replFoot');
  if(!list) return;
  const cases=(DATA.meta && DATA.meta.replaceCases)||[];
  const stages=(DATA.meta && DATA.meta.replStages)||[];
  list.innerHTML = cases.map(function(c){
    const m=replState(c);
    const cycle=dayDiff(c.replace_done_date, c.complaint_date);
    const recover=dayDiff(c.purchase_recover_date, c.complaint_date);
    const lvLabel = m.closed ? '✅ 已闭环' : (m.alert==='red' ? '红预警' : (m.alert==='yellow' ? '黄预警' : '进行中'));
    const lvCls = m.closed ? 'green' : m.alert;
    let segs='';
    stages.forEach(function(name,i){
      let cls='';
      if(m.closed) cls='done';
      else if(i<m.curIdx) cls='done';
      else if(i===m.curIdx) cls='cur'+(m.alert==='red'?' bad':(m.alert==='yellow'?' warn':''));
      segs+='<div class="rp-seg '+cls+'" title="'+esc(name)+'">'+esc(name)+'</div>';
    });
    let meta='<span>客诉 <b>'+esc(c.complaint_date)+'</b></span>'
      + '<span>替换完成 <b>'+esc(c.replace_done_date||'—')+'</b></span>'
      + '<span>替换周期 <b>'+(cycle==null?'—':cycle+' 天')+'</b></span>';
    if(recover!=null) meta+='<span>采购恢复周期 <b>'+recover+' 天</b></span>';
    if(!m.closed && m.elapsed!=null) meta+='<span>当前阶段已耗时 <b>'+m.elapsed+' 天</b></span>';
    const pairs=[['案例编号',c.case_id],['客户',c.customer],['客诉单（关联售后单）',c.ticket],
      ['原货号',c.orig_sku],['替品货号',c.repl_sku],['客诉发生日',c.complaint_date],['受影响起始月',c.affected_month],
      ['替换完成日',c.replace_done_date||'（未完成）'],['采购恢复确认日',c.purchase_recover_date||'（未确认）'],
      ['替换周期',(cycle==null?'—':cycle+' 天')],['采购恢复周期',(recover==null?'—':recover+' 天')],
      ['当前阶段',stages[m.curIdx]||'—'],['状态',c.status]];
    (c.stages||[]).forEach(function(s){
      const d=(s.in && s.out) ? dayDiff(s.out,s.in) : (s.in ? dayDiff(TODAY,s.in) : null);
      pairs.push(['阶段·'+s.name, (s.in||'—')+' → '+(s.out||'进行中')+(d!=null?'（停留 '+d+' 天）':'')]);
    });
    pairs.push(['说明', c.note||'']);
    return '<div class="risk-item" data-lv="'+lvCls+'" data-case="'+esc(c.case_id)+'">'
      + '<div class="ri-top"><span class="lv '+lvCls+'">'+lvLabel+'</span>'
      + '<span class="ri-cust">'+esc(c.customer)+'</span>'
      + '<span class="ri-rule">'+esc(c.case_id)+' · '+esc(c.status)+'</span></div>'
      + '<div class="ri-reason">原货号 <b>'+esc(c.orig_sku)+'</b> → 替品 <b>'+esc(c.repl_sku)+'</b>'
      + ' ｜ 客诉 '+esc(c.complaint_date)+' → 替换完成 '+esc(c.replace_done_date||'—')
      + ' ｜ 替换周期 <b>'+(cycle==null?'—':cycle+' 天')+'</b>'
      + (recover!=null?' ｜ 采购恢复周期 '+recover+' 天':'')+'</div>'
      + '<div class="rp-bar">'+segs+'</div>'
      + '<div class="rp-meta">'+meta+'</div>'
      + '<div class="ri-toggle">▸ 点击展开依据字段 / 阶段时间</div>'
      + '<div class="risk-basis">'+basisTable(pairs)+'</div></div>';
  }).join('');
  $('replN').textContent=cases.length;
  let timely=0;
  cases.forEach(function(c){
    const st=(c.stages||[]).filter(function(s){ return s.name==='定位中'; })[0];
    if(st && st.in){ const d=(st.out ? dayDiff(st.out,st.in) : dayDiff(TODAY,st.in)); if(d!=null && d<=7) timely++; }
  });
  const base=(DATA.meta && DATA.meta.replBaselineDays)||60;
  if(foot) foot.innerHTML='<b>指标</b>：替换周期天数 = 替换完成日 − 客诉发生日；采购恢复周期天数 = 采购恢复确认日 − 客诉发生日；'
    + '响应及时率 = 定位中耗时 ≤ 7 天的案例数 / 总案例数 = '+timely+' / '+cases.length
    + ' = '+(cases.length?(timely/cases.length*100).toFixed(1):'0')+'%'
    + '<br><b>案例基线</b>：天隆 M5244-ZJ4 → MD2104-003006 实测替换周期 ≈ '+base+' 天；'
    + '告警：任一案例停留某阶段 > 30 天 → 黄；> 60 天 → 红。';
  bindRiskToggles(list);
}
function renderRisk(){
  renderOppLoss(); renderReplace();
  window.__riskOK = {oppLoss:document.querySelectorAll('#oppLossList .risk-item').length,
    replace:document.querySelectorAll('#replList .risk-item').length};
}

/* ================= init ================= */
initForm();
initWeekly();
renderL1();
setView('rail');
renderL2();
document.querySelectorAll('#natfBar .natf').forEach(function(b){
  b.onclick=function(){ applyNatFilter(b.getAttribute('data-nat')); };
});
renderNatFilter();
renderRisk();
window.__demoOK = {
  layer:'two-layer', defaultView:curView,
  l1:{cols:allCols().length, rows:L1.depts.reduce((a,d)=>a+d.rows.length,0)},
  nodes:NODES.length, lanes:LANES.length,
  l2rows:activities.length, actTypes:DATA.meta.actTypes, supportTypes:DATA.meta.supportTypes,
  weeklyGroup:'周会决议', weeklyRoles:['sales','tech','rnd'],
  descBlocks:4, descTemplates:Object.keys(TPLS), reqSegs:REQ_SEGS,
  natures:natCounts(), risk:window.__riskOK, replStages:(DATA.meta.replStages||[]).length,
  replaceCases:(DATA.meta.replaceCases||[]).length
};
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('__ICONS__', json.dumps(ICONS, ensure_ascii=False))
    html = html.replace('__TPLS__', json.dumps(CT.DESC_TEMPLATES, ensure_ascii=False))
    html = html.replace('__REQSEGS__', json.dumps(CT.REQ_SEGS, 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 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]
    for pat in ('<ti'+'tle', '<b'+'r'):
        pass  # JS 模板串里通过拼接规避，静态断言以 svgGuard 运行期为准
    with io.open(OUT, 'w', encoding='utf-8') as f:
        f.write(html)
    _node_check(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 / 资源协助列 %d'
          % (len(DATA['layer1']['depts']), len(CT.flat_rows()), len(NODE_COLS), len(RES_COLS)))
    print('   第二层：已录入活动 %d 行 / 资源泳道 %d 类' % (len(DATA['activities']), len(CT.LANES)))
    print('   默认视图：轨道视图（形态1 里程碑轨道）；点行 → 资源泳道矩阵（形态B）')


def _node_check(html):
    """对页面内联 JS 跑 node --check（语法铁律）。"""
    import subprocess, tempfile
    m = re.search(r'<script>(.*?)</script>', html, re.S)
    if not m:
        return
    fp = tempfile.NamedTemporaryFile('w', suffix='.js', delete=False, encoding='utf-8')
    fp.write(m.group(1))
    fp.close()
    try:
        r = subprocess.run(['node', '--check', fp.name], capture_output=True, text=True)
        assert r.returncode == 0, '内联 JS 语法错误：\n%s' % (r.stderr or r.stdout)
    finally:
        os.unlink(fp.name)


if __name__ == '__main__':
    main()
