#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
客户画像 P0 · 活动录入字段扩充 —— 可调试可编辑 DEMO 生成器
产出：自包含单页 HTML（零外部依赖）
改版一律改本生成器再重出，不手改成品。

【铁律】所有工作文档必须含 Logo + slogan —— 统一由 脚本/brand_kit.py 注入，勿手写。
"""
import io, os, json, re, sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import brand_kit as BK          # Logo + slogan 唯一权威实现

OUT = os.path.expanduser('~/Desktop/Hermes输出-工作类/知识沉淀/客户画像P0与测试反馈模块_定版DEMO_20260929.html')

# ─────────────────────────────────────────────────────────────
# 字段规格（DEMO 的初始值；页面上可实时编辑）
# ─────────────────────────────────────────────────────────────
SPEC = {
  "version": "v0.2",
  "updated": "2026-09-29",
  "changelog": "v0.2：① 按刘新元 09-29 定版落地 4 项决策（corp_nature 拆为 行业/公司类型/市场场景 三字段；新增 biz_type；segment 按原样保留；influence_tier 保持角色分类）；② 项目画像的 test_status/test_feedback 改为引用，不再自建；③ 新增第 5 卡【测试反馈模块重构】13 字段，独立集合。",
  "cards": [
    {
      "id": "reg", "title": "工商与资质", "func": "custBlockHtml",
      "status": "new",
      "desc": "成立时间 · 资本 · 融资 · 规模 · 属性 · 战略 · 资质 · 风险",
      "hint": "框架里完全没有的 8 个维度。成立时间/注册资本/人员规模等可由天眼查批量导出自动灌入，销售只在不对时修正。建议整卡默认折叠。",
      "fields": [
        {"k": "founded_year", "label": "成立时间", "type": "text", "ph": "如 2015",
         "unit": "年", "auto": True, "on": True, "note": "天眼查可自动"},
        {"k": "registered_capital_wan", "label": "注册资本", "type": "text", "ph": "如 5000",
         "unit": "万元", "auto": True, "on": True, "note": "天眼查可自动"},
        {"k": "financing_status", "label": "融资 / 上市情况", "type": "select",
         "enums": ["未融资", "天使 ~ A 轮", "B 轮及以后", "已上市（科创板 / 创业板）", "已上市（主板 / 新三板）", "其他"],
         "auto": True, "on": True, "todo": True, "note": "档位待定版"},
        {"k": "headcount_scale", "label": "人员规模", "type": "select",
         "enums": ["< 50 人", "50 ~ 200 人", "200 ~ 500 人", "500 ~ 1000 人", "> 1000 人"],
         "auto": True, "on": True, "todo": True, "note": "档位待定版"},
        {"k": "corp_nature", "label": "客户行业", "type": "select",
         "enums": ["人医", "动保", "NGS", "科研服务", "其他"],
         "auto": True, "on": True, "note": "✅ 已定版（刘新元 09-29）：客户【属于哪个行业】"},
        {"k": "biz_type", "label": "公司类型", "type": "select",
         "enums": ["生产型", "贸易型", "服务型", "合资外资"],
         "on": True, "note": "🆕 已定版：经营形态（生产／贸易这类区分），与行业、市场场景三权分立"},
        {"k": "strategy_focus", "label": "市场场景", "type": "multi",
         "enums": ["呼吸道", "肿瘤", "HPV", "法医", "宠物", "科研高校", "其他"],
         "on": True, "note": "✅ 已定版（09-29）：客户【主营业务在哪个市场场景经营】· 多选"},
        {"k": "license_status", "label": "资质情况", "type": "select",
         "enums": ["已有注册证", "注册检 · 临床中", "研发阶段", "不涉及报证"],
         "on": True, "note": "复用三表一书 f_regStage"},
        {"k": "license_count", "label": "注册证数", "type": "text", "ph": "如 12",
         "auto": True, "on": True, "note": "天眼查可自动"},
        {"k": "registering_products", "label": "在研 / 在注册产品", "type": "textarea",
         "ph": "如 呼吸道7重（注册检中）、HPV分型（临床中）", "on": True},
        {"k": "risk_notes", "label": "资金链 / 经营风险", "type": "textarea",
         "ph": "如 2026Q2 起回款明显放缓，账期从 60 天拉长到 120 天", "on": True,
         "note": "必须带来源"},
        {"k": "risk_note_source", "label": "风险来源", "type": "text",
         "ph": "如 客户访谈 2026-08 / 年报 / 公开司法信息", "on": True}
      ]
    },
    {
      "id": "dec", "title": "决策链 · 行内新增", "func": "custBlockHtml",
      "status": "ext",
      "desc": "层级 · 汇报对象 · 态度 · 沟通偏好 · 关注点（行内字段）",
      "hint": "框架要求「层级树」+「性格」。层级用 reports_to 建树；「性格」不采，拆成沟通偏好与关注点两个客观字段（现有一个框拆成两个）。",
      "fields": [
        {"k": "influence_tier", "label": "层级", "type": "select",
         "enums": ["决策人", "技术把关", "采购执行", "影响者", "使用者"],
         "on": True, "note": "✅ 已定版（09-29）：保留【角色功能分类】；组织树由 reports_to 承担，一字段一用途"},
        {"k": "reports_to", "label": "汇报对象", "type": "text",
         "ph": "填本表内另一人的姓名", "on": True, "note": "行内 · 用于建层级树"},
        {"k": "attitude", "label": "决策人态度", "type": "select",
         "enums": ["支持", "中立", "抵触"], "on": True, "note": "行内 · 复用 kp 枚举"},
        {"k": "comm_pref", "label": "沟通偏好", "type": "text",
         "ph": "如 只认量化数据，需提前发对比表", "on": True, "note": "行内 · 从原单框拆出"},
        {"k": "focus_point", "label": "关注点", "type": "text",
         "ph": "如 性能 / 价格 / 账期 / 技术支持", "on": True, "note": "行内 · 从原单框拆出"}
      ]
    },
    {
      "id": "mode", "title": "决策模式", "func": "custBlockHtml",
      "status": "new",
      "desc": "6 问 · 直接决定报价与打法",
      "hint": "框架点明「研发定标 → 采购再砍价 → 首轮要留空间 → 注册与优化分开」。这 6 问是报价策略的输入。",
      "fields": [
        {"k": "price_priority", "label": "选择倾向", "type": "select",
         "enums": ["性能优先", "价格优先", "性能优先，但价格是门槛"], "on": True, "todo": True},
        {"k": "require_backup_vendor", "label": "必须有备选供应商", "type": "select",
         "enums": ["是", "否", "不确定"], "on": True},
        {"k": "vendor_decider", "label": "谁是定标人", "type": "select",
         "enums": ["研发决定", "采购决定", "老板决定", "多方博弈"], "on": True, "todo": True},
        {"k": "negotiation_rounds", "label": "砍价轮次", "type": "select",
         "enums": ["1 轮到底", "2 轮（研发谈价 + 采购再砍）", "3 轮及以上", "不确定"], "on": True, "todo": True},
        {"k": "quote_buffer", "label": "首轮是否需留空间", "type": "select",
         "enums": ["需要留空间", "可直接报底价"], "on": True},
        {"k": "phase_split_reg_opt", "label": "注册与优化是否分开", "type": "select",
         "enums": ["是（先注册，后优化）", "否（一次到位）"], "on": True},
        {"k": "pain_labor_shortage", "label": "痛点 · 人员不足", "type": "multi",
         "enums": ["研发/技术人手不足", "采购人手不足", "无此痛点"], "on": True},
        {"k": "pain_import_cost", "label": "痛点 · 降本诉求", "type": "multi",
         "enums": ["现用进口原料成本高想替换", "竞品涨价 / 批间差不稳", "货期长 / 断供风险"], "on": True},
        {"k": "pain_notes", "label": "痛点补充说明", "type": "textarea",
         "ph": "如 2026 年新设降本 KPI，要求原料成本降 15%", "on": True}
      ]
    },
    {
      "id": "proj", "title": "项目画像", "func": "projBlockHtml",
      "status": "new",
      "desc": "板块 · 产品线 · 年出货量 · 体系 · 现用品牌 · 测试 · 机会/挑战/支持/突破",
      "hint": "框架「客户产品」板块要求按【项目粒度】记录量纲。注意 top_products 是「我司卖给客户的量」，不是「客户成品项目的年出货量」。",
      "fields": [
        {"k": "segment", "label": "板块归属", "type": "select",
         "enums": ["IVD 注册", "公卫", "国外", "宠物", "动物", "科研"], "on": True,
         "note": "✅ 已定版（09-29）：按原样【保留】（已知与 strategy_focus 在 宠物/科研 上有重叠，用户明确接受）"},
        {"k": "product_line", "label": "产品线", "type": "text",
         "ph": "如 呼吸道6重 / 甲乙流 / HPV分型", "on": True},
        {"k": "annual_volume_t", "label": "年出货量", "type": "text",
         "ph": "如 300", "on": True, "note": "配合单位"},
        {"k": "volume_unit", "label": "出货量单位", "type": "select",
         "enums": ["T / 测试数", "人份", "万人份", "kg", "L"], "on": True},
        {"k": "reaction_system", "label": "反应体系", "type": "text",
         "ph": "如 25ul / 单管全预混", "on": True},
        {"k": "incumbent_brand", "label": "现用原料品牌", "type": "text",
         "ph": "如 诺唯赞", "on": True, "note": "可由 suppliers/main_rival 预填"},
        {"k": "test_ref", "label": "关联测试反馈", "type": "text", "ro": True,
         "ph": "（只读）由「测试反馈」模块按 客户+货号 自动带出，此处不编辑",
         "on": True,
         "note": "🔁 已改为【引用】而非自建：原 test_status/test_feedback 两个字段删除，统一走新测试反馈模块，杜绝两套口径"},
        {"k": "opportunity", "label": "机会", "type": "textarea",
         "ph": "如 客户 2027 年要报 7 重，届时必须换供应商", "on": True},
        {"k": "challenge", "label": "挑战", "type": "textarea",
         "ph": "如 客户对批间差极敏感，已签年度框架", "on": True},
        {"k": "support_needed", "label": "需要的支持", "type": "textarea",
         "ph": "如 需要技术支持上门做体系适配实验", "on": True},
        {"k": "breakthrough", "label": "突破方向", "type": "textarea",
         "ph": "如 先以荧光定量酶单点切入，再扩到全预混", "on": True}
      ]
    },
    {
      "id": "fb", "title": "测试反馈（模块重构）", "func": "测试反馈模块 · 新页",
      "status": "new",
      "payload_key": "test_feedback",
      "desc": "反馈部门 · 客户 · 订单 · 货号 · 日期 · 有效否 · 背景 · 参数 · 结果+附件 · 下一步",
      "hint": "🔁 本卡对应【工具/科研测试反馈.html 的重构】，不是活动录入里的字段。两条架构规则：① 加一个货号 → 自动单独带出一组本模板（一货号一组，可重复）；② 本页 = 全工作台测试反馈数据的【唯二来源】之一，另一个来源是刘新元单独指定可计为测试反馈的记录。",
      "fields": [
        {"k": "fb_dept", "label": "反馈部门", "type": "text", "ro": True, "auto": True, "on": True,
         "ph": "（只读）按提交人所在部门自动带出",
         "note": "架构规则：不手填，由人员部门自动带出"},
        {"k": "fb_customer", "label": "关联客户", "type": "text", "ph": "如 圣湘生物", "on": True},
        {"k": "fb_order", "label": "关联订单", "type": "text", "ph": "如 SO2026xxxx", "on": True,
         "note": "可与 test_order 结构对齐"},
        {"k": "fb_sku", "label": "关联货号", "type": "text", "ph": "如 E20-NA / M-MLV-200U",
         "on": True,
         "note": "⭐ 关键：加一个货号 → 自动单独带出一组本模板（一货号一组）"},
        {"k": "fb_date", "label": "反馈日期", "type": "text", "ph": "如 2026-09-29", "on": True},
        {"k": "fb_valid", "label": "是否有效反馈", "type": "select",
         "enums": ["有效反馈", "无效反馈", "待判定"], "on": True},
        {"k": "fb_background", "label": "项目背景", "type": "textarea",
         "ph": "如 客户在报呼吸道7重，现用竞品酶在低浓度样本上 Ct 偏晚", "on": True},
        {"k": "fb_params", "label": "具体需求参数", "type": "textarea",
         "ph": "如 25ul 体系 / 单管全预混 / 检出限 500 copies/mL / 批间 CV < 5%", "on": True},
        {"k": "fb_result", "label": "测试结果（语言描述）", "type": "textarea",
         "ph": "如 扩增时间与竞品同档，CV 略高 1.2 个百分点", "on": True},
        {"k": "fb_files", "label": "测试结果附件", "type": "file", "attach": True, "on": True,
         "note": "不限附件类型 · 不限大小"},
        {"k": "fb_next", "label": "下一步计划（描述）", "type": "textarea",
         "ph": "如 寄送第二批样做批间差验证", "on": True},
        {"k": "fb_next_who", "label": "参与人", "type": "text", "ph": "如 赵云浩 / 李晓明（技术支持）", "on": True},
        {"k": "fb_next_when", "label": "时间节点", "type": "text", "ph": "如 2026-10-15 前", "on": True}
      ]
    }
  ]
}

# ── 后端白名单（实测取自 脚本/销售工作台服务.py 的 _clean_block / _block_empty）──
BLOCK_WL = ["opp","opp_status","opp_url","need","params","issue","feedback","competitors",
            "prices","price_on","decision","biz_desc","revenues","suppliers","self_share",
            "main_rival","visit_goal","visit_content","plan","test_order","order_no"]
SUB_WL = {
  "decision": ["name","title","role","power","phone","note"],
  "prices":   ["brand","sku","spec","qty","price","source","source_other"],
  "revenues": ["year","amt","source"],
  "suppliers":["name","project","amt","pct"],
  "plan":     ["item"],
  "test_order":["no","customer","sales","items"]
}
# 行内字段归属（决策链 = decision[] 子字段）
INLINE_OWNER = {"influence_tier":"decision","reports_to":"decision","attitude":"decision",
                "comm_pref":"decision","focus_point":"decision"}

# ── 断言：每个字段必须显式声明 on，否则 falsy 会被静默判为【停用】──
_noflag = [(c['id'], f['k']) for c in SPEC['cards'] for f in c['fields'] if 'on' not in f]
assert not _noflag, '字段缺少显式 "on" 标记（会被静默停用，KPI 与预览都不计入）: %s' % _noflag

SPEC_JSON = json.dumps(SPEC, ensure_ascii=False)
BLOCK_WL_JSON = json.dumps(BLOCK_WL, ensure_ascii=False)
SUB_WL_JSON = json.dumps(SUB_WL, ensure_ascii=False)
INLINE_OWNER_JSON = json.dumps(INLINE_OWNER, ensure_ascii=False)

# ─────────────────────────────────────────────────────────────
HTML = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>客户画像 P0 字段 + 测试反馈模块 · 定版 DEMO v0.2（可编辑）</title>
<style>
:root{--bad:#C0271D;--bg:#F5F7FA;--blue:#0043C1;--deep:#002D80;--grey:#94A3B8;
--ink:#0F172A;--line:#E4E9F2;--ok:#0d904f;--tint:#F1F5FE;--warn:#B45309;--purple:#6D28D9}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;padding-bottom:92px}
.topbar{height:52px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;
padding:0 20px;gap:13px;position:sticky;top:0;z-index:60}
.logo{font-weight:800;letter-spacing:.5px;color:var(--deep);font-size:15px;flex:none}
.logo b{color:var(--blue)}
.topbar .dv{width:1px;height:20px;background:var(--line);flex:none}
.topbar h1{font-size:14.5px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tagv{margin-left:auto;background:#FFF7ED;color:var(--warn);border:1px solid #FED7AA;
padding:3px 10px;border-radius:20px;font-size:12px;font-weight:700;flex:none}
.wrap{max-width:1560px;margin:0 auto;padding:16px 18px}
.lead{background:#fff;border:1px solid var(--line);border-left:4px solid var(--blue);border-radius:10px;
padding:14px 17px;margin-bottom:14px;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}
.lead .txt{flex:1;min-width:320px}
.lead h2{font-size:15px;margin-bottom:6px;color:var(--deep)}
.lead p{color:#475569;font-size:13px}
.kpis{display:flex;gap:9px;flex-wrap:wrap}
.kpi{background:var(--tint);border:1px solid #DCE6FA;border-radius:8px;padding:8px 13px;min-width:96px;text-align:center}
.kpi .v{font-size:19px;font-weight:800;color:var(--blue);line-height:1.2}
.kpi .l{font-size:11px;color:#64748B;margin-top:1px}
.kpi.bad{background:#FEF2F2;border-color:#FECACA}
.kpi.bad .v{color:var(--bad)}.kpi.bad .l{color:#991B1B}
.split{display:grid;grid-template-columns:minmax(420px,44%) 1fr;gap:14px;align-items:start}
@media(max-width:1100px){.split{grid-template-columns:1fr}}
.panel{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden}
.panel>h3{font-size:13.5px;font-weight:700;padding:11px 15px;background:var(--tint);
border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
.panel>h3 .s{margin-left:auto;font-size:11.5px;font-weight:600;color:#64748B}
.panel .body{padding:13px 15px}
.cardgrp{border:1px solid var(--line);border-radius:9px;margin-bottom:11px;overflow:hidden}
.cardgrp>h4{font-size:13px;font-weight:700;padding:9px 12px;background:#FAFBFD;
border-bottom:1px solid var(--line);display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none}
.cardgrp>h4 .n{background:var(--blue);color:#fff;min-width:20px;height:20px;padding:0 5px;border-radius:5px;
display:inline-flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:800;flex:none}
.cardgrp>h4 .n.ext{background:#0F766E}
.cardgrp>h4 .fn{font:11px/1 ui-monospace,Menlo,monospace;color:var(--blue);background:var(--tint);
padding:2px 6px;border-radius:4px;font-weight:600}
.cardgrp>h4 .cnt{margin-left:auto;font-size:11px;color:var(--grey);font-weight:600}
.cardgrp>h4 .cv{color:var(--grey);font-size:11px;transition:transform .15s}
.cardgrp.collapsed .cv{transform:rotate(-90deg)}
.cardgrp.collapsed .frows,.cardgrp.collapsed .ghint{display:none}
.ghint{font-size:11.5px;color:#64748B;padding:8px 12px;background:#FCFDFF;border-bottom:1px solid #F1F5F9}
.frow{display:grid;grid-template-columns:26px minmax(0,1fr) 106px auto;gap:7px;align-items:center;
padding:6px 12px;border-bottom:1px solid #F6F8FB;font-size:12.5px}
.frow:last-child{border-bottom:none}
.frow:hover{background:#FAFCFF}
.sw{width:32px;height:18px;border-radius:10px;background:#CBD5E1;position:relative;cursor:pointer;
transition:background .15s;flex:none}
.sw::after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;transition:left .15s}
.sw.on{background:var(--ok)}
.sw.on::after{left:16px}
.sw.card{width:36px;height:20px;margin-right:2px}
.sw.card::after{width:16px;height:16px}
.sw.card.on::after{left:18px}
.frow .lf{display:flex;flex-direction:column;gap:2px;min-width:0}
.frow input.lbl{border:1px solid transparent;background:transparent;font:600 12.5px/1.4 inherit;color:var(--ink);
padding:3px 5px;border-radius:4px;width:100%}
.frow input.lbl:hover{border-color:#DCE6FA;background:#fff}
.frow input.lbl:focus{border-color:var(--blue);background:#fff;outline:none}
.frow .k{font:11px/1 ui-monospace,Menlo,monospace;color:var(--blue);padding-left:5px}
.frow select.ty{border:1px solid #DCE6FA;border-radius:5px;font:11.5px inherit;padding:3px 5px;background:#fff;
color:#334155;width:100%}
.allow{display:flex;gap:5px;justify-content:flex-end;align-items:center;flex-wrap:wrap}
.bdg{font-size:10px;font-weight:700;padding:2px 6px;border-radius:9px;white-space:nowrap}
.bdg.auto{background:#ECFDF5;color:var(--ok);border:1px solid #A7F3D0}
.bdg.todo{background:#FFF7ED;color:var(--warn);border:1px solid #FED7AA}
.bdg.dead{background:#FEF2F2;color:var(--bad);border:1px solid #FECACA}
.bdg.ok{background:#F0FDF4;color:var(--ok);border:1px solid #BBF7D0}
.bdg.ro{background:#EFF6FF;color:#1D4ED8;border:1px solid #BFDBFE}
.dropzone{border:1.5px dashed #BFDBFE;background:#F8FAFF;border-radius:8px;padding:11px;text-align:center;
cursor:pointer;transition:background .15s,border-color .15s}
.dropzone:hover{background:#EFF6FF;border-color:#60A5FA}
.dropzone b{display:block;font-size:12px;color:#1D4ED8;font-weight:700}
.dropzone span{display:block;font-size:10.5px;color:#64748B;margin-top:3px}
.edenums{grid-column:2/-1;margin:-1px 0 5px;display:flex;gap:5px;align-items:flex-start;flex-wrap:wrap;
padding:0 0 0 5px}
.edenums .eb{border:1px solid #DCE6FA;border-radius:12px;background:#fff;font:11.5px inherit;
padding:2px 8px;color:#334155;min-width:40px;max-width:230px}
.edenums .eb:focus{border-color:var(--blue);outline:none;background:#fff}
.edenums .eb+.eb{margin-left:0}
.edenums .lab{font-size:10.5px;color:var(--grey);align-self:center;flex:none}
.pv{margin-bottom:13px}
.pv:last-child{margin-bottom:0}
.pv .pvh{font-size:13px;font-weight:700;margin-bottom:8px;padding-left:9px;border-left:3px solid var(--blue);
display:flex;align-items:center;gap:7px}
.pv .pvh .pvf{font:11px/1 ui-monospace,Menlo,monospace;color:var(--blue);background:var(--tint);
padding:2px 6px;border-radius:4px;font-weight:600}
.fgrid{display:grid;grid-template-columns:repeat(6,1fr);gap:11px}
.fld{display:flex;flex-direction:column;gap:4px;grid-column:span 2}
.fld.span3{grid-column:span 3}.fld.span6{grid-column:span 6}
.fld label{font-size:12px;font-weight:600;color:#334155;display:flex;align-items:center;gap:5px;flex-wrap:wrap}
.fld label .fk{font:10.5px/1 ui-monospace,Menlo,monospace;color:var(--blue);background:var(--tint);
padding:1px 5px;border-radius:3px;font-weight:600}
.fld input,.fld select,.fld textarea{border:1px solid #CBD5E1;border-radius:7px;padding:7px 9px;font-size:12.5px;
font-family:inherit;background:#fff;color:var(--ink);width:100%}
.fld textarea{resize:vertical;min-height:52px}
.fld input::placeholder,.fld textarea::placeholder{color:#A8B4C4;font-size:12px}
.fld .u{font-size:11px;color:var(--grey)}
.fld .multibox{display:flex;flex-wrap:wrap;gap:6px}
.fld .multibox label{display:inline-flex;align-items:center;gap:5px;border:1px solid #CBD5E1;border-radius:16px;
padding:4px 11px;font-size:12px;font-weight:500;cursor:pointer;background:#fff;white-space:nowrap}
.fld .multibox label:hover{border-color:var(--blue);background:var(--tint)}
.fld .multibox input{width:auto;accent-color:var(--blue)}
.jsonbox{background:#0F172A;border-radius:9px;padding:13px 15px;overflow:auto;max-height:340px}
.jsonbox pre{font:11.5px/1.65 ui-monospace,Menlo,monospace;color:#E2E8F0;white-space:pre-wrap;word-break:break-all}
.jsonbox .kp{color:#7DD3FC}.jsonbox .kd{color:#C4B5FD}.jsonbox .ks{color:#86EFAC}
.jsonbox .kb{color:#FCA5A5;font-weight:700}
.chklist{margin-top:12px;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.chklist h5{font-size:12.5px;font-weight:700;padding:8px 12px;background:#FAFBFD;border-bottom:1px solid var(--line)}
.chklist .ci{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #F6F8FB;font-size:12px}
.chklist .ci:last-child{border-bottom:none}
.chklist .ci code{font:11px/1 ui-monospace,Menlo,monospace;background:var(--tint);color:var(--blue);
padding:2px 5px;border-radius:3px}
.chklist .ci .st{margin-left:auto;font-weight:700;font-size:11px;white-space:nowrap}
.st.ok{color:var(--ok)}.st.dead{color:var(--bad)}
.st.na{color:#1D4ED8}
.toolbar{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--line);
padding:9px 18px;display:flex;gap:9px;align-items:center;z-index:70;box-shadow:0 -2px 12px rgba(15,23,42,.06);
flex-wrap:wrap}
.btn{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:8px 15px;font-size:12.5px;
font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap}
.btn:hover{background:var(--deep)}
.btn.gh{background:#fff;color:var(--blue);border:1px solid #C7D8F7}
.btn.gh:hover{background:var(--tint)}
.btn.dg{background:#fff;color:var(--bad);border:1px solid #FECACA}
.btn.dg:hover{background:#FEF2F2}
.state{font-size:12px;color:#64748B;margin-left:6px}
.state.ok{color:var(--ok);font-weight:600}
.warnbox{background:#FFFBEB;border:1px solid #FDE68A;border-radius:9px;padding:11px 14px;font-size:12.5px;
color:#78350F;margin-bottom:13px}
.warnbox b{color:#92400E}
.redbox{background:#FEF2F2;border:1px solid #FECACA;border-radius:9px;padding:11px 14px;font-size:12.5px;
color:#7F1D1D;margin-bottom:13px}
.redbox b{color:#991B1B}
.mono{font:11.5px/1.5 ui-monospace,Menlo,monospace}
.hintline{font-size:11.5px;color:#64748B;margin-top:7px}
.hintline b{color:var(--deep)}
.tabs{display:flex;gap:5px;margin-bottom:11px;flex-wrap:wrap}
.tabs button{border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 13px;font-size:12.5px;
font-weight:600;cursor:pointer;font-family:inherit;color:#475569}
.tabs button.on{background:var(--blue);color:#fff;border-color:var(--blue)}
@media print{.toolbar,.tagv{display:none}body{padding-bottom:0}}

/* ── 品牌页眉常驻（Logo + slogan 滚动时始终可见）── */
.bior-header{position:sticky;top:0;z-index:71}
.topbar{position:static;z-index:auto}
</style>
</head>
<body>

<div class="topbar">
  <h1>客户画像 P0 字段 + 测试反馈模块 · 定版 DEMO</h1>
  <div class="tagv">🧪 原型 v0.2 · 可编辑</div>
</div>

<div class="wrap">

  <div class="lead">
    <div class="txt">
      <h2>这是什么</h2>
      <p><b>v0.2 一个版本两件事</b>：①《客户画像》框架里工作台缺失的字段；② <b>测试反馈模块重构</b>。左侧可直接改字段（改名／改枚举／换控件／整卡开关）；右侧实时看到<b>销售会看到的界面</b>与<b>提交后生成的 JSON</b>，并<b>实时标出哪些 key 会被后端丢弃</b>。改完点「导出定版」把配置发我，我照此实施。</p>
      <p style="margin-top:6px;font-size:12.5px;color:#B45309;background:#FFFBEB;border-left:3px solid #FCD34D;padding:7px 10px;border-radius:0 6px 6px 0">
        <b>09-29 定版结果已灌入</b>：① `corp_nature` 拆成「客户行业／公司类型／市场场景」三字段并新增 <code>biz_type</code>；② `segment` 按你要求<b>原样保留</b>（宠物/科研与市场场景的重叠你已确认接受）；③ `influence_tier` 保持角色分类，组织树交给 `reports_to`；④ 新增第 5 卡「测试反馈模块重构」。
      </p>
    </div>
    <div class="kpis" id="kpis"></div>
  </div>

  <div class="tabs">
    <button class="on" data-tab="all">全部展开</button>
    <button data-tab="cfg">只看配置</button>
    <button data-tab="pv">只看预览</button>
  </div>

  <div class="split" id="split">

    <!-- ══ 左：字段配置 ══ -->
    <div class="panel" id="panelCfg">
      <h3>① 字段配置 <span style="font-weight:400;color:var(--grey);font-size:11.5px">（直接改，自动保存）</span>
        <span class="s" id="cfgStat"></span></h3>
      <div class="body" id="cfgBody"></div>
    </div>

    <!-- ══ 右：预览 + JSON ══ -->
    <div>
      <div class="panel" style="margin-bottom:14px">
        <h3>② 实时预览 <span style="font-weight:400;color:var(--grey);font-size:11.5px">（销售看到的界面）</span>
          <span class="s" id="pvStat"></span></h3>
        <div class="body" id="pvBody"></div>
      </div>
      <div class="panel">
        <h3>③ 提交载荷（JSON）＋ 后端白名单体检
          <span class="s" id="jsStat"></span></h3>
        <div class="body">
          <div class="redbox" id="wlAlert"></div>
          <div class="jsonbox"><pre id="jsonOut"></pre></div>
          <div class="chklist" id="wlList"></div>
          <div class="hintline">
            <b>白名单来源</b>：<span class="mono">脚本/销售工作台服务.py</span> 的 <span class="mono">_clean_block()</span>
            —— 它用 20 个 <span class="mono">b.get('…')</span> 逐字段重建 dict，<b>不在表内的 key 一律丢弃且不报错</b>。
            <span class="mono">_block_empty()</span> 同样只看旧字段：销售若只填新卡片，整块会被判「空块」丢弃。
          </div>
        </div>
      </div>
    </div>

  </div>
</div>

<div class="toolbar">
  <button class="btn" onclick="exportSpec()">⬇ 导出定版 JSON</button>
  <button class="btn gh" onclick="exportHtml()">⬇ 导出 HTML</button>
  <button class="btn gh" onclick="showJson()">{ } 查看完整规格</button>
  <button class="btn dg" onclick="resetAll()">⟲ 重置</button>
  <span class="state" id="state"></span>
</div>

<script>
var LS_KEY = 'bior_p0_field_demo_v1';
var ORIG = __SPEC__;
var BLOCK_WL = __BLOCK_WL__;
var SUB_WL = __SUB_WL__;
var INLINE_OWNER = __INLINE_OWNER__;

var SPEC = null;

function clone(o){ return JSON.parse(JSON.stringify(o)); }

/* ── 保存 / 恢复 ── */
function save(){
  try{ localStorage.setItem(LS_KEY, JSON.stringify(SPEC)); setState('已保存', true); }
  catch(e){ setState('保存失败: '+e.message, false); }
}
function restore(){
  try{
    var s = localStorage.getItem(LS_KEY);
    if(!s) return false;
    var d = JSON.parse(s);
    if(!(d && d.cards && d.cards.length)) return false;
    // ⚠️ 版本护栏：旧版存档缺新卡片（如 v0.1 无第 5 卡「测试反馈模块」），
    //    直接套用会让人以为「卡片丢了」。版本不符 → 丢弃存档，回到新版默认。
    if(d.version !== ORIG.version){
      localStorage.removeItem(LS_KEY);
      window.__STALE = d.version || '(无版本号)';
      return false;
    }
    SPEC = d; return true;
  }catch(e){}
  return false;
}
function setState(t, ok){
  var el = document.getElementById('state');
  el.textContent = t;
  el.className = 'state' + (ok ? ' ok' : '');
}

/* ── 统计派生 ── */
function stats(){
  var r = {cards:0, fields:0, keys:0, dead:0, deadKeys:[], todo:0, auto:0, inlineKeys:[]};
  SPEC.cards.forEach(function(c){
    if(c.on === false) return;
    r.cards++;
    c.fields.forEach(function(f){
      if(!f.on) return;
      r.fields++;
      r.keys++;
      if(f.auto) r.auto++;
      if(f.todo) r.todo++;
      if(c.payload_key){ r.na = (r.na||0) + 1; return; }   // 独立模块，不走 _clean_block
      var owner = INLINE_OWNER[f.k];
      if(owner){
        if((SUB_WL[owner]||[]).indexOf(f.k) < 0){ r.dead++; r.deadKeys.push(f.k); r.inlineKeys.push(f.k); }
      } else if(BLOCK_WL.indexOf(f.k) < 0){
        r.dead++; r.deadKeys.push(f.k);
      }
    });
  });
  return r;
}

/* ── 渲染 KPI ── */
function renderKpis(){
  var s = stats();
  document.getElementById('kpis').innerHTML =
    '<div class="kpi"><div class="v">'+s.cards+'</div><div class="l">卡片</div></div>' +
    '<div class="kpi"><div class="v">'+s.keys+'</div><div class="l">启用字段</div></div>' +
    '<div class="kpi"><div class="v">'+s.auto+'</div><div class="l">可自动获取</div></div>' +
    '<div class="kpi"><div class="v">'+(s.na||0)+'</div><div class="l">独立模块字段</div></div>' +
    '<div class="kpi"><div class="v">'+s.todo+'</div><div class="l">待你定版</div></div>' +
    '<div class="kpi bad"><div class="v">'+s.dead+'</div><div class="l">需加进后端白名单</div></div>';
}

/* ── 渲染配置面板 ── */
function renderCfg(){
  var h = '';
  SPEC.cards.forEach(function(c, ci){
    var liveF = c.fields.filter(function(f){ return f.on; }).length;
    h += '<div class="cardgrp'+(c.collapsed?' collapsed':'')+'" data-ci="'+ci+'">'
      + '<h4 data-act="fold"><span class="n'+(c.status==='ext'?' ext':'')+'">'+(ci+1)+'</span>'
      + esc(c.title)
      + '<span class="fn">'+esc(c.func)+'</span>'
      + '<span class="sw card'+(c.on === false ? '' : ' on')+'" data-act="ctoggle" title="整卡启用 / 停用"></span>'
      + '<span class="cnt">'+liveF+'/'+c.fields.length+' 字段</span>'
      + '<span class="cv">▼</span></h4>'
      + '<div class="ghint">'+esc(c.hint||'')+'</div>'
      + '<div class="frows">';
    c.fields.forEach(function(f, fi){
      var owner = INLINE_OWNER[f.k];
      var inWl = owner ? ((SUB_WL[owner]||[]).indexOf(f.k) >= 0) : (BLOCK_WL.indexOf(f.k) >= 0);
      h += '<div class="frow" data-ci="'+ci+'" data-fi="'+fi+'">'
        + '<div class="sw'+(f.on?' on':'')+'" data-act="toggle" title="启用 / 停用"></div>'
        + '<div class="lf"><input class="lbl" data-act="label" value="'+esc(f.label)+'" spellcheck="false">'
        + '<span class="k">'+esc(f.k)+(owner?' <span style="color:#94A3B8">← '+owner+'[] 子字段</span>':'')+'</span></div>'
        + '<select class="ty" data-act="type">'
          + ['text','textarea','select','multi','file'].map(function(t){
              return '<option value="'+t+'"'+(f.type===t?' selected':'')+'>'+({text:'单行文本',textarea:'多行文本',select:'下拉单选',multi:'多选标签',file:'附件上传'})[t]+'</option>';
            }).join('')
        + '</select>'
        + '<div class="allow">'
          + (f.auto ? '<span class="bdg auto">自动</span>' : '')
          + (f.ro ? '<span class="bdg ro">只读</span>' : '')
          + (f.todo ? '<span class="bdg todo">待定版</span>' : '')
          + (c.payload_key ? '<span class="bdg ok">独立模块</span>'
                           : (inWl ? '<span class="bdg ok">后端✓</span>' : '<span class="bdg dead">后端✗</span>'))
        + '</div>'
        + '</div>';
      if(f.type === 'select' || f.type === 'multi'){
        h += '<div class="edenums" data-ci="'+ci+'" data-fi="'+fi+'">'
          + '<span class="lab">枚举（可改／可加）:</span>';
        (f.enums||[]).forEach(function(e, ei){
          h += '<input class="eb" data-act="enum" data-ei="'+ei+'" value="'+esc(e)+'" spellcheck="false">';
        });
        h += '<input class="eb" data-act="enumadd" placeholder="+ 新增" style="width:64px;color:#94A3B8">';
        h += '</div>';
      }
    });
    h += '</div></div>';
  });
  document.getElementById('cfgBody').innerHTML = h;
  var s = stats();
  document.getElementById('cfgStat').textContent = s.keys + ' 个启用字段';
}

/* ── 渲染预览 ── */
function renderPreview(){
  var h = '';
  SPEC.cards.forEach(function(c, ci){
    var fs = c.fields.filter(function(f){ return f.on; });
    if(!fs.length) return;
    h += '<div class="pv"><div class="pvh"><span class="n" style="background:var(--blue);color:#fff;'
      + 'min-width:20px;height:20px;padding:0 5px;border-radius:5px;display:inline-flex;align-items:center;'
      + 'justify-content:center;font-size:11.5px;font-weight:800">'+(ci+1)+'</span>'
      + esc(c.title) + '<span class="pvf">'+esc(c.func)+'</span>'
      + (c.status==='ext' ? '<span class="bdg todo">行内新增</span>' : '<span class="bdg auto">新增卡片</span>')
      + '</div><div class="fgrid">';
    fs.forEach(function(f){
      var wide = (f.type === 'textarea' || f.type === 'multi' || f.type === 'file') ? ' span6' : '';
      h += '<div class="fld'+wide+'"><label>'+esc(f.label)
        + '<span class="fk">'+esc(f.k)+'</span>'
        + (f.auto?'<span class="bdg auto">自动</span>':'')
        + (f.ro?'<span class="bdg ro">只读带出</span>':'')
        + (f.attach?'<span class="bdg auto">支持附件</span>':'')
        + '</label>';
      if(f.type === 'text'){
        h += '<input placeholder="'+esc(f.ph||'')+'"'+(f.ro?' disabled':'')+'>';
      }else if(f.type === 'textarea'){
        h += '<textarea placeholder="'+esc(f.ph||'')+'"'+(f.ro?' disabled':'')+'></textarea>';
      }else if(f.type === 'select'){
        h += '<select'+(f.ro?' disabled':'')+'><option value="">请选择…</option>'
          + (f.enums||[]).map(function(e){ return '<option>'+esc(e)+'</option>'; }).join('')
          + '</select>';
      }else if(f.type === 'multi'){
        h += '<div class="multibox">'
          + (f.enums||[]).map(function(e){ return '<label><input type="checkbox"'+(f.ro?' disabled':'')+'>'+esc(e)+'</label>'; }).join('')
          + '</div>';
      }else if(f.type === 'file'){
        h += '<div class="dropzone"><b>📎 点击或拖拽上传附件</b>'
          + '<span>不限附件类型 · 不限大小</span></div>';
      }
      if(f.unit) h += '<span class="u">单位：'+esc(f.unit)+'</span>';
      if(f.note) h += '<span class="u">'+esc(f.note)+'</span>';
      h += '</div>';
    });
    h += '</div></div>';
  });
  document.getElementById('pvBody').innerHTML = h || '<div class="hintline">全部字段已停用。</div>';
  var n = stats().keys;
  document.getElementById('pvStat').textContent = n + ' 个字段在渲染';
}

/* ── 渲染 JSON + 白名单体检 ── */
function renderJson(){
  var payload = {}, dead = [], inlineDead = [];
  SPEC.cards.forEach(function(c){
    if(c.on === false) return;
    // 独立模块（如测试反馈）：不走 _clean_block，单独成数组载荷 —— 数组结构正好体现「一货号一组」
    if(c.payload_key){
      var grp = {};
      c.fields.forEach(function(f){ if(f.on) grp[f.k] = sample(f); });
      payload[c.payload_key] = [grp];
      return;
    }
    c.fields.forEach(function(f){
      if(!f.on) return;
      var owner = INLINE_OWNER[f.k];
      if(owner){
        payload[owner] = payload[owner] || [];
        if(!payload[owner].length) payload[owner].push({});
        payload[owner][0][f.k] = sample(f);
        if((SUB_WL[owner]||[]).indexOf(f.k) < 0) inlineDead.push(owner + '[] . ' + f.k);
      } else {
        payload[f.k] = sample(f);
        if(BLOCK_WL.indexOf(f.k) < 0) dead.push(f.k);
      }
    });
  });
  var s = stats();
  document.getElementById('jsonOut').innerHTML = hl(JSON.stringify(payload, null, 2));
  document.getElementById('jsStat').textContent = s.dead + ' 字段会被丢弃';

  var alertEl = document.getElementById('wlAlert');
  if(s.dead){
    alertEl.style.display = 'block';
    alertEl.innerHTML = '<b>🔴 后端当前会丢弃 '+s.dead+' 个字段</b> —— 这些 key 不在 <span class="mono">_clean_block()</span> 的白名单里。'
      + '若只改前端就上线，销售填的一切<b>当场静默消失且无人报错</b>。<br><b>必须先改后端 2 处，顺序不能反。</b>';
  }else{
    alertEl.style.display = 'none';
  }

  var h = '<h5>字段级体检</h5>';
  SPEC.cards.forEach(function(c){
    c.fields.forEach(function(f){
      if(!f.on) return;
      var owner = INLINE_OWNER[f.k];
      var inWl = owner ? ((SUB_WL[owner]||[]).indexOf(f.k) >= 0) : (BLOCK_WL.indexOf(f.k) >= 0);
      var stHtml = c.payload_key
        ? '<span class="st na">◻ 独立模块 · 独立集合</span>'
        : '<span class="st '+(inWl?'ok':'dead')+'">'+(inWl?'✔ 已在白名单':'✘ 需加入 _clean_block')+'</span>';
      h += '<div class="ci"><code>'+esc(f.k)+'</code>'
        + '<span style="color:#64748B">'+esc(f.label)+(owner?' <span style="color:#94A3B8">('+owner+'[])</span>':'')+'</span>'
        + stHtml + '</div>';
    });
  });
  document.getElementById('wlList').innerHTML = h;
}

function sample(f){
  if(f.type === 'multi') return (f.enums||[]).slice(0,1);
  if(f.type === 'select') return (f.enums||[])[0] || '请选择…';
  return f.type === 'textarea' ? '（填写内容）' : '（填写内容）';
}
function esc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function hl(j){
  return esc(j)
    .replace(/&quot;([^&]*?)&quot;(\s*:)/g, '<span class="kp">&quot;$1&quot;</span>$2')
    .replace(/:\s*&quot;([^&]*)&quot;/g, ': <span class="ks">&quot;$1&quot;</span>')
    .replace(/:\s*(\[[^\]]*\])/g, ': <span class="kd">$1</span>');
}

function renderAll(){ renderKpis(); renderCfg(); renderPreview(); renderJson(); }

/* ── 交互（全部事件委托到 document，抗 DOM 重建）── */
document.addEventListener('click', function(e){
  var csw = e.target.closest('[data-act="ctoggle"]');
  if(csw){
    var grp = csw.closest('.cardgrp');
    var cc = SPEC.cards[+grp.dataset.ci];
    cc.on = (cc.on === false) ? true : false;
    save(); renderAll(); return;
  }
  var sw = e.target.closest('[data-act="toggle"]');
  if(sw){
    var row = sw.closest('.frow');
    var f = SPEC.cards[+row.dataset.ci].fields[+row.dataset.fi];
    f.on = !f.on;
    save(); renderAll(); return;
  }
  var fold = e.target.closest('[data-act="fold"]');
  if(fold){
    var g = fold.closest('.cardgrp');
    var c = SPEC.cards[+g.dataset.ci];
    c.collapsed = !c.collapsed;
    g.classList.toggle('collapsed');
    save(); return;
  }
  var tab = e.target.closest('[data-tab]');
  if(tab){
    var t = tab.dataset.tab;
    Array.prototype.forEach.call(document.querySelectorAll('.tabs button'), function(b){ b.classList.toggle('on', b === tab); });
    var cfg = document.getElementById('panelCfg'), right = cfg.nextElementSibling;
    if(t === 'all'){ cfg.style.display=''; right.style.display=''; document.getElementById('split').style.gridTemplateColumns=''; }
    else if(t === 'cfg'){ cfg.style.display=''; right.style.display='none'; document.getElementById('split').style.gridTemplateColumns='1fr'; }
    else { cfg.style.display='none'; right.style.display=''; document.getElementById('split').style.gridTemplateColumns='1fr'; }
    return;
  }
  if(e.target.id === 'panelCfg') return;
});

document.addEventListener('input', function(e){
  var el = e.target;
  var act = el.dataset && el.dataset.act;
  if(!act) return;
  var row = el.closest('[data-ci]');
  if(!row) return;
  var ci = +row.dataset.ci, fi = +row.dataset.fi;
  var f = SPEC.cards[ci] && SPEC.cards[ci].fields[fi];
  if(!f) return;

  if(act === 'label'){ f.label = el.value; save(); renderPreview(); renderJson(); }
  else if(act === 'type'){ f.type = el.value; save(); renderAll(); }
  else if(act === 'enum'){
    var ei = +el.dataset.ei;
    if(f.type === 'select'){
      var cur = el.value;
      f.enums[ei] = cur;
      save(); renderPreview();
    }else{
      f.enums[ei] = el.value;
      save(); renderPreview();
    }
  }
});

document.addEventListener('keydown', function(e){
  if(e.key !== 'Enter') return;
  var el = e.target;
  if(!el.dataset || el.dataset.act !== 'enumadd') return;
  e.preventDefault();
  var box = el.closest('.edenums');
  var ci = +box.dataset.ci, fi = +box.dataset.fi;
  var f = SPEC.cards[ci].fields[fi];
  var v = el.value.trim();
  if(!v) return;
  f.enums = f.enums || [];
  f.enums.push(v);
  el.value = '';
  save(); renderAll();
});

/* ── 导出 / 重置 ── */
function download(name, mime, content){
  var b = new Blob([content], {type: mime});
  var a = document.createElement('a');
  a.href = URL.createObjectURL(b);
  a.download = name;
  document.body.appendChild(a); a.click();
  setTimeout(function(){ URL.revokeObjectURL(a.href); a.remove(); }, 400);
}
function nowStr(){
  var d = new Date(), p = function(n){ return (n < 10 ? '0' : '') + n; };
  return d.getFullYear() + '-' + p(d.getMonth()+1) + '-' + p(d.getDate()) + ' '
       + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
function exportSpec(){
  var s = stats();
  var out = clone(SPEC);
  out._meta = {
    exported_at: nowStr(),
    note: '时间戳为本地时间（CST）。早期版本用 toISOString() 会返回 UTC，日期倒退一天。',
    enabled_fields: s.keys,
    need_backend_whitelist: s.deadKeys,
    pending_enum_decision: s.todo
  };
  download('客户画像P0字段定版.json', 'application/json;charset=utf-8', JSON.stringify(out, null, 2));
  setState('已导出定版 JSON', true);
}
function exportHtml(){
  var doc = document.documentElement.cloneNode(true);
  doc.querySelector('#jsonOut').textContent = document.getElementById('jsonOut').textContent;
  download('客户画像P0_DEMO_已编辑.html', 'text/html;charset=utf-8', '<!DOCTYPE html>' + doc.outerHTML);
  setState('已导出 HTML（含当前编辑）', true);
}
function showJson(){
  var s = stats();
  alert('当前配置概览\n\n卡片: ' + s.cards + '\n启用字段: ' + s.keys
    + '\n可自动获取: ' + s.auto + '\n待定版枚举: ' + s.todo
    + '\n需加进后端白名单: ' + s.dead + '\n  → ' + (s.deadKeys.join(', ') || '无'));
}
function resetAll(){
  if(!confirm('清空所有编辑，恢复到初始配置？')) return;
  localStorage.removeItem(LS_KEY);
  location.reload();
}

/* ── 启动 ── */
(function boot(){
  var restored = restore();
  if(!restored) SPEC = clone(ORIG);
  renderAll();
  if(window.__STALE){
    setState('已丢弃 ' + window.__STALE + ' 旧存档，载入 ' + ORIG.version + ' 新版默认', true);
  }else{
    setState(restored ? '已恢复上次编辑' : '初始配置 · ' + ORIG.version, !!restored);
  }
})();
</script>
</body>
</html>'''

html = (HTML
        .replace('__SPEC__', SPEC_JSON)
        .replace('__BLOCK_WL__', BLOCK_WL_JSON)
        .replace('__SUB_WL__', SUB_WL_JSON)
        .replace('__INLINE_OWNER__', INLINE_OWNER_JSON))

# ── 品牌注入（Logo + slogan —— 铁律，唯一来源 脚本/brand_kit.py）──
import datetime as _dt
NOW_STR = _dt.datetime.now().strftime('%Y-%m-%d %H:%M')
html = BK.inject(html, theme='dark', tag='营销中心 · 销售管理',
                 footer_extra='客户画像 P0 字段定版 DEMO · 内部资料 · 生成于 %s' % NOW_STR)

_bv = BK.verify(html)
assert all(_bv.values()), f"❌ 品牌自检未过: {_bv}"

# ── 断言所有宏已替换 ──
MACROS = ['__SPEC__', '__BLOCK_WL__', '__SUB_WL__', '__INLINE_OWNER__', '__CSS__', '__EDITJS__']
left = [m for m in MACROS if m in html]
assert not left, f"未替换的宏: {left}"

os.makedirs(os.path.dirname(OUT), exist_ok=True)
with io.open(OUT, 'w', encoding='utf-8') as f:
    f.write(html)

# ── 提取内联 JS 供 node --check ──
scripts = re.findall(r'<script>(.*?)</script>', html, re.S)
with io.open('/tmp/_demo_check.js', 'w', encoding='utf-8') as f:
    f.write('\n;\n'.join(scripts))

n_fields = sum(len(c['fields']) for c in SPEC['cards'])
print(f"✅ 已生成: {OUT}")
print(f"   大小: {len(html):,} 字符")
print(f"   卡片: {len(SPEC['cards'])} ｜ 字段: {n_fields}")
print(f"   白名单: 块级 {len(BLOCK_WL)} 个 ｜ 子字段表 {len(SUB_WL)} 组")
print(f"   已抽取内联 JS → /tmp/_demo_check.js")
