java程序员-http://www.it-ebooks.info/

年轻的我,脚踩大地,仰望天空,路还很长....

博客园 首页 新随笔 联系 订阅 管理
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>ExtJS 3 页面设计器 - 修复版</title>
    
    <!-- 1. 确保 CSS 和 JS 路径正确 -->
    


    <link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/ext-all.css" />
    <!-- Gray theme. Must come AFTER ext-all.css so it overrides the default
         blue one; its image assets live in resources/images/gray. -->
    <link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/xtheme-gray.css" />
    <!-- 树形表格(Ext.ux.tree.TreeGrid)的样式。它不在 ext-all.css 里,跟组件
         脚本一起从官方 SDK 的 examples/ux/treegrid 原样拷过来(GPLv3)。 -->
    <link rel="stylesheet" type="text/css" href="vendor/extjs/ux/treegrid/treegrid.css" />
    <!-- CodeMirror 5 (MIT). Its stylesheet must come before app.css so
         app.css can override the editor chrome to match the theme. -->
    <link rel="stylesheet" type="text/css" href="vendor/codemirror/lib/codemirror.css" />
    <!-- Folding gutter styles (addon/fold). Must also precede app.css so the
         project's own overrides still win. -->
    <link rel="stylesheet" type="text/css" href="vendor/codemirror/addon/fold/foldgutter.css" />
    <link rel="stylesheet" type="text/css" href="css/app.css" />

    <!-- Adapter + core library. Swap ext-all-debug.js for ext-all.js before
         shipping to production, then re-test. -->
    <script type="text/javascript" src="vendor/extjs/adapter/ext/ext-base.js"></script>
    <script type="text/javascript" src="vendor/extjs/ext-all-debug.js"></script>

    <!-- 树形表格组件。ext-all-debug.js 里没有 Ext.ux.tree.TreeGrid(它随官方 SDK 的
         examples/ux 分发),所以整包拷进了 vendor/extjs/ux/treegrid。加载顺序照官方
         示例:Sorter → ColumnResizer → NodeUI → Loader → Columns → TreeGrid,
         最后的 TreeGrid.js 才 Ext.reg('treegrid', ...)。 -->
    <script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridSorter.js"></script>
    <script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridColumnResizer.js"></script>
    <script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridNodeUI.js"></script>
    <script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridLoader.js"></script>
    <script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGridColumns.js"></script>
    <script type="text/javascript" src="vendor/extjs/ux/treegrid/TreeGrid.js"></script>

    <!-- 图表组件。Chart.js 4(MIT)负责画,ux/Ext.ux.Chart.js 把它包成一个
         Ext 组件(xtype: 'uxchart')。设计器画布和导出的页面用的都是这两个
         文件——生成代码里写的就是 xtype: 'uxchart'。 -->
    <script type="text/javascript" src="vendor/chartjs/chart.umd.js"></script>
    <script type="text/javascript" src="ux/Ext.ux.Chart.js"></script>

    <!-- CodeMirror 5 runtime for the 获取代码 window. Its stylesheets were
         already linked above, but the library itself was never loaded - until
         now nothing in this page displayed code. Addon selection matches
         index.html so folding behaves the same in both. -->
    <script type="text/javascript" src="vendor/codemirror/lib/codemirror.js"></script>
    <script type="text/javascript" src="vendor/codemirror/mode/javascript/javascript.js"></script>
    <script type="text/javascript" src="vendor/codemirror/addon/fold/foldcode.js"></script>
    <script type="text/javascript" src="vendor/codemirror/addon/fold/brace-fold.js"></script>
    <script type="text/javascript" src="vendor/codemirror/addon/fold/foldgutter.js"></script>




    <style type="text/css">
        body { margin: 0; padding: 0; overflow: hidden; font-family: tahoma, arial, verdana, sans-serif; font-size: 12px; }
        .canvas-area { background-color: #fff; position: relative; min-height: 100%; }
        /* 设计区 body:滚动容器,同时作为绝对定位子项与辅助线的坐标原点。
           顺便在这里铺一层网格辅助线:两层 repeating gradient 各画一个方向的
           细线,主格 50px 稍深、次格 10px 极浅,不占 DOM,组件正好浮在格子上
           当对齐参考。
           background-attachment: local 是关键——画布会滚动,网格要跟着内容
           一起滚;默认的 scroll 会让网格钉在视口上,滚下去就和组件错位了。 */
        .canvas-area .x-panel-body {
            position: relative;
            background-image:
                linear-gradient(to right,  rgba(31, 63, 97, .10) 1px, transparent 1px),
                linear-gradient(to bottom, rgba(31, 63, 97, .10) 1px, transparent 1px),
                linear-gradient(to right,  rgba(31, 63, 97, .045) 1px, transparent 1px),
                linear-gradient(to bottom, rgba(31, 63, 97, .045) 1px, transparent 1px);
            background-size: 50px 50px, 50px 50px, 10px 10px, 10px 10px;
            background-attachment: local;
        }
        /* 工具条上的「网格」按钮关掉时挂这个类(默认开着) */
        .canvas-area.no-grid .x-panel-body { background-image: none; }
        .canvas-placeholder { border: 2px dashed #ccc; color: #999; text-align: center; padding: 50px; margin: 10px; background: #fafafa; }
        .x-component-selected { outline: 2px solid #157fcc !important; position: relative; z-index: 10; }
        /* 上面那条的 position:relative 只是为了让 z-index 生效,但绝对布局的子项
           (画布上的组件、分割出来的区域面板)本来就是 position:absolute,两者
           特异性一样、后写的那条赢,选中一下组件就被打回文档流了——位置会跟着
           画布 body 里在流的兄弟元素(占位提示之类)往下跑。这里按类别把定位收回
           去;这一条更具体,z-index 照常生效。 */
        .canvas-area .x-abs-layout-item.x-component-selected,
        .canvas-area .x-border-panel.x-component-selected { position: absolute; }
        /* 标尺 */
        .designer-ruler { display: block; background: #f0f0f0; cursor: crosshair; }
        /* 设计器自己的浮层(辅助线、缩放把手)一律压在这条线以下。
           Ext 的窗口管理器 Ext.WindowMgr 从 9000 起发号,每个窗口拿到的
           z-index 是 9001 往上(预览、获取代码都是 Ext.Window)。浮层要是
           高过它,就会浮在窗口上面:选中一个组件再开预览,画布上的蓝色
           选中框和四个角把手会盖在预览页上。1000 这一档在画布组件之上
           (选中框才 10),又在任何 Ext 窗口之下,两头都够用。
           辅助线覆盖层:铺满设计区但不拦截鼠标,子元素自行开启事件 */
        .designer-overlay { position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: hidden; z-index: 1000; pointer-events: none; }
        .designer-guide { position: absolute; background: #157fcc; pointer-events: auto; }
        .designer-guide-v { width: 1px; cursor: ew-resize; }
        .designer-guide-h { height: 1px; cursor: ns-resize; }
        .designer-guide:hover { background: #d81b60; }
        /* 拖动时的自动吸附提示线。原来是正红 #ff3f3f,在铺了网格的白底上太抢眼,
           换成浅红:看得见对到哪条线了,又不盖过组件本身 */
        .designer-snap { position: absolute; background: #ffb0b8; display: none; }
        .designer-snap-v { width: 1px; }
        .designer-snap-h { height: 1px; }
        /* 复选框/单选框的外层 wrap 是块级元素,会一路撑满容器;设计器里让它按内容
           收缩,选中框和缩放把手才贴合控件本身 */
        .canvas-area .x-form-check-wrap { display: inline-block; }
        /* 分割条:Ext 默认是透明的 5px 区域,设计器里需要看得见、光标要提示可拖动 */
        .canvas-area .x-layout-split { background-color: #cfe0ef; }
        .canvas-area .x-layout-split:hover { background-color: #7fb2e0; }
        .canvas-area .x-layout-split-west,
        .canvas-area .x-layout-split-east { cursor: ew-resize; }
        .canvas-area .x-layout-split-north,
        .canvas-area .x-layout-split-south { cursor: ns-resize; }
        /* 分割区域面板不带标题栏(标题栏白占高度,生成的代码里还会多出「上区域/
           左区域」这类假标题),改用虚线框 + 角标把区域范围画出来,否则空区域在
           画布上完全看不见、也点不中。
           注意这里绝对不能写 position:区域面板的定位靠 x-border-panel 上的
           position: absolute,本规则的特异性更高(.canvas-area .designer-region
           是 0,2,0,.x-border-panel 只有 0,1,0),一旦写上就会把区域打回普通
           文档流,主区域会被顶到第一个区域下面。角标挂在内层 body 上,而
           .canvas-area .x-panel-body 本身就是 position: relative。 */
        .canvas-area .designer-region { outline: 1px dashed #c8ced4; outline-offset: -1px; }
        .canvas-area .designer-region > .x-panel-body::before {
            content: attr(data-designer-region);
            position: absolute; top: 1px; left: 1px; z-index: 1; padding: 0 4px;
            font-size: 10px; line-height: 14px; color: #8a949c;
            background: rgba(255, 255, 255, .7); pointer-events: none;
        }
        /* 缩放把手浮层:贴在选中组件上,本身不吃鼠标事件(否则点不到底下的组件),
           只有把手吃 */
        .designer-resize-host { position: absolute; pointer-events: none; z-index: 1100; display: none; }
        .designer-resize-host .designer-handle {
            position: absolute; pointer-events: auto; width: 7px; height: 7px;
            background-color: #2a6fb5; user-select: none;
        }
        .designer-resize-host .designer-handle-active { background-color: #d81b60; }
        .designer-handle-e { right: -4px; top: 50%; margin-top: -4px; cursor: e-resize; }
        .designer-handle-w { left: -4px; top: 50%; margin-top: -4px; cursor: w-resize; }
        .designer-handle-n { left: 50%; margin-left: -4px; top: -4px; cursor: n-resize; }
        .designer-handle-s { left: 50%; margin-left: -4px; bottom: -4px; cursor: s-resize; }
        .designer-handle-ne { right: -4px; top: -4px; cursor: ne-resize; }
        .designer-handle-nw { left: -4px; top: -4px; cursor: nw-resize; }
        .designer-handle-se { right: -4px; bottom: -4px; cursor: se-resize; }
        .designer-handle-sw { left: -4px; bottom: -4px; cursor: sw-resize; }
        /* 组件库:按分组铺开的小卡片(原来是树,200px 宽里还要折叠、缩进、长名字
           换行,找一个组件要点好几下)。每张卡 = 一个图标 + 短名,两列排布,
           名字超宽省略号截断,完整名字放在 title 提示里。
           颜色全部走分组变量 --palette-accent(见下面的分组规则):图标、左侧色条、
           标题圆点都用它,一眼扫过去就能分出一大类,加一组只要多写一行变量。
           图标是内联 SVG:不用图片、不依赖图标字体,颜色取的就是同一个变量 */
        .component-palette .x-panel-body { padding: 8px 8px 0; }
        .palette-group { margin-bottom: 12px; }
        .palette-group-title {
            display: flex; align-items: center; gap: 5px;
            font-size: 11px; font-weight: bold; color: #7c8794; letter-spacing: .3px;
            padding-bottom: 5px; margin-bottom: 7px; border-bottom: 1px solid #e9edf1;
        }
        /* 标题前的分组色小圆点 */
        .palette-group-title::before {
            content: ''; flex: 0 0 6px; width: 6px; height: 6px; border-radius: 50%;
            background: var(--palette-accent, #c3ccd4);
        }
        /* 两列等宽卡片 */
        .palette-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
        .palette-tile {
            display: flex; align-items: center; gap: 5px; box-sizing: border-box;
            min-height: 32px; padding: 5px 6px;
            border: 1px solid #dde3e9; border-left: 3px solid var(--palette-accent, #dde3e9);
            border-radius: 4px;
            background: #fff; color: #46535f; font-size: 12px;
            cursor: move; user-select: none; -webkit-user-select: none;
            transition: border-color .12s, box-shadow .12s, color .12s;
        }
        .palette-tile:hover {
            border-color: var(--palette-accent, #9dc3e6);
            box-shadow: 0 2px 5px rgba(31, 63, 97, .14);
            color: var(--palette-accent, #157fcc);
        }
        .palette-tile:active { background: #f7fafd; }
        /* 抓起时原位淡掉,鼠标上跟着的是 DragZone 的代理块 */
        .palette-tile-dragging { opacity: .4; }
        .palette-label { flex: 1 1 auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
        .palette-icon {
            flex: 0 0 16px; width: 16px; height: 16px;
            fill: none; stroke: var(--palette-accent, #7c8794); stroke-width: 1.25;
            stroke-linecap: round; stroke-linejoin: round;
            /* 图标不吃鼠标事件:事件源要是 SVG 子节点,Ext 的
               Ext.get(target).addClass() 会拿到 SVGElement(它的 className 是个
               对象而不是字符串),拖拽代理那块就废了 */
            pointer-events: none;
        }
        /* 图标里需要实心的部分(饼图切块、单选框圆点…)、粗线(柱状图的柱子)
           和虚线(绝对定位的外框) */
        .palette-icon .pi-fill { fill: var(--palette-accent, #7c8794); stroke: none; }
        .palette-icon .pi-thick { stroke-width: 2.4; }
        .palette-icon .pi-dash { stroke-dasharray: 2 1.8; }
        /* 每个分组的强调色。加分组时这里补一行,卡片/图标/圆点自动跟着变 */
        .palette-group[data-group="layout"]    { --palette-accent: #5b9bd5; }
        .palette-group[data-group="container"] { --palette-accent: #70ad47; }
        .palette-group[data-group="table"]     { --palette-accent: #17a2b8; }
        .palette-group[data-group="chart"]     { --palette-accent: #d94f8b; }
        .palette-group[data-group="form"]      { --palette-accent: #ed7d31; }
        .palette-group[data-group="basic"]     { --palette-accent: #8a6fd4; }
        /* 预览 iframe。窗口是 fit 布局,Ext 会给它量好尺寸,这里再兜一层 100%:
           万一没量到,iframe 会退回默认的 150px 高,只能看见页面顶上一条 */
        .designer-preview-frame { display: block; width: 100%; height: 100%; border: 0 none; background: #fff; }
    </style>

    <script type="text/javascript">
        // 2. 确保所有代码在 Ext 加载完成后执行
        Ext.onReady(function() {

            // --- 0. 设计器运行时状态 ---
            // 必须在下方 Viewport 渲染之前声明:Viewport 一渲染就会触发画布 render
            // 回调并写入这些变量,若在此之后再声明,初始化赋值会把它们覆盖掉。
            var SNAP = 6;          // 吸附阈值(像素)
            var canvasBody = null; // 设计区 body(滚动容器)
            var overlay = null;    // 辅助线覆盖层
            var snapV = null, snapH = null; // 吸附提示线
            var guides = [];       // 手动辅助线:{ vertical, pos, el },pos 为画布坐标

            // --- 1. 组件库定义 ---
            // 组件库清单。xtype 必须能在 Ext.ComponentMgr 里查到(Ext 3.4 自带的那批),
            // 生成代码时也是靠它还原组件的。
            // text 是完整名称(鼠标悬停提示用),short 是区块上的短标签:组件库只有
            // 200px 宽,用完整名称会被截成省略号,看不出是什么
            var componentTemplates = [
                { xtype: 'layout-absolute', text: '绝对定位容器', short: '绝对定位', group: 'layout' },
                { xtype: 'layout-anchor', text: '相对定位容器', short: '相对定位', group: 'layout' },
                { xtype: 'panel', text: '面板 (Panel)', short: '面板', group: 'container' },
                { xtype: 'grid', text: '表格 (Grid)', short: '表格', group: 'table' },
                { xtype: 'advgrid', text: '高级表格 (Advanced Grid)', short: '高级表格', group: 'table' },
                { xtype: 'treegrid', text: '树形表格 (TreeGrid)', short: '树形表格', group: 'table' },
                { xtype: 'pivotgrid', text: '交叉表 (PivotGrid)', short: '交叉表', group: 'table' },
                // 图表四个卡片是伪 xtype(像上面的 layout-absolute 一样):画布上
                // 真建出来的是同一个 Ext.ux.Chart(xtype: 'uxchart'),靠 chartType
                // 分饼图/曲线图/柱状图/散点图。这里只当组件库的一个 key 用
                { xtype: 'chart-pie', text: '饼图 (Pie)', short: '饼图', group: 'chart' },
                { xtype: 'chart-line', text: '曲线图 (Line)', short: '曲线图', group: 'chart' },
                { xtype: 'chart-bar', text: '柱状图 (Bar)', short: '柱状图', group: 'chart' },
                { xtype: 'chart-scatter', text: '散点图 (Scatter)', short: '散点图', group: 'chart' },
                { xtype: 'form', text: '表单 (Form)', short: '表单', group: 'container' },
                { xtype: 'fieldset', text: '字段集 (FieldSet)', short: '字段集', group: 'container' },
                { xtype: 'tabpanel', text: '页签面板 (TabPanel)', short: '页签', group: 'container' },
                { xtype: 'textfield', text: '文本框', short: '文本框', group: 'form' },
                { xtype: 'textarea', text: '多行文本', short: '多行文本', group: 'form' },
                { xtype: 'numberfield', text: '数字框', short: '数字框', group: 'form' },
                { xtype: 'datefield', text: '日期框', short: '日期框', group: 'form' },
                { xtype: 'timefield', text: '时间框', short: '时间框', group: 'form' },
                { xtype: 'combo', text: '下拉框', short: '下拉框', group: 'form' },
                { xtype: 'radio', text: '单选框', short: '单选框', group: 'form' },
                { xtype: 'radiogroup', text: '单选框组', short: '单选框组', group: 'form' },
                { xtype: 'checkbox', text: '复选框', short: '复选框', group: 'form' },
                { xtype: 'checkboxgroup', text: '复选框组', short: '复选框组', group: 'form' },
                { xtype: 'displayfield', text: '展示字段', short: '展示字段', group: 'form' },
                { xtype: 'sliderfield', text: '滑块', short: '滑块', group: 'form' },
                { xtype: 'trigger', text: '触发字段', short: '触发字段', group: 'form' },
                { xtype: 'label', text: '文本标签', short: '文本标签', group: 'form' },
                { xtype: 'htmleditor', text: '富文本', short: '富文本', group: 'form' },
                { xtype: 'button', text: '按钮', short: '按钮', group: 'basic' }
            ];

            // 组件卡片上的图标:16×16 内联 SVG,key 就是上面的 xtype。
            // 这里只画图形,颜色/线宽/实心/虚线全部交给 CSS(见 .palette-icon),
            // 所以加一个组件只要补一行路径,不用碰样式。viewBox 是 0 0 16 16,
            // 所以坐标都按 16 见方来写,四周留 1.5 的空当。
            // 没写图标的组件卡片会退化成纯文字(buildPaletteHtml 里判断)
            var PALETTE_ICONS = {
                // 布局:虚线框 + 一个块;虚线框 + 左右拉开的条
                'layout-absolute': '<rect class="pi-dash" x="1.5" y="2.5" width="13" height="11" rx="1.5"/><rect class="pi-fill" x="4.4" y="5.4" width="4.6" height="3.6" rx=".6"/>',
                'layout-anchor': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M3.6 8h8.8M3.6 8l1.8-1.5M3.6 8l1.8 1.5M12.4 8l-1.8-1.5M12.4 8l-1.8 1.5"/>',
                // 容器
                'panel': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13"/>',
                'form': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M3.6 6.6h2.8M8.4 6.6h4M3.6 10h2.8M8.4 10h4"/>',
                'fieldset': '<path d="M5.8 4.6H3.5A1.5 1.5 0 0 0 2 6.1v5.4a1.5 1.5 0 0 0 1.5 1.5h9a1.5 1.5 0 0 0 1.5-1.5V6.1a1.5 1.5 0 0 0-1.5-1.5h-2.3"/><path d="M4.4 2.5h4.4"/>',
                'tabpanel': '<path d="M1.5 6v6.5h13V6"/><path d="M1.5 6h3.8V3.4h3.6V6h5.6"/>',
                // 表格:田字格、田字格 + 左边的树杈、田字格 + 左上角实心块
                'grid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13M1.5 9.8h13M5.8 2.5v11M10.2 2.5v11"/>',
                // 高级表格:田字格 + 顶上多一条工具条,条里放个放大镜
                'advgrid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13M1.5 9.8h13M5.8 6.2v7.3M10.2 6.2v7.3"/><circle cx="12.3" cy="4.3" r="1.1"/><path d="M13.2 5.2l.7.7"/>',
                'treegrid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 6.2h13M5.6 2.5v11"/><path d="M3.1 8.4h1.5M3.1 11.2h1.5"/>',
                'pivotgrid': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M1.5 7h13M7 2.5v11"/><path class="pi-fill" d="M3.4 2.5h3.6v4.5H2.5V3.4a.9.9 0 0 1 .9-.9z"/>',
                // 图表:坐标轴 + 各自的图形
                'chart-pie': '<circle cx="8" cy="8" r="5.8"/><path class="pi-fill" d="M8 8V2.2a5.8 5.8 0 0 1 5 8.6z"/>',
                'chart-line': '<path d="M2.5 2.2v11.3h11.3"/><path d="M4.6 10.6l2.8-3.3 2.4 1.8 3-4.1"/>',
                'chart-bar': '<path d="M2.5 2.2v11.3h11.3"/><path class="pi-thick" d="M5.6 13.5V9.4M8.8 13.5V6.4M12 13.5V8.4"/>',
                'chart-scatter': '<path d="M2.5 2.2v11.3h11.3"/><circle class="pi-fill" cx="5.7" cy="10.2" r="1.2"/><circle class="pi-fill" cx="8.6" cy="6.4" r="1.2"/><circle class="pi-fill" cx="11.4" cy="9.4" r="1.2"/>',
                // 表单
                'textfield': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M4.3 6.8v2.9"/>',
                'textarea': '<rect x="1.5" y="2.8" width="13" height="10.4" rx="1.5"/><path d="M4.2 6h7.6M4.2 8.2h7.6M4.2 10.4h4.6"/>',
                'numberfield': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M10.6 5v6.5"/><path d="M11.8 7.4l.9-.9.9.9M11.8 9.4l.9.9.9-.9"/>',
                'datefield': '<rect x="1.5" y="3.4" width="13" height="10.2" rx="1.5"/><path d="M1.5 6.8h13M4.9 2.4v2.6M11.1 2.4v2.6"/><path class="pi-fill" d="M4.9 8.6h1.6v1.6H4.9zM8 8.6h1.6v1.6H8z"/>',
                'timefield': '<circle cx="8" cy="8" r="5.7"/><path d="M8 4.6V8l2.4 1.7"/>',
                'combo': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M9.9 5v6.5"/><path d="M10.9 7.6l1 1 1-1"/>',
                'radio': '<circle cx="8" cy="8" r="5.6"/><circle class="pi-fill" cx="8" cy="8" r="2.1"/>',
                'radiogroup': '<circle cx="3.6" cy="5.6" r="2.1"/><circle cx="3.6" cy="10.8" r="2.1"/><path d="M7.4 5.6h6.4M7.4 10.8h6.4"/>',
                'checkbox': '<rect x="2.4" y="2.4" width="11.2" height="11.2" rx="2"/><path d="M5.4 8.1l1.8 1.9 3.5-4"/>',
                'checkboxgroup': '<rect x="1.5" y="3.3" width="4.4" height="4.4" rx="1.2"/><rect x="1.5" y="8.4" width="4.4" height="4.4" rx="1.2"/><path d="M7.6 5.5h6.4M7.6 10.6h6.4"/>',
                'displayfield': '<path d="M2 8.2h8.4"/><path class="pi-dash" d="M2 11.4h12"/>',
                'sliderfield': '<path d="M1.5 8h13"/><circle cx="6" cy="8" r="2.4"/>',
                'trigger': '<rect x="1.5" y="5" width="13" height="6.5" rx="1.5"/><path d="M9.6 5v6.5"/><path d="M10.7 8h2"/>',
                'label': '<path d="M2.4 4.6h11.2M2.4 8h11.2M2.4 11.4h5.6"/>',
                'htmleditor': '<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M5.6 5.3h2.1a1.6 1.6 0 0 1 0 3.2H5.6zM5.6 8.5h2.4a1.7 1.7 0 0 1 0 3.4H5.6z"/>',
                // 基础
                'button': '<rect x="2" y="5.4" width="12" height="5.6" rx="2.6"/><path d="M5.6 8.2h4.8"/>'
            };

            // 容器型 xtype:拖到表单上时不往里塞(表单只收字段)
            // grid/treegrid/pivotgrid 都是 Panel 派生的,但里面是表体,装不下别的组件
            var CONTAINER_XTYPES = ['panel', 'form', 'grid', 'treegrid', 'pivotgrid',
                                    'tabpanel', 'fieldset', 'layout-absolute', 'layout-anchor'];

            // 能配「内边距 / 组件间距」的容器
            function isSpacingContainer(cmp) {
                var t = cmp.getXType();
                return t === 'panel' || t === 'form' || t === 'fieldset';
            }

            // 可投放的容器:布局容器、区域面板、表单、字段集、页签面板
            function isDropContainer(cmp) {
                var t = cmp.getXType();
                return t === 'panel' || t === 'form' || t === 'fieldset' || t === 'tabpanel' ||
                       !!cmp._designerLayout || !!cmp._isDesignerRegion;
            }

            // 右侧属性面板可选布局方式(Ext 3.4 已注册到 Ext.Container.LAYOUTS 的 key)
            var LAYOUT_TYPES = ['auto', 'absolute', 'anchor', 'form', 'fit', 'column', 'border', 'accordion', 'card', 'hbox', 'vbox'];
            var LAYOUT_DATA = [];
            Ext.each(LAYOUT_TYPES, function(t) { LAYOUT_DATA.push([t]); });

            // 图表的属性行:属性表里显示中文名,回写时换回 Ext.ux.Chart 认的类型名
            // (生成代码里写的也是英文名)。定义在这里是因为右边的属性面板
            // (下面第 4 节那个 PropertyGrid)建的时候就要用 CHART_TYPE_DATA
            var CHART_TYPE_NAMES = { pie: '饼图', line: '曲线图', bar: '柱状图', scatter: '散点图' };
            var CHART_TYPE_KEYS = { '饼图': 'pie', '曲线图': 'line', '柱状图': 'bar', '散点图': 'scatter' };
            var CHART_TYPE_DATA = [['饼图'], ['曲线图'], ['柱状图'], ['散点图']];

            // ================= 属性行规格表 =================
            // 「不同的组件,右侧显示不同的属性」这句话就落在这张表上:一条 = 一个
            // 属性,四件事写在一起 ——
            //   when(cmp)     哪些组件有这一行(不写就是人人都有)
            //   get(cmp)      现在是什么值,显示在属性表里
            //   set(cmp, v)   改完怎么写回画布上的那个组件(外观要当场变)
            //   code(item)    生成代码时落成什么属性(不生成就返回 null)
            //   cssOf/cssTarget  这一项其实是样式时,落成外层 style 还是 bodyStyle
            // 属性表的行、属性回写、代码生成三处都遍历这张表,加一个属性只改这里
            // 一处,不用三个地方各维护一份。
            //
            // kind 决定用哪种编辑器:text 文本框 / number 数字 / color 颜色 /
            // bool 显示-隐藏(下拉:属性表里布尔值只会渲染成 true/false,必须
            // 换成下拉) / enum 下拉,选项写在 values 里 / chartData 图表数据那种
            // 加宽右对齐的文本框(见下面自定义编辑器)。
            //
            // 面板的外观(字号、圆角、边框、标题栏)不在这里逐项写读和写:值统一
            // 存在组件的 _xxx 上,由 applyPanelStyle 一次性刷到 DOM,这里只负责把
            // 值读出来显示、把代码拼出来。
            var SHOW_HIDE = [['显示'], ['隐藏']];

            // 有独立外观盒子的容器:面板 / 表单 / 字段集 / 表格 / 页签 …… 都派生自
            // Ext.Panel。图表(Ext.ux.Chart)继承的是 BoxComponent,样式另走一套
            function isStyleContainer(cmp) { return cmp instanceof Ext.Panel; }

            // 文本框一家(含带扳机的下拉、日期时间),它们才有占位提示
            function isTextFieldLike(cmp) {
                var t = cmp.getXType();
                return t === 'textfield' || t === 'textarea' || t === 'numberfield' ||
                       t === 'datefield' || t === 'timefield' || t === 'trigger' || t === 'combo';
            }

            function isNumberLike(cmp) {
                var t = cmp.getXType();
                return t === 'numberfield' || t === 'sliderfield';
            }

            function isDateLike(cmp) {
                var t = cmp.getXType();
                return t === 'datefield' || t === 'timefield';
            }

            function isChart(cmp) { return cmp.getXType() === 'uxchart'; }

            // 能取值/填值的字段。开关类的值走 Checked、展示字段走 Text,
            // 都不在这里重复给
            function canSetValue(cmp) {
                var t = cmp.getXType();
                return !!cmp.isFormField && t !== 'checkbox' && t !== 'radio' &&
                       t !== 'displayfield' && t !== 'hidden' && t !== 'htmleditor' &&
                       !!cmp.setValue;
            }

            // 数字框的「不限制」哨兵(Ext.form.NumberField 的默认上下限)。判定一个
            // 值算不算真的取值范围:得是有限数、且不是这两个哨兵之一。定义必须在
            // PROP_SPECS 之前——表里的 get/code 是在选取组件时跑的,那时这些 var
            // 得已经赋过值(函数声明会提升,var 的赋值不会)
            var NO_MIN = Number.NEGATIVE_INFINITY;
            var NO_MAX = Number.MAX_VALUE;
            function hasLimit(v, sentinel) {
                return typeof v === 'number' && isFinite(v) && v !== sentinel;
            }

            // ================= 高级表格 =================
            // 「高级表格」不是新的组件类型,就是一个普通 GridPanel(xtype: 'grid')
            // 多带一条工具条:搜索条件(按列生成的输入框 + 查询/重置),外加一份可选
            // 的「列请求地址」。组件上带三个设计器标记:_advGrid / _searchCols(用哪
            // 几列当搜索条件)/ _columnUrl(列定义去哪取),属性行、工具条、生成代码
            // 三处共用这几个标记。
            function isAdvGrid(cmp) { return !!(cmp && cmp._advGrid); }

            // 列配置。渲染之后以 colModel 为准(「加载列」换过列的就是它),
            // 还没渲染时看配置里的 columns
            function advGridColumns(cmp) {
                if (!cmp) return [];
                return (cmp.colModel && cmp.colModel.config) ? cmp.colModel.config : (cmp.columns || []);
            }

            function advColumnByIndex(columns, dataIndex) {
                var found = null;
                Ext.each(columns || [], function(c) {
                    if (!found && c.dataIndex === dataIndex) found = c;
                });
                return found;
            }

            // 真正生效的搜索条件列。名单里写错的列名一律丢掉——工具条上不该出现
            // 一个表格里根本没有的搜索框
            function advGridSearchCols(cmp) {
                var columns = advGridColumns(cmp), out = [];
                Ext.each((cmp && cmp._searchCols) || [], function(di) {
                    if (di && advColumnByIndex(columns, di) && out.indexOf(di) === -1) out.push(di);
                });
                return out;
            }

            // 「搜索条件」填的是列名(dataIndex),逗号/分号/空格分隔
            function parseSearchCols(text, cmp) {
                var columns = advGridColumns(cmp), out = [];
                Ext.each(String(text == null ? '' : text).split(/[,,;;\s]+/), function(name) {
                    name = Ext.util.Format.trim(name);
                    if (name && advColumnByIndex(columns, name) && out.indexOf(name) === -1) out.push(name);
                });
                return out;
            }

            // 搜索框的类型按这一列第一行数据的类型来定:数字给数字框、日期给日期框,
            // 其余给文本框。列本身不带类型信息(示例表是内存数据),看值最省事
            function advSearchFieldType(store, dataIndex) {
                var rec = (store && store.getAt) ? store.getAt(0) : null;
                var v = rec ? rec.data[dataIndex] : null;
                if (typeof v === 'number') return 'numberfield';
                if (Ext.isDate(v)) return 'datefield';
                return 'textfield';
            }

            // 「查询」:把工具条上带 name 的输入框收成参数,交给 store 重新加载。
            // 下面这两个 deliberately 写成一整行、也不调任何外部函数 —— 生成代码里直接用
            // String() 取它们的源码内联进去(见 advGridTbarCode),设计器和导出页共用
            // 同一份实现,不会各写一遍走样。
            // 两处讲究:
            // ① 用 items.each 而不是 findBy —— Ext 3 的 Container.findBy 要谓词严格返回
            //    true 才算数,返回「有名字」这种真值会被丢掉;
            // ② reload 前先看有没有 proxy —— 画布上的示例数据是 ArrayStore,
            //    Store.reload 会一路走到 this.proxy.request,没有 proxy 直接抛异常。
            //    参数照样落到 baseParams 上(真实后端 store 就是靠它取搜索条件的),
            //    换成带 proxy 的 store 就会真的重新加载
            function advQueryHandler(btn) { var g = btn.findParentByType('grid'), p = {}; btn.ownerCt.items.each(function(f) { if (f.isFormField && f.name) p[f.name] = f.getValue(); }); var s = g.getStore(); s.baseParams = p; if (s.proxy) s.reload(); }

            function advResetHandler(btn) { btn.ownerCt.items.each(function(f) { if (f.isFormField) f.reset(); }); }

            // 工具条的内容。设计器建工具条、改完搜索条件重建、生成代码,都走这里
            function advGridTbarItems(cmp) {
                var columns = advGridColumns(cmp), items = [];
                var store = cmp.getStore ? cmp.getStore() : cmp.store;
                Ext.each(advGridSearchCols(cmp), function(di) {
                    var col = advColumnByIndex(columns, di);
                    items.push({ xtype: 'tbtext', text: (col && col.header ? col.header : di) + ':' });
                    items.push({ xtype: advSearchFieldType(store, di), width: 120, name: di });
                });
                if (items.length) items.push('-');
                items.push({ text: '查询', handler: advQueryHandler });
                items.push({ text: '重置', handler: advResetHandler });
                return items;
            }

            // 搜索条件改了,工具条上的输入框跟着变。工具条是渲染时就建好的,只能
            // 把里面的控件清掉重加(removeAll(true) 会连旧控件一起销毁)
            function applyAdvGridToolbar(cmp) {
                var tb = cmp.getTopToolbar ? cmp.getTopToolbar() : null;
                if (!tb) return;
                tb.removeAll(true);
                tb.add(advGridTbarItems(cmp));
                tb.doLayout();
            }

            // 右侧「加载列」:请求「列请求地址」,用返回的列配置重设表格的列。
            // 约定接口返回 Ext 的列配置数组:[{ header: '姓名', dataIndex: 'name' }, ...]
            function loadAdvGridColumns() {
                var cmp = currentSelectedCmp;
                if (!isAdvGrid(cmp)) { Ext.Msg.alert('提示', '先选中画布上的高级表格'); return; }
                var url = cmp._columnUrl;
                if (!url) { Ext.Msg.alert('提示', '先在「列请求地址」里填上地址'); return; }
                Ext.Ajax.request({
                    url: url,
                    success: function(resp) {
                        var cols;
                        try { cols = Ext.decode(resp.responseText); } catch (e) { cols = null; }
                        if (!Ext.isArray(cols) || !cols.length) {
                            Ext.Msg.alert('提示', '接口返回的不是列配置数组');
                            return;
                        }
                        cmp.reconfigure(cmp.getStore(), new Ext.grid.ColumnModel(cols));
                        cmp.doLayout();
                        updatePropertyGrid(cmp);
                        positionResizeHost(cmp);
                    },
                    failure: function() { Ext.Msg.alert('提示', '请求列定义失败:' + url); }
                });
            }

            var PROP_SPECS = [

                // ---- 面板/容器:外观 ----
                { name: 'FontSize', kind: 'number', when: isStyleContainer,
                  get: function(c) { return c._fontSize || 0; },
                  set: function(c, v) { c._fontSize = toPx(v); applyPanelStyle(c); },
                  cssTarget: 'body',
                  cssOf: function(it) { return it._fontSize ? 'font-size: ' + it._fontSize + 'px;' : null; } },

                { name: 'FontColor', kind: 'color', when: isStyleContainer,
                  get: function(c) { return c._fontColor || ''; },
                  set: function(c, v) {
                      var col = normalizeColor(v);
                      if (col === null) return; // 认不出的颜色忽略,和 BodyColor 一致
                      c._fontColor = col;
                      applyPanelStyle(c);
                  },
                  cssTarget: 'body',
                  cssOf: function(it) { return it._fontColor ? 'color: ' + it._fontColor + ';' : null; } },

                { name: 'Radius', kind: 'number', when: isStyleContainer,
                  get: function(c) { return c._radius || 0; },
                  set: function(c, v) { c._radius = toPx(v); applyPanelStyle(c); },
                  cssTarget: 'both',
                  cssOf: function(it) { return it._radius ? 'border-radius: ' + it._radius + 'px;' : null; } },

                { name: 'Shadow', kind: 'bool', when: isStyleContainer,
                  get: function(c) { return c._shadow ? '显示' : '隐藏'; },
                  set: function(c, v) { c._shadow = (String(v) === '显示' || v === true); applyPanelStyle(c); },
                  cssTarget: 'el',
                  cssOf: function(it) { return it._shadow ? 'box-shadow: ' + PANEL_SHADOW + ';' : null; } },

                { name: 'BorderWidth', kind: 'number', when: isStyleContainer,
                  get: function(c) { return c._borderWidth || 0; },
                  set: function(c, v) { c._borderWidth = toPx(v); applyPanelStyle(c); },
                  cssTarget: 'el',
                  cssOf: function(it) {
                      return it._borderWidth
                          ? 'border: ' + it._borderWidth + 'px solid ' + (it._borderColor || PANEL_BORDER_COLOR) + ';'
                          : null;
                  } },

                // 边框颜色单独一行没意义(没设粗细就画不出边),它是被上面那条
                // 一起拼进 border 里的,所以这里没有自己的 code/cssOf
                { name: 'BorderColor', kind: 'color', when: isStyleContainer,
                  get: function(c) { return c._borderColor || ''; },
                  set: function(c, v) {
                      var col = normalizeColor(v);
                      if (col === null) return;
                      c._borderColor = col;
                      applyPanelStyle(c);
                  } },

                // ---- 面板:标题栏。表头是渲染时按 title 建出来的,没有表头的
                //      组件(区域面板、已经关掉表头的)不给这几行 ----
                { name: 'TitleAlign', kind: 'enum', values: [['左'], ['中'], ['右']], when: hasHeader,
                  get: function(c) {
                      return c._titleAlign === 'center' ? '中' : (c._titleAlign === 'right' ? '右' : '左');
                  },
                  set: function(c, v) {
                      c._titleAlign = (v === '中') ? 'center' : (v === '右' ? 'right' : 'left');
                      applyPanelStyle(c);
                  } },

                { name: 'TitleSize', kind: 'number', when: hasHeader,
                  get: function(c) { return c._titleSize || 0; },
                  set: function(c, v) { c._titleSize = toPx(v); applyPanelStyle(c); } },

                { name: 'TitleColor', kind: 'color', when: hasHeader,
                  get: function(c) { return c._titleColor || ''; },
                  set: function(c, v) {
                      var col = normalizeColor(v);
                      if (col === null) return;
                      c._titleColor = col;
                      applyPanelStyle(c);
                  } },

                // ---- 容器:内边距 / 组件间距 ----
                { name: 'Padding', kind: 'number', when: isSpacingContainer, refresh: true,
                  get: function(c) { return toPx(c._padding); },
                  set: applyPadding,
                  cssTarget: 'body',
                  cssOf: function(it) { return it._padding > 0 ? 'padding: ' + it._padding + 'px;' : null; } },

                { name: 'Spacing', kind: 'number', when: isSpacingContainer, refresh: true,
                  get: function(c) { return toPx(c._spacing); },
                  set: applySpacing,
                  code: function(it) {
                      // 落到容器的 defaults 上,以后新拖进去的图元也自动带间距
                      return (it._spacing > 0 && !hasRegionChildren(it))
                          ? "defaults: { style: 'margin: " + it._spacing + "px;' }"
                          : null;
                  } },

                // ---- 按钮 / 标签 / 展示字段的文案 ----
                { name: 'Text', kind: 'text', when: function(c) {
                      var t = c.getXType();
                      return t === 'button' || t === 'label' || t === 'displayfield';
                  },
                  get: function(c) {
                      if (c.getXType() === 'displayfield') return c.getValue ? c.getValue() : (c.value || '');
                      return c.text || '';
                  },
                  set: setComponentText,
                  code: function(it) {
                      if (it.getXType() === 'displayfield') {
                          var v = it.getValue ? it.getValue() : it.value;
                          return v ? "value: '" + esc(v) + "'" : null;
                      }
                      return it.text ? "text: '" + esc(it.text) + "'" : null;
                  } },

                // ---- 表单字段 ----
                { name: 'FieldLabel', kind: 'text', when: function(c) { return !!c.isFormField; },
                  get: function(c) { return c.fieldLabel || ''; },
                  set: function(c, v) { if (c.setFieldLabel) c.setFieldLabel(v); c.fieldLabel = v; },
                  code: function(it) { return it.fieldLabel ? "fieldLabel: '" + esc(it.fieldLabel) + "'" : null; } },

                { name: 'Name', kind: 'text', when: function(c) { return !!c.isFormField; },
                  get: function(c) { return c.name || ''; },
                  set: function(c, v) { c.name = v; },
                  code: function(it) { return it.name ? "name: '" + esc(it.name) + "'" : null; } },

                { name: 'Value', kind: 'text', when: canSetValue,
                  get: function(c) { return c.getRawValue ? c.getRawValue() : (c.value || ''); },
                  set: function(c, v) { c.setValue(v); },
                  code: function(it) {
                      var v = it.getValue ? it.getValue() : it.value;
                      return v ? "value: '" + esc(v) + "'" : null;
                  } },

                { name: 'EmptyText', kind: 'text', when: isTextFieldLike,
                  get: function(c) { return c.emptyText || ''; },
                  set: function(c, v) {
                      c.emptyText = v;
                      // Ext 只在失焦/重置时补占位文字,改完得自己喊一声
                      if (c.rendered && c.applyEmptyText) c.applyEmptyText();
                  },
                  code: function(it) { return it.emptyText ? "emptyText: '" + esc(it.emptyText) + "'" : null; } },

                // 只给能打的输入框:复选框/单选框/滑块的 readOnly 是 Field 继承来的
                // 空壳(改的是外层 div 的 dom.readOnly,点了照样变),给了也是白给
                { name: 'ReadOnly', kind: 'enum', values: [['可编辑'], ['只读']],
                  when: function(c) {
                      return !!c.setReadOnly && isTextFieldLike(c) && c.getXType() !== 'displayfield';
                  },
                  get: function(c) { return c.readOnly ? '只读' : '可编辑'; },
                  set: function(c, v) { c.setReadOnly(String(v) === '只读'); },
                  code: function(it) { return it.readOnly ? 'readOnly: true' : null; } },

                { name: 'AllowBlank', kind: 'enum', values: [['允许为空'], ['必填']],
                  when: function(c) { return c.isFormField && c.allowBlank !== undefined; },
                  get: function(c) { return c.allowBlank === false ? '必填' : '允许为空'; },
                  set: function(c, v) { c.allowBlank = (String(v) !== '必填'); },
                  code: function(it) { return it.allowBlank === false ? 'allowBlank: false' : null; } },

                { name: 'BoxLabel', kind: 'text',
                  when: function(c) {
                      var t = c.getXType();
                      return (t === 'checkbox' || t === 'radio') && !!c.boxLabel;
                  },
                  get: function(c) { return c.boxLabel || ''; },
                  set: setBoxLabel,
                  code: function(it) { return it.boxLabel ? "boxLabel: '" + esc(it.boxLabel) + "'" : null; } },

                { name: 'Checked', kind: 'enum', values: [['选中'], ['未选中']],
                  when: function(c) {
                      var t = c.getXType();
                      return t === 'checkbox' || t === 'radio';
                  },
                  get: function(c) {
                      return (c.getValue ? c.getValue() : c.checked) ? '选中' : '未选中';
                  },
                  set: function(c, v) {
                      var on = (String(v) === '选中' || v === true);
                      if (c.setValue) c.setValue(on);
                      c.checked = on;
                  },
                  code: function(it) {
                      var on = it.getValue ? it.getValue() : it.checked;
                      return on ? 'checked: true' : null;
                  } },

                // ---- 数字 / 日期时间字段 ----
                // 数字框自带 minValue = -Infinity / maxValue = Number.MAX_VALUE,意思
                // 是「不限制」。这两个哨兵值都不能当真的取值范围:
                //   -Infinity 连 Ext.isNumber 都不认(它要求 isFinite),属性表里那一行
                //   根本建不出来;Number.MAX_VALUE 会显示成 1.7976931348623157e+308。
                // 所以只有「有限的、且不等于哨兵」的值才算设过;没有就是空行、不写进代码。
                { name: 'MinValue', kind: 'number', when: isNumberLike,
                  get: function(c) { return hasLimit(c.minValue, NO_MIN) ? c.minValue : ''; },
                  set: function(c, v) {
                      var n = parseFloat(v);
                      if (isFinite(n)) {
                          c.minValue = n;
                          if (c.setMinValue) c.setMinValue(n); // 滑块会转手给内部 slider
                      } else {
                          // 清空 = 交回不限制。滑块内部那套只认数字,不去动它的轨道,
                          // 只把组件自己记的值清掉(属性表和生成代码读的是这一份)
                          c.minValue = c.slider ? undefined : NO_MIN;
                          if (!c.slider && c.setMinValue) c.setMinValue(undefined);
                      }
                      syncSlider(c);
                  },
                  code: function(it) {
                      return hasLimit(it.minValue, NO_MIN) ? 'minValue: ' + it.minValue : null;
                  } },

                { name: 'MaxValue', kind: 'number', when: isNumberLike,
                  get: function(c) { return hasLimit(c.maxValue, NO_MAX) ? c.maxValue : ''; },
                  set: function(c, v) {
                      var n = parseFloat(v);
                      if (isFinite(n)) {
                          c.maxValue = n;
                          if (c.setMaxValue) c.setMaxValue(n);
                      } else {
                          c.maxValue = c.slider ? undefined : NO_MAX;
                          if (!c.slider && c.setMaxValue) c.setMaxValue(undefined);
                      }
                      syncSlider(c);
                  },
                  code: function(it) {
                      return hasLimit(it.maxValue, NO_MAX) ? 'maxValue: ' + it.maxValue : null;
                  } },

                { name: 'Format', kind: 'text', when: isDateLike,
                  get: function(c) { return c.format || ''; },
                  set: function(c, v) {
                      c.format = v;
                      // format 只在取值/回填时用到,已经填了值的要重新回填一次才看得出来
                      if (c.getValue && c.setValue) {
                          var cur = c.getValue();
                          if (cur) c.setValue(cur);
                      }
                  },
                  code: function(it) { return it.format ? "format: '" + esc(it.format) + "'" : null; } },

                // ---- 下拉框 ----
                { name: 'Editable', kind: 'bool',
                  when: function(c) { return c.getXType() === 'combo'; },
                  get: function(c) { return c.editable === false ? '隐藏' : '显示'; },
                  set: function(c, v) {
                      c.editable = (String(v) === '显示' || v === true);
                      if (c.rendered && c.setEditable) c.setEditable(c.editable);
                  },
                  // 这一项一直是显式写出来的(默认 true 也写),和下拉框本来的
                  // 代码生成保持一致
                  code: function(it) { return 'editable: ' + (it.editable !== false); } },

                { name: 'ForceSelection', kind: 'bool',
                  when: function(c) { return c.getXType() === 'combo'; },
                  get: function(c) { return c.forceSelection ? '显示' : '隐藏'; },
                  set: function(c, v) {
                      c.forceSelection = (String(v) === '显示' || v === true);
                      if (c.rendered && c.setForceSelection) c.setForceSelection(c.forceSelection);
                  },
                  code: function(it) { return it.forceSelection ? 'forceSelection: true' : null; } },

                // ---- 绝对定位容器里的坐标。顶层的画布就是绝对定位的,所以画布上
                //      的组件都有这两行 ----
                { name: 'X', kind: 'number', when: isAbsoluteChild,
                  get: function(c) { return c.x || 0; },
                  set: function(c, v) { c.setPosition(parseInt(v, 10) || 0, c.y || 0); },
                  code: function(it) {
                      return isAbsoluteChild(it) ? 'x: ' + (it.x || 0) : null;
                  } },

                { name: 'Y', kind: 'number', when: isAbsoluteChild,
                  get: function(c) { return c.y || 0; },
                  set: function(c, v) { c.setPosition(c.x || 0, parseInt(v, 10) || 0); },
                  code: function(it) {
                      return isAbsoluteChild(it) ? 'y: ' + (it.y || 0) : null;
                  } },

                // ---- 表格 ----
                { name: 'StripeRows', kind: 'bool',
                  when: function(c) {
                      var t = c.getXType();
                      return t === 'grid' || t === 'treegrid';
                  },
                  get: function(c) { return c.stripeRows ? '显示' : '隐藏'; },
                  set: function(c, v) {
                      c.stripeRows = (String(v) === '显示' || v === true);
                      var view = c.getView ? c.getView() : null;
                      if (view) {
                          view.stripeRows = c.stripeRows;
                          if (view.refresh) view.refresh();
                      }
                  },
                  code: function(it) { return it.stripeRows ? 'stripeRows: true' : null; } },

                // ---- 高级表格:搜索条件 + 列请求地址。两行都只存在组件上,
                //      落到代码里的是工具条和那个取列的 render 回调(结构性拼装,
                //      见 renderItem 的表格分支),所以这里没有 code ----
                { name: 'SearchColumns', kind: 'text', wide: true, when: isAdvGrid,
                  // refresh:写错的列名会被丢掉,回显归一化之后真正生效的那份
                  refresh: true,
                  get: function(c) { return advGridSearchCols(c).join(', '); },
                  set: function(c, v) {
                      c._searchCols = parseSearchCols(v, c);
                      applyAdvGridToolbar(c);
                  } },

                { name: 'ColumnUrl', kind: 'text', wide: true, when: isAdvGrid, refresh: true,
                  get: function(c) { return c._columnUrl || ''; },
                  set: function(c, v) { c._columnUrl = Ext.util.Format.trim(String(v == null ? '' : v)); } },

                { name: 'Aggregator', kind: 'enum',
                  values: [['sum'], ['avg'], ['count'], ['min'], ['max']],
                  when: function(c) { return c.getXType() === 'pivotgrid'; },
                  get: function(c) { return c.aggregator || 'sum'; },
                  set: function(c, v) { c.setAggregator(v); repivot(c); },
                  code: function(it) { return "aggregator: '" + esc(it.aggregator || 'sum') + "'"; } },

                { name: 'Measure', kind: 'text',
                  when: function(c) { return c.getXType() === 'pivotgrid'; },
                  get: function(c) { return c.measure || ''; },
                  set: function(c, v) { c.setMeasure(v); repivot(c); },
                  code: function(it) { return "measure: '" + esc(it.measure) + "'"; } },

                // ---- 图表 ----
                { name: 'ChartType', kind: 'enum', values: CHART_TYPE_DATA, when: isChart, refresh: true,
                  get: function(c) { return CHART_TYPE_NAMES[c.chartType] || c.chartType; },
                  set: function(c, v) {
                      if (c.setChartType) c.setChartType(CHART_TYPE_KEYS[v] || v);
                      else c.chartType = v;
                  },
                  code: function(it) { return "chartType: '" + esc(it.chartType || 'bar') + "'"; } },

                { name: 'Legend', kind: 'bool', when: isChart, refresh: true,
                  get: function(c) { return c.showLegend === false ? '隐藏' : '显示'; },
                  set: function(c, v) {
                      var on = (String(v) === '显示' || v === true);
                      if (c.setLegend) c.setLegend(on); else c.showLegend = on;
                  },
                  code: function(it) { return it.showLegend === false ? 'showLegend: false' : null; } },

                { name: 'Data', kind: 'text', wide: true, when: isChart, refresh: true,
                  get: function(c) { return chartDataText(c.data); },
                  set: function(c, v) {
                      if (c.setChartData) c.setChartData(parseChartData(v));
                      else c.data = parseChartData(v);
                  },
                  code: function(it) { return 'data: ' + chartDataLiteral(it.data); } }
            ];

            // 面板阴影和默认边框色。设计器和生成出来的代码用的是同一组值,
            // 所以提出来当常量,别一边写一遍
            var PANEL_SHADOW = '0 2px 9px rgba(23, 43, 77, .22)';
            var PANEL_BORDER_COLOR = '#b7c1cc';

            // 数字框的「不限制」哨兵 NO_MIN / NO_MAX 和 hasLimit 定义在属性行规格表
            // 前面(表里的 get/code 要用),这里只放滑块自己的同步

            // 滑块的可拖范围改了要重新铺一次轨道,不然拖着还是照旧范围走。
            // 取值范围被清空时是 undefined(或数字框的哨兵,见上面 hasLimit),
            // 别拿它去喂滑块
            function syncSlider(cmp) {
                if (cmp.getXType() !== 'sliderfield' || !cmp.slider) return;
                if (isFinite(cmp.minValue) && cmp.slider.setMinValue) cmp.slider.setMinValue(cmp.minValue);
                if (isFinite(cmp.maxValue) && cmp.slider.setMaxValue) cmp.slider.setMaxValue(cmp.maxValue);
                if (cmp.slider.update) cmp.slider.update();
            }

            // 交叉表的聚合方式和度量列一改,整张表要重新摊一遍。view.refresh(true)
            // 就是生成代码里 viewready 用的那一下(见 renderItem),确实会重算
            function repivot(cmp) {
                if (cmp.view && cmp.view.refresh) cmp.view.refresh(true);
            }

            // 面板外观:值都存在组件自己的 _xxx 上,这里一次性刷到 DOM。
            // 六项各写一个 setter 的话,「刷到哪个元素上」这件事要重复六遍,
            // 集中在这里只维护一次。
            function applyPanelStyle(cmp) {
                if (!cmp || !cmp.el || !cmp.el.dom) return;
                var el = cmp.el;
                var body = (cmp.body && cmp.body.dom) ? cmp.body : el;
                var radius = cmp._radius ? cmp._radius + 'px' : '';
                // 圆角要连 body 一起圆:不带边框的面板看得见的那块底色就是 body,
                // 只圆外层的话四个角会露出直角
                el.setStyle('border-radius', radius);
                body.setStyle('border-radius', radius);
                el.setStyle('box-shadow', cmp._shadow ? PANEL_SHADOW : '');
                // 粗细为 0 就是把内联边框撤掉,回到主题自己的边线
                el.setStyle('border', cmp._borderWidth
                    ? cmp._borderWidth + 'px solid ' + (cmp._borderColor || PANEL_BORDER_COLOR)
                    : '');
                body.setStyle('font-size', cmp._fontSize ? cmp._fontSize + 'px' : '');
                body.setStyle('color', cmp._fontColor || '');
                var t = titleTextEl(cmp);
                if (t) {
                    t.setStyle('text-align', cmp._titleAlign || '');
                    t.setStyle('font-size', cmp._titleSize ? cmp._titleSize + 'px' : '');
                    t.setStyle('color', cmp._titleColor || '');
                }
            }

            // 表头里的标题文字。Panel.setTitle 走的是 header.child('span'),那个
            // span 就是它(headerTextCls = 'x-panel-header-text')
            function titleTextEl(cmp) {
                if (!cmp.header || !cmp.header.dom) return null;
                return cmp.header.child('span.x-panel-header-text') || cmp.header.child('span');
            }

            // --- 2. 左侧组件库 ---
            // 以前是 TreePanel:折叠展开、节点缩进、长文本换行,在 200px 宽里找个
            // 组件要点好几下。改成按分组铺开的小区块,一屏看全,直接往画布上拖。
            var PALETTE_GROUPS = [
                { key: 'layout',    title: '布局容器' },
                { key: 'container', title: '容器组件' },
                // 表格、树形表格、交叉表都是 Panel 派生的,但里子是表体,拖放规则
                // 和普通容器不一样,单独分一组更好找
                { key: 'table',     title: '表格组件' },
                { key: 'chart',     title: '图表组件' },
                { key: 'form',      title: '表单组件' },
                { key: 'basic',     title: '基础组件' }
            ];

            // 卡片是纯 HTML(不是 Ext 组件):这里只是拖拽的起点,不需要各自的
            // Ext 组件生命周期,用组件反而要处理布局、选中、事件一堆东西
            function buildPaletteHtml() {
                var html = ['<div class="palette">'];
                PALETTE_GROUPS.forEach(function(g) {
                    var tiles = componentTemplates.filter(function(t) { return t.group === g.key; });
                    if (!tiles.length) return;
                    html.push('<div class="palette-group" data-group="' + g.key + '">');
                    html.push('<div class="palette-group-title">' + g.title + '</div>');
                    html.push('<div class="palette-tiles">');
                    tiles.forEach(function(t) {
                        var icon = PALETTE_ICONS[t.xtype];
                        // data-xtype 是拖拽时唯一要带走的东西(见 initPaletteDragZone)。
                        // 图标是装饰:没有图标的组件退化成纯文字卡片,其他照旧
                        html.push('<div class="palette-tile" data-xtype="' + t.xtype + '" title="' + t.text + '">');
                        if (icon) {
                            html.push('<svg class="palette-icon" viewBox="0 0 16 16" aria-hidden="true">' + icon + '</svg>');
                        }
                        html.push('<span class="palette-label">' + (t.short || t.text) + '</span>');
                        html.push('</div>');
                    });
                    html.push('</div>');
                    html.push('</div>');
                });
                html.push('</div>');
                return html.join('');
            }

            var componentPalette = new Ext.Panel({
                region: 'west',
                title: '组件库',
                width: 200,
                split: true,
                collapsible: true,
                autoScroll: true,
                cls: 'component-palette',
                html: buildPaletteHtml(),
                listeners: {
                    'render': function(p) { initPaletteDragZone(p.body.dom); }
                }
            });

            // --- 3. 中间画布(顶部/左侧标尺 + 设计区)---
            // 标尺用 BoxComponent + autoEl:canvas,组件自身元素即画布
            var rulerH = new Ext.BoxComponent({
                region: 'north',
                height: 20,
                autoEl: { tag: 'canvas', cls: 'designer-ruler' },
                listeners: { 'resize': function() { drawRulers(); } }
            });
            var rulerV = new Ext.BoxComponent({
                region: 'west',
                width: 20,
                autoEl: { tag: 'canvas', cls: 'designer-ruler' },
                listeners: { 'resize': function() { drawRulers(); } }
            });

            var designCanvas = new Ext.Panel({
                region: 'center',
                id: 'design-canvas',
                cls: 'canvas-area',
                border: false,
                autoScroll: true,
                // 中间这块默认就是绝对定位:组件扔在哪就留在哪,位置由 x/y 说了
                // 算,不会被布局重排(Ext 的绝对布局认 x/y 配置,渲染时写进
                // left/top)。_designerLayout 是设计器自己的标记,拖拽摆放、命中
                // 嵌套容器、生成代码要不要写 layout: 'absolute' 都看它。
                layout: 'absolute',
                _designerLayout: 'absolute',
                html: '<div class="canvas-placeholder" id="placeholder-msg">拖拽左侧组件到此处<br/>Start Dragging Here</div>',
                listeners: {
                    'render': function(p) {
                        initCanvasDropZone(p.body.dom);
                        initDesignerGuides(p); // 标尺绘制 + 辅助线层
                    },
                    'resize': function() { drawRulers(); growCanvas(); }
                }
            });

            var canvasPanel = new Ext.Panel({
                region: 'center',
                layout: 'border',
                border: false,
                bodyStyle: 'background-color:#f5f5f5;',
                items: [rulerH, rulerV, designCanvas]
            });

            // --- 4. 右侧属性面板 ---
            // 属性表里某个 cell 用什么编辑器:PROP_SPECS 上标了 kind 的按 kind
            // 生成,剩下的零头(Layout / Header / Region)就在下面手写。
            // 注意布尔值不能直接扔给属性表——它只会渲染成 true/false,得换成
            // 显示/隐藏的下拉。
            function buildCustomEditors() {
                var editors = {};
                Ext.each(PROP_SPECS, function(spec) {
                    if (spec.kind === 'enum' || spec.kind === 'bool') {
                        var data = (spec.kind === 'bool') ? SHOW_HIDE : spec.values;
                        editors[spec.name] = new Ext.grid.GridEditor(new Ext.form.ComboBox({
                            store: new Ext.data.ArrayStore({ fields: ['value'], data: data }),
                            valueField: 'value',
                            displayField: 'value',
                            mode: 'local',
                            triggerAction: 'all',
                            editable: false
                        }));
                    } else if (spec.wide) {
                        // 数据那一行最长(「一月:120, 二月:200, …」),属性表的值列
                        // 才一百多像素,cell 里只能看见开头。这个编辑器单独放宽,
                        // 并且改成右边缘对齐(tr-tr):编辑框从单元格右边往左铺开,
                        // 正好盖住整列,不会顶到窗口外面去。
                        // autoSize 是 Ext.grid.GridEditor 原型上的 "width":不给它
                        // 关掉,编辑器每次都会被强行拉成单元格那么宽,上面那个
                        // 宽度白写
                        editors[spec.name] = new Ext.grid.GridEditor(
                            new Ext.form.TextField({ width: 232 }),
                            { alignment: 'tr-tr', autoSize: false }
                        );
                    }
                });
                return editors;
            }

            // 属性面板底部的「加载列」:只有选中的是画布上的高级表格时才可用
            var loadColsBtn = new Ext.Button({
                text: '加载列',
                disabled: true,
                handler: loadAdvGridColumns
            });

            var propertyGrid = new Ext.grid.PropertyGrid({
                region: 'east',
                title: '属性配置',
                width: 250,
                split: true,
                source: {},
                bbar: [loadColsBtn],
                // 各组件自己的行在 PROP_SPECS 里(连编辑器一起),这里补上表里
                // 不出现的几个:布局方式、表头开关、区域名
                customEditors: Ext.apply(buildCustomEditors(), {
                    'Layout': new Ext.grid.GridEditor(
                        new Ext.form.ComboBox({
                            store: new Ext.data.ArrayStore({ fields: ['value'], data: LAYOUT_DATA }),
                            valueField: 'value',
                            displayField: 'value',
                            mode: 'local',
                            triggerAction: 'all',
                            editable: false
                        })
                    ),
                    'Header': new Ext.grid.GridEditor(
                        new Ext.form.ComboBox({
                            store: new Ext.data.ArrayStore({ fields: ['value'], data: SHOW_HIDE }),
                            valueField: 'value',
                            displayField: 'value',
                            mode: 'local',
                            triggerAction: 'all',
                            editable: false
                        })
                    )
                }),
                listeners: {
                    'afteredit': function(e) {
                        updateComponentProperty(e.record.get('name'), e.value);
                    }
                }
            });

            // --- 5. 主窗口 ---
            // 顶部工具条。注意不能把 tbar 挂在 Viewport 上:Viewport 继承的是
            // Ext.Container,不认 tbar 这个配置项(只有 Panel 会把它做成工具栏),
            // 原来那个 tbar 就一直没渲染出来过。用一块北区面板来装才是能显示的写法。
            var actionBar = new Ext.Panel({
                region: 'north',
                height: 28,
                border: false,
                bodyStyle: 'display: none;', // 只要工具条,不要面板内容区
                tbar: [{
                    text: '获取代码',
                    handler: showCodeWindow
                }, {
                    text: '预览',
                    handler: showPreviewWindow
                }, '-', {
                    // 画布上的网格辅助线开关。默认开着(pressed: true 会带上
                    // x-btn-pressed),关掉就是在 .canvas-area 上挂个 no-grid
                    text: '网格',
                    enableToggle: true,
                    pressed: true,
                    toggleHandler: function(btn, on) { setCanvasGrid(on); }
                }]
            });

            new Ext.Viewport({
                layout: 'border',
                items: [actionBar, componentPalette, canvasPanel, propertyGrid]
            });

            // ================= 右键菜单 / 分割条 =================
            function isLayoutContainer(cmp) {
                var t = cmp.getXType();
                return t === 'panel' || t === 'form';
            }

            function regionPanels(cmp) {
                var out = [];
                if (cmp && cmp.items) {
                    cmp.items.each(function(c) { if (c._isDesignerRegion) out.push(c); });
                }
                return out;
            }

            // 选中 + 右键菜单。最内层组件先响应(stopEvent 阻止冒泡到父容器)
            function bindDesignerEvents(cmp) {
                if (cmp._designerBound) return;
                cmp._designerBound = true;
                var bind = function() {
                    // 用可见盒子绑事件:日期/下拉这类组件的 el 只是里面的输入框,
                    // 绑在它上面点右边的扳机是不会选中的
                    var el = designerEl(cmp);
                    if (!el) return;
                    el.on('click', function(e) {
                        e.stopPropagation();
                        selectComponent(cmp);
                    });
                    el.on('contextmenu', function(e) {
                        e.stopEvent();
                        showDesignerMenu(cmp, e.getXY());
                    });
                };
                // 区域面板是先建后挂载的,创建时还没渲染,得等 render 再绑
                if (cmp.rendered) bind();
                else cmp.on('render', bind, cmp, { single: true });
            }

            function showDesignerMenu(cmp, xy) {
                var items = [];
                // 分割区域本身就是布局容器,所以同样能继续分割(嵌套区域)
                if (isLayoutContainer(cmp)) {
                    items.push({ text: '设置左右分割条', handler: function() { applySplit(cmp, 'horizontal'); } });
                    items.push({ text: '设置上下分割条', handler: function() { applySplit(cmp, 'vertical'); } });
                    items.push('-');
                    items.push({ text: '添加分割区域', menu: { items: [
                        { text: '上 (north)', handler: function() { addRegion(cmp, 'north'); } },
                        { text: '下 (south)', handler: function() { addRegion(cmp, 'south'); } },
                        { text: '左 (west)',  handler: function() { addRegion(cmp, 'west'); } },
                        { text: '右 (east)',  handler: function() { addRegion(cmp, 'east'); } }
                    ] } });
                    items.push({ text: '清除分割区域', disabled: !regionPanels(cmp).length,
                                 handler: function() { clearSplit(cmp); } });
                    items.push('-');
                }
                // 页签面板:增删页签
                if (cmp.getXType() === 'tabpanel') {
                    items.push({ text: '添加页签', handler: function() { addDesignerTab(cmp); } });
                    items.push({ text: '删除当前页签', disabled: cmp.items.getCount() <= 1,
                                 handler: function() { removeDesignerTab(cmp.getActiveTab()); } });
                    items.push('-');
                }
                // 某个页签本身:同样能加/删,位置在它后面
                if (isTab(cmp)) {
                    items.push({ text: '在此页签后添加页签', handler: function() { insertDesignerTabAfter(cmp); } });
                    items.push({ text: '删除此页签', disabled: cmp.ownerCt.items.getCount() <= 1,
                                 handler: function() { removeDesignerTab(cmp); } });
                    items.push('-');
                }
                // 区域面板不能单独删:它由父容器的区域布局撑着,摘掉会让 border 布局缺角,
                // 要拆就整个用“清除分割区域”;页签也不能当普通组件删,上面已经给了专门入口
                if (!cmp._isDesignerRegion && !isTab(cmp)) {
                    items.push({ text: '删除组件', handler: function() { removeComponent(cmp); } });
                }
                new Ext.menu.Menu({ items: items }).showAt(xy);
            }

            function removeComponent(cmp) {
                var host = cmp.ownerCt;
                if (!host) return;
                // 选中的可能是它自己,也可能是它里面的子组件,两种都要收掉选中态
                if (currentSelectedCmp && isDescendantOf(currentSelectedCmp, cmp)) clearSelection();
                host.remove(cmp, true);
                host.doLayout();
                growCanvas(true); // 删掉最下面那个之后画布要缩回去
            }

            // 切换布局前先销毁旧 layout:BorderLayout 的分割条是它自己创建的 DOM,
            // 不销毁就会在切换布局后残留成孤儿元素。
            function setDesignerLayout(cmp, type) {
                var Cls = Ext.Container.LAYOUTS[type];
                if (!Cls) return;
                if (cmp.layout && cmp.layout.destroy) cmp.layout.destroy();
                cmp.setLayout(new Cls({}));
            }

            // 区域的显示名。以前是直接当面板标题用的,现在只用于画布上的角标
            var REGION_NAMES = { north: '上', south: '下', west: '左', east: '右', center: '主' };

            // 区域面板的虚线框 + 角标。加在 DOM 上而不是 config 的 cls 里,
            // 生成代码时才不会把设计器自己的类名写进去
            function paintRegion(p) {
                var el = designerEl(p);
                if (!el) return;
                el.addClass('designer-region');
                // 角标挂到内层 body 上(它是 position: relative),别落到面板元素上,
                // 否则 CSS 里就必须写 position 才能定位角标,会把区域打回文档流
                var badge = (p.body && p.body.dom) ? p.body.dom : el.dom;
                badge.setAttribute('data-designer-region', REGION_NAMES[p.region] || p.region);
            }

            // 新建区域面板。region/split/minSize 必须写在初始 config 里:
            // BorderLayout 是用 item.initialConfig 构造 Region 的(见源码 onLayout)。
            // 不给 title:区域只是布局骨架,标题栏白白占掉高度,生成代码里也会多出
            // 「上区域/左区域」这类假标题。而 Ext 的表头是渲染时按 title 建的,
            // 没有 title 就没有表头、事后也补不出来(Panel.setTitle 只改已有表头),
            // 所以区域的可辨识度交给 .designer-region 的虚线框和角标。
            function createRegion(pos) {
                var cfg = { xtype: 'panel', region: pos, layout: 'auto', _isDesignerRegion: true };
                if (pos === 'north' || pos === 'south') cfg.height = 110;
                else if (pos === 'west' || pos === 'east') cfg.width = 170;
                if (pos !== 'center') { cfg.split = true; cfg.minSize = 30; }
                var p = Ext.ComponentMgr.create(cfg);
                // 主区域也要能选中、也能再分割,所以统一绑事件
                bindDesignerEvents(p);
                // 拖分割条改的尺寸要能同步回 config
                bindRegionResize(p);
                // 建出来还没渲染,designerEl 这时是 null,等挂上去渲染完再画
                if (p.rendered) paintRegion(p);
                else p.on('render', function() { paintRegion(p); }, p, { single: true });
                return p;
            }

            // 组件在 DOM 里的可见节点不一定是 el:日期/时间/下拉/触发字段、滑块、
            // 富文本外面还包着一层 wrap(el 只是里面的 input)。搬 DOM 时必须搬 wrap,
            // 否则 input 被拽走、wrap 留在原地塌成 0 高,绑在 wrap 上的点击也点不到了
            function moveDomInto(item, target) {
                var el = designerEl(item);
                if (el && el.dom && target && target.dom) target.dom.appendChild(el.dom);
            }

            // 把组件搬到新容器。Ext 对“已渲染”的子项不会再走 render 流程,
            // DOM 不会自己跟过去,必须手动 append,否则组件仍停在原来的容器里。
            function reparent(item, newParent) {
                newParent.add(item);
                if (item.rendered && newParent.rendered) moveDomInto(item, newParent.body);
                newParent.doLayout();
            }

            // 把容器变成“区域 + 分割条”的 border 布局
            function applySplit(cmp, dir) {
                if (!isLayoutContainer(cmp)) return;
                // 已经是区域布局就先拆干净,否则老区域面板会被当成普通子项搬到主区域里
                if (regionPanels(cmp).length) clearSplit(cmp);
                var existing = [];
                if (cmp.items) cmp.items.each(function(c) { existing.push(c); });
                for (var i = 0; i < existing.length; i++) {
                    if (existing[i].ownerCt) existing[i].ownerCt.remove(existing[i], false);
                }

                var side = createRegion(dir === 'vertical' ? 'north' : 'west');
                var main = createRegion('center');

                setDesignerLayout(cmp, 'border');
                cmp._designerLayout = 'border';
                cmp.add(side);
                cmp.add(main);
                cmp.doLayout();

                for (i = 0; i < existing.length; i++) reparent(existing[i], main);
                cmp.doLayout();
                selectComponent(cmp);
            }

            function addRegion(cmp, pos) {
                if (!isLayoutContainer(cmp)) return;
                if (cmp._designerLayout !== 'border') applySplit(cmp, 'horizontal'); // 先备好 左区域 + 主区域
                var exists = false;
                cmp.items.each(function(c) { if (c._isDesignerRegion && c.region === pos) exists = true; });
                if (exists) return;

                cmp.add(createRegion(pos));
                setDesignerLayout(cmp, 'border'); // 重建 layout,BorderLayout 才会认出新区域
                cmp.doLayout();
                selectComponent(cmp);
            }

            // 区域里可能还套着区域,收集时要一路钻到底,只把真正的组件带出来;
            // 否则嵌套的区域面板会被当成普通子项搬到上一层,分割结构就散了。
            function collectRegionContents(region, out) {
                if (!region.items) return;
                region.items.each(function(ch) {
                    if (ch._isDesignerRegion) collectRegionContents(ch, out);
                    else out.push(ch);
                });
            }

            // 销毁区域面板之前,先把它的嵌套区域逐层销毁(区域面板的 DOM 是它的子节点,
            // 不先处理会留下挂在已销毁组件下的孤儿)
            function destroyRegionTree(region) {
                if (!region.items) return;
                var nested = [];
                region.items.each(function(c) { if (c._isDesignerRegion) nested.push(c); });
                for (var i = 0; i < nested.length; i++) {
                    destroyRegionTree(nested[i]);
                    region.remove(nested[i], true);
                }
            }

            // 拆掉区域面板,把里面的组件提回容器
            function clearSplit(cmp) {
                var regions = regionPanels(cmp);
                if (!regions.length) return;

                var moved = [];
                for (var i = 0; i < regions.length; i++) collectRegionContents(regions[i], moved);
                for (i = 0; i < moved.length; i++) {
                    if (moved[i].ownerCt) moved[i].ownerCt.remove(moved[i], false);
                    // 先把 DOM 搬回容器,再销毁区域面板,避免组件被一起销毁
                    moveDomInto(moved[i], cmp.body);
                }
                for (i = 0; i < regions.length; i++) destroyRegionTree(regions[i]);
                for (i = 0; i < regions.length; i++) cmp.remove(regions[i], true);

                setDesignerLayout(cmp, 'auto');
                cmp._designerLayout = undefined;
                for (i = 0; i < moved.length; i++) {
                    cmp.add(moved[i]);
                    clearAbsoluteDragFlag(moved[i]);
                }
                cmp.doLayout();
                selectComponent(cmp);
            }

            // ================= 页签面板 =================
            // 页签就是 TabPanel 里的子面板,加/删都走这里,保证新页签也带设计器事件
            function isTab(tab) {
                return !!(tab && tab.ownerCt && tab.ownerCt.getXType() === 'tabpanel');
            }

            function isDescendantOf(cmp, ancestor) {
                var p = cmp;
                while (p) { if (p === ancestor) return true; p = p.ownerCt; }
                return false;
            }

            function addDesignerTab(tp, title) {
                if (!tp || tp.getXType() !== 'tabpanel' || !tp.items) return null;
                var tab = Ext.ComponentMgr.create(
                    newTabConfig(title || ('页签' + (tp.items.getCount() + 1))));
                tp.add(tab);
                tp.doLayout();          // TabPanel 会在这个阶段给新页签建出标签条
                bindDesignerEvents(tab);
                if (tp.setActiveTab) tp.setActiveTab(tab); // 新页签直接亮出来,好往里拖东西
                return tab;
            }

            // 注意是 insert(index, item) 而不是 add(index, item):这个版本的
            // Container.add 收到多个参数会当成「要加多个组件」,位置参数会被当成组件建出来。
            // TabPanel 会在 onAdd 里给新页签补上标签条
            function insertDesignerTabAfter(tab) {
                if (!isTab(tab)) return null;
                var tp = tab.ownerCt;
                var newTab = Ext.ComponentMgr.create(newTabConfig('页签' + (tp.items.getCount() + 1)));
                tp.insert(tp.items.indexOf(tab) + 1, newTab);
                tp.doLayout();
                bindDesignerEvents(newTab);
                if (tp.setActiveTab) tp.setActiveTab(newTab);
                return newTab;
            }

            function removeDesignerTab(tab) {
                if (!isTab(tab)) return;
                var tp = tab.ownerCt;
                if (tp.items.getCount() <= 1) return; // 至少留一个,页签面板不能空着
                // 选中的组件就在这个页签里(或就是它)时,选中态得先清掉,
                // 否则属性表还挂在一个已经销毁的组件上
                if (currentSelectedCmp && isDescendantOf(currentSelectedCmp, tab)) clearSelection();
                tp.remove(tab, true);
                tp.doLayout();
                selectComponent(tp);
            }

            function clearSelection() {
                var el = designerEl(currentSelectedCmp);
                if (el) el.removeClass('x-component-selected');
                currentSelectedCmp = null;
                clearResizeHandles();
                propertyGrid.setSource({});
            }

            // ================= 画布组件的缩放把手 =================
            // 把手放在一个独立浮层里,而不是塞进组件内部:按钮的 el 是 <table>、
            // 输入框的 el 就是 <input>,往这两种元素里塞 div 要么报 DOM 异常,
            // 要么塞进去了也根本不显示。浮层只镜像组件的位置和尺寸,组件本身不动。
            var resizeHost = null, resizeTrackers = [];

            // 主区域的大小是 border 布局按剩余空间算出来的,拖了也会被下一次布局覆盖;
            // 页签的尺寸由 TabPanel 的卡片布局接管,同理拖不动
            function canResize(cmp) {
                if (cmp._isDesignerRegion && cmp.region === 'center') return false;
                if (isTab(cmp)) return false;
                return true;
            }

            // 有些字段(日期/时间/下拉/触发字段、滑块、富文本)真正的可见盒子是包在
            // 外面的 wrap,el 只是个取值用的 input:滑块的是 <input type=hidden>(量出来
            // 是 0×0),触发类的只量得到输入框那一截(漏掉右边的扳机)。
            // 量尺寸、画选中框、贴把手都要用这个可见盒子。
            function designerEl(cmp) {
                if (!cmp) return null;
                if (cmp.positionEl && cmp.positionEl.dom && cmp.positionEl !== cmp.el) return cmp.positionEl;
                var el = cmp.getEl ? cmp.getEl() : null;
                return (el && el.dom) ? el : null;
            }

            function isAbsoluteChild(cmp) {
                return !!(cmp.ownerCt && cmp.ownerCt._designerLayout === 'absolute');
            }

            // 绝对定位的子项能八向缩放(往左/上拖会连带改 x/y);
            // 其余组件的坐标由布局决定,尺寸只能往右下长
            function resizeHandleDirs(cmp) {
                return isAbsoluteChild(cmp) ? ['se', 'e', 's', 'ne', 'nw', 'sw', 'n', 'w'] : ['se', 'e', 's'];
            }

            function ensureResizeHost() {
                if (!resizeHost && canvasBody) {
                    resizeHost = Ext.get(Ext.DomHelper.append(canvasBody.dom, { cls: 'designer-resize-host' }));
                }
                return resizeHost;
            }

            function hideResizeHandles() {
                // 显式用 display 而不是 Ext 的 hide():Ext 默认走 visibility: hidden,
                // 元素在布局里还占着位置,判断起来容易出岔子
                if (resizeHost) resizeHost.setStyle('display', 'none');
            }

            function clearResizeHandles() {
                for (var i = 0; i < resizeTrackers.length; i++) {
                    if (resizeTrackers[i].destroy) resizeTrackers[i].destroy();
                }
                resizeTrackers = [];
                if (resizeHost) resizeHost.update(''); // 连把手 DOM 一起清掉
            }

            // 浮层贴着组件。坐标用「画布 body 的内容坐标」而不是页面坐标:
            // 这样画布一滚动,把手跟着内容一起走,不用重新算一遍。
            function positionResizeHost(cmp) {
                var el = designerEl(cmp);
                if (!el) { hideResizeHandles(); return; }
                var host = ensureResizeHost();
                if (!host) return;
                var box = el.getBox();
                var bb = canvasBody.getBox(), sc = canvasBody.getScroll();
                // 直接写样式而不是 setBox:setBox 要靠元素当前的页面坐标换算偏移,
                // 浮层隐藏时的坐标是没意义的
                host.setStyle({
                    left: (box.x - bb.x + sc.left) + 'px',
                    top: (box.y - bb.y + sc.top) + 'px',
                    width: box.width + 'px',
                    height: box.height + 'px',
                    display: 'block'
                });
            }

            // 只有当前选中的组件带把手,选中切换时由 selectComponent 负责装卸
            function buildResizeHandles(cmp) {
                clearResizeHandles();
                if (!designerEl(cmp) || !canResize(cmp)) { hideResizeHandles(); return; }
                positionResizeHost(cmp);
                var dirs = resizeHandleDirs(cmp);
                for (var i = 0; i < dirs.length; i++) addResizeHandle(cmp, dirs[i]);
            }

            function addResizeHandle(cmp, dir) {
                var h = Ext.get(Ext.DomHelper.append(resizeHost.dom, { cls: 'designer-handle designer-handle-' + dir }));
                var tracker = new Ext.dd.DragTracker({
                    el: h,
                    // onStart 要等超过 5px 容差才触发,其坐标已经不是按下点,
                    // 基准尺寸/坐标必须在 onBeforeStart 里记录
                    onBeforeStart: function(e) {
                        this._pt = e.getXY();
                        this._box = designerEl(cmp).getBox();
                        this._pos = { x: cmp.x || 0, y: cmp.y || 0 };
                        h.addClass('designer-handle-active');
                    },
                    onDrag: function(e) {
                        resizeByHandle(cmp, dir, this, e);
                    },
                    onEnd: function(e) {
                        h.removeClass('designer-handle-active');
                        positionResizeHost(cmp);
                        updatePropertyGrid(cmp);
                    }
                });
                resizeTrackers.push(tracker);
            }

            // 拖动中每帧都直接把尺寸写到组件上,属性面板随之实时刷新(不用轮询)
            function resizeByHandle(cmp, dir, tracker, e) {
                var xy = e.getXY(), b = tracker._box;
                var dx = xy[0] - tracker._pt[0], dy = xy[1] - tracker._pt[1];
                var MIN_W = 24, MIN_H = 16;
                var w = b.width, h = b.height, x = b.x, y = b.y;

                if (dir.indexOf('e') !== -1) w = b.width + dx;
                if (dir.indexOf('s') !== -1) h = b.height + dy;
                if (dir.indexOf('w') !== -1) { w = b.width - dx; x = b.x + dx; }
                if (dir.indexOf('n') !== -1) { h = b.height - dy; y = b.y + dy; }
                // 顶到最小尺寸时让被拖的那条边停住,另一条边保持不动
                if (w < MIN_W) { if (dir.indexOf('w') !== -1) x = b.x + b.width - MIN_W; w = MIN_W; }
                if (h < MIN_H) { if (dir.indexOf('n') !== -1) y = b.y + b.height - MIN_H; h = MIN_H; }
                w = Math.round(w);
                h = Math.round(h);

                cmp.setSize(w, h);
                // 渲染后 setSize 不再回写 config(尺寸相同还直接返回),这里手动记一份,
                // 区域面板生成代码时要用它
                cmp.width = w;
                cmp.height = h;
                if (isAbsoluteChild(cmp)) {
                    // 拖的是页面坐标,容器内坐标的位移和它一样,直接加到原 x/y 上
                    cmp.setPosition(Math.max(0, Math.round(tracker._pos.x + (x - b.x))),
                                    Math.max(0, Math.round(tracker._pos.y + (y - b.y))));
                }
                // 区域面板的尺寸由 border 布局按它自己的尺寸反推,重排后分割条才跟得上
                if (cmp.ownerCt && cmp.ownerCt._designerLayout === 'border') cmp.ownerCt.doLayout();
                positionResizeHost(cmp);
                syncSizeProps(cmp);
            }

            // 只改宽高两行:拖动中整表重建太浪费,也容易把正在编辑的单元格顶掉。
            // getRec 在 PropertyStore(propStore)上,不在内层的 data.Store 上。
            function syncSizeProps(cmp) {
                if (currentSelectedCmp !== cmp || !propertyGrid.propStore || !cmp.getEl()) return;
                var names = ['Width', 'Height'];
                var vals = [Math.round(cmp.getWidth()), Math.round(cmp.getHeight())];
                for (var i = 0; i < names.length; i++) {
                    var rec = propertyGrid.propStore.getRec(names[i]);
                    if (rec) rec.set('value', vals[i]);
                }
            }

            // 区域面板的尺寸是 border 布局按剩余空间算出来的(拖分割条改的也是它),
            // 写回 config 后生成代码里的 width/height 才和画布一致
            function syncRegionSize(cmp) {
                if (!cmp._isDesignerRegion || !cmp.getEl()) return;
                if (cmp.region === 'north' || cmp.region === 'south') cmp.height = Math.round(cmp.getHeight());
                else if (cmp.region === 'west' || cmp.region === 'east') cmp.width = Math.round(cmp.getWidth());
            }

            // 分割条是 Ext.SplitBar 在拖,区域尺寸变了不会有回调,给区域面板挂个
            // resize 监听:尺寸写回 config,选中的话把手和属性表也跟着走
            function bindRegionResize(cmp) {
                if (!cmp._isDesignerRegion || cmp._regionResizeBound) return;
                cmp._regionResizeBound = true;
                cmp.on('resize', function() {
                    syncRegionSize(cmp);
                    if (currentSelectedCmp !== cmp) return;
                    positionResizeHost(cmp);
                    syncSizeProps(cmp);
                });
            }

            // ================= panel 表头 / 颜色 =================
            // Ext 的表头是个 Ext.Element(不是组件),面板没有 title 时压根不建它
            function hasHeader(cmp) {
                return !!(cmp.header && cmp.header.dom && cmp.header.isVisible());
            }

            function setPanelHeader(cmp, on) {
                if (!cmp.header || !cmp.header.dom) return;
                // 面板总高度是「表头 + body」拼出来的,先把当前总高量下来,
                // 改完表头再按这个总高重算 body,面板整体才不会跟着长高或变矮
                var w = cmp.getWidth(), h = cmp.getHeight();
                // 记下这个开关,生成代码时要写成 header: false —— Ext 里表头是
                // 渲染时按 title 建的,光有 title 是去不掉表头的
                cmp._headerHidden = !on;
                // 用 display 而不是 Ext 的 hide()/show():那套默认走 visibility: hidden,
                // 表头在布局里还占着高度,body 顶不上来
                cmp.header.setStyle('display', on ? '' : 'none');
                // 顺带换上 noheader 类,去掉表头下边那条分隔线
                if (cmp.body) {
                    if (on) cmp.body.removeClass('x-panel-body-noheader');
                    else cmp.body.addClass('x-panel-body-noheader');
                }
                // 表头显隐不会自动重算 body 高度(setSize 又被 lastSize 缓存挡着),
                // 直接走一次 onResize,让 body 吃掉 / 让出这块高度
                cmp.onResize(w, h, w, h);
            }

            // 空串 = 恢复主题默认;非法颜色返回 null(调用方忽略)
            function normalizeColor(v) {
                var s = String(v === undefined || v === null ? '' : v).replace(/^\s+|\s+$/g, '');
                if (!s) return '';
                if (/^#?[0-9a-f]{3}$/i.test(s) || /^#?[0-9a-f]{6}$/i.test(s)) {
                    return s.charAt(0) === '#' ? s : '#' + s;
                }
                if (/^[a-z]+$/i.test(s) || /^rgba?\(/i.test(s)) return s; // 颜色名 / rgb(...)
                return null;
            }

            function rgbToHex(c) {
                var m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(c || '');
                if (!m) return c || '';
                var h = function(n) { n = parseInt(n, 10).toString(16); return n.length < 2 ? '0' + n : n; };
                return '#' + h(m[1]) + h(m[2]) + h(m[3]);
            }

            function readColor(el) {
                if (!el || !el.dom) return '';
                var c = el.getStyle('background-color') || '';
                if (!c || c === 'transparent' || /^rgba\(0,\s*0,\s*0,\s*0\)$/.test(c)) return '';
                return rgbToHex(c);
            }

            function setPanelColor(cmp, part, value) {
                var el = (part === 'header') ? cmp.header : cmp.body;
                if (!el || !el.dom) return;
                var color = normalizeColor(value);
                if (color === null) return; // 非法颜色,忽略
                // 表头默认是渐变图,改底色时要一起把背景图压掉,否则颜色看不出来
                el.setStyle({ backgroundColor: color, backgroundImage: color ? 'none' : '' });
            }

            // 按钮/标签走 setText,展示字段是把值渲染进 div,走 setValue
            function setComponentText(cmp, value) {
                var t = cmp.getXType();
                if (t === 'button' || t === 'label') {
                    if (cmp.setText) cmp.setText(value);
                } else if (t === 'displayfield') {
                    if (cmp.setValue) cmp.setValue(value);
                }
            }

            // Ext 3.4 的 Checkbox/Radio 没有 setBoxLabel:标签是渲染时一次性插进 wrap
            // 的,改文案只能自己去动那个 label 元素
            function setBoxLabel(cmp, value) {
                cmp.boxLabel = value;
                var el = designerEl(cmp);
                if (!el) return;
                var lbl = el.child('label.x-form-cb-label');
                if (!lbl) {
                    lbl = el.createChild({ tag: 'label', cls: 'x-form-cb-label',
                                           htmlFor: cmp.getEl() ? cmp.getEl().id : '' });
                }
                lbl.update(value ? Ext.util.Format.htmlEncode(value) : '&#160;');
            }

            // ================= 容器间距 =================
            // 两个量分开配:Padding 是容器内边距(内容与容器边线的距离),
            // Spacing 是子组件之间的外边距。生成代码里分别落到 bodyStyle 和 defaults。

            function toPx(v) {
                var n = parseInt(v, 10);
                return (isNaN(n) || n < 0) ? 0 : n;
            }

            // 外边距一律加在最外层的可见元素上:字段类的 el 只是内部的 input,
            // margin 留在它身上会被外面的 wrap 包进去,凭空多出 20px 空档,
            // 量出来的宽高、选中框、缩放把手都会跟着偏(见 designerEl)
            function applyItemMargin(cmp, px) {
                var el = designerEl(cmp);
                if (el) el.setStyle('margin', px + 'px');
            }

            function normalizeItemMargin(cmp) {
                var el = designerEl(cmp), inner = cmp.getEl();
                if (!el) return;
                if (inner && el !== inner) inner.setStyle('margin', ''); // 让 wrap 去撑外边距
                el.setStyle('margin', '10px');                          // 与默认配置保持一致
            }

            function applyPadding(cmp, value) {
                var px = toPx(value);
                cmp._padding = px;
                // 直接写 body 的 padding:换布局、改尺寸都不会把它冲掉(bodyStyle 只在渲染时生效)
                if (cmp.body) cmp.body.setStyle('padding', px + 'px');
                if (cmp.doLayout) cmp.doLayout();
            }

            function applySpacing(cmp, value) {
                var px = toPx(value);
                cmp._spacing = px;
                if (!cmp.items) return;
                cmp.items.each(function(child) {
                    // 区域面板的尺寸位置全由 border 布局说了算,给它加外边距只会把分割条挤歪
                    if (child._isDesignerRegion || !designerEl(child)) return;
                    applyItemMargin(child, px);
                });
                if (cmp.doLayout) cmp.doLayout();
            }

            // --- 核心逻辑实现 ---

            // 组件库的拖拽源。整块组件库共用一个 DragZone(不是每个区块挂一个),
            // 按下时用 getTarget 反查落在哪个区块上,以后往库里加组件也不用重新绑定。
            // 关于组名:Ext 的 DragZone 与 DropZone 靠 ddGroup 配对,这里沿用最早的
            // 'TreeDD',只是名字,和树没有关系了。
            function initPaletteDragZone(paletteEl) {
                // 拖拽以各种方式结束(放到画布上 / 弹回原位 / 中断)都要把区块的淡出
                // 恢复,所以三条路径都清一次,removeClass 重入没有副作用
                function clearPaletteDragging(data) {
                    if (data && data.ddel) Ext.get(data.ddel).removeClass('palette-tile-dragging');
                }

                new Ext.dd.DragZone(paletteEl, {
                    ddGroup: 'TreeDD',
                    // 只有从区块上按下才开始拖,组件库空白处按下返回 null 即不拖
                    getDragData: function(e) {
                        var tile = e.getTarget('.palette-tile', 5, true);
                        if (!tile) return null;
                        var xtype = tile.getAttribute('data-xtype');
                        if (!xtype) return null;
                        // ddel 是 DragZone 要做拖拽代理(跟着鼠标的那个块)的源节点,
                        // 必须是 DOM 节点:DragZone 会对它 cloneNode
                        return { xtype: xtype, ddel: tile.dom };
                    },
                    // 抓起时把原位的区块淡掉,表示它正被拖走
                    onStartDrag: function() {
                        Ext.get(this.dragData.ddel).addClass('palette-tile-dragging');
                    },
                    onValidDrop: function() {
                        Ext.dd.DragZone.superclass.onValidDrop.apply(this, arguments);
                        clearPaletteDragging(this.dragData);
                    },
                    afterRepair: function() {
                        Ext.dd.DragZone.superclass.afterRepair.apply(this, arguments);
                        clearPaletteDragging(this.dragData);
                    },
                    onEndDrag: function(data) { clearPaletteDragging(data); }
                });
            }

            function initCanvasDropZone(canvasEl) {
                new Ext.dd.DropZone(canvasEl, {
                    ddGroup: 'TreeDD', // 必须与组件库的 DragZone 同组,否则收不到拖放
                    getTargetFromEvent: function(e) {
                        return e.getTarget('.canvas-area') || e.getTarget('.x-panel-body');
                    },
                    onNodeOver: function(target, dd, e, data) {
                        return Ext.dd.DropZone.prototype.dropAllowed;
                    },
                    onNodeDrop: function(target, dd, e, data) {
                        // 拖拽数据来自组件库 DragZone.getDragData
                        var xtype = data && data.xtype;
                        if (!xtype) return false;

                        // 移除占位符
                        var placeholder = document.getElementById('placeholder-msg');
                        if (placeholder) placeholder.parentNode.removeChild(placeholder);

                        createComponentOnCanvas(xtype, e);
                        return true;
                    }
                });
            }

            function createComponentOnCanvas(xtype, dropEvent) {
                var canvas = Ext.getCmp('design-canvas');
                var config = getDefaultConfig(xtype);
                
                var cmp = Ext.ComponentMgr.create(config);
                if (config.layout) cmp._designerLayout = config.layout;

                var host = canvas;
                if (dropEvent) {
                    // 用真 xtype 去判断能不能落在表单上:图表卡片是伪 xtype
                    // (chart-pie 等),真身是 uxchart,拿卡片名判断会认错
                    host = resolveDropHost(getNestTarget(dropEvent.getXY()), config.xtype || xtype) || canvas;
                }

                // 绝对定位的宿主里,落点就是坐标:鼠标松在哪就摆在哪。以前是
                // 按 (n%3)*120 错开排,落点和实际位置对不上,拖到哪儿全凭猜
                if (host._designerLayout === 'absolute' && host.items && dropEvent) {
                    var hb = host.body.getBox();
                    var pt = dropEvent.getXY();
                    cmp.x = Math.max(0, Math.round(pt[0] - hb.x));
                    cmp.y = Math.max(0, Math.round(pt[1] - hb.y));
                }

                host.add(cmp);
                host.doLayout();
                growCanvas();

                // 绑定选中 / 右键菜单(组件此时已经渲染,直接绑定即可,不能用 on('render'))
                bindDesignerEvents(cmp);

                // 默认外边距先落到最外层可见元素上,再按容器的「组件间距」覆盖
                normalizeItemMargin(cmp);
                if (host._spacing != null) applyItemMargin(cmp, host._spacing);

                // 页签面板的子页签是随 config 一起建出来的,得逐个补上事件绑定
                if (cmp.getXType() === 'tabpanel' && cmp.items) {
                    cmp.items.each(function(tab) { bindDesignerEvents(tab); });
                }

                // 绝对定位容器内的子项支持拖拽自由摆放
                if (host._designerLayout === 'absolute') {
                    enableAbsoluteDrag(cmp, host);
                }

                selectComponent(cmp);
                return cmp;
            }

            // 设计器一进来画布上就摆着的那个面板。走的是跟拖进来完全同一条路
            // (getDefaultConfig → 建组件 → 绑定事件 → 选中),所以它的默认外观、
            // 属性行、生成出来的代码跟手动拖进来的面板一模一样,只是落点是定死的:
            // 没有拖放事件就没有落点,绝对布局会把它贴在左上角
            var DEFAULT_PANEL_X = 20, DEFAULT_PANEL_Y = 20;
            function createDefaultCanvasPanel() {
                // 画布上有东西了,空占位提示就该收掉(拖放那条路也是这么做的)
                var msg = document.getElementById('placeholder-msg');
                if (msg && msg.parentNode) msg.parentNode.removeChild(msg);
                var cmp = createComponentOnCanvas('panel');
                cmp.setPosition(DEFAULT_PANEL_X, DEFAULT_PANEL_Y);
                // 挪过位置之后缩放把手要重新贴上去(选中时已经摆过一次了),
                // 属性表里的 X/Y 也是选中那一刻读的(那时还没定位),一并回正
                positionResizeHost(cmp);
                updatePropertyGrid(cmp);
                return cmp;
            }

            // ================= 标尺 / 辅助线 / 自动吸附 =================
            // 相关状态变量在文件顶部(第 0 节)声明,见那里的说明。

            function initDesignerGuides(panel) {
                canvasBody = panel.body;
                canvasBody.setStyle('position', 'relative');
                panel.el.setStyle('position', 'relative');
                overlay = Ext.get(Ext.DomHelper.append(panel.el, { cls: 'designer-overlay' }));

                snapV = makeLine('designer-snap designer-snap-v');
                snapH = makeLine('designer-snap designer-snap-h');

                // 从标尺按住拖出辅助线:顶部标尺出竖线,左侧标尺出横线
                rulerH.el.on('mousedown', function(e) {
                    e.stopEvent();
                    startLineDrag(addGuide(true, pageToCanvas(e.getPageX(), e.getPageY())[0]), e);
                });
                rulerV.el.on('mousedown', function(e) {
                    e.stopEvent();
                    startLineDrag(addGuide(false, pageToCanvas(e.getPageX(), e.getPageY())[1]), e);
                });

                canvasBody.on('scroll', function() { drawRulers(); refreshGuides(); });
                Ext.EventManager.onWindowResize(function() {
                    drawRulers();
                    refreshGuides();
                    // 布局重排后组件的位置会变,缩放把手要重新贴上去
                    if (currentSelectedCmp) positionResizeHost(currentSelectedCmp);
                });

                drawRulers();
                growCanvas();
            }

            // 画布 body 的最小高度。绝对定位的子项不占文档流,body 不会被子项撑
            // 高:不给它一个高度,内容全被裁在 0 高的盒子里,滚动条也出不来。
            // 这里按最下面那个组件的底边把 body 撑开(底下再留一截空白方便接着
            // 拖),并且至少填满可视区。
            // shrink=false 时只长不缩——拖动过程中每挪一下就重算,允许缩回去的话
            // 拖拽时的钳位范围会跟着变,组件会自己跳。
            // 画布内容底下留出来的空白:够接着往下拖,导出页里也不会贴着底边
            var CANVAS_PAD = 80;

            // 画布上内容的实际范围:最下面那个组件的底边到画布原点的距离。
            // 生成代码时算导出页该留多高用的也是它
            function canvasExtent() {
                var p = Ext.getCmp('design-canvas');
                var extent = 0;
                if (p && p.items) {
                    p.items.each(function(c) {
                        var el = designerEl(c);
                        if (!el || !el.dom) return;
                        extent = Math.max(extent, (c.y || 0) + Math.round(el.getHeight()));
                    });
                }
                return extent;
            }

            var canvasMinH = 0;
            function growCanvas(shrink) {
                var p = Ext.getCmp('design-canvas');
                if (!p || !p.body || !p.rendered) return;
                var chrome = (p.header && p.header.dom) ? p.header.getHeight() : 0;
                var need = Math.max(canvasExtent() + CANVAS_PAD, p.getHeight() - chrome, 200);
                if (!shrink) need = Math.max(need, canvasMinH);
                canvasMinH = need;
                p.body.setStyle('min-height', need + 'px');
            }

            // 工具条上那个「网格」按钮:网格是画在 .canvas-area .x-panel-body 上的
            // 背景图,关掉就是挂个 no-grid 类(见样式表里的注释)
            function setCanvasGrid(on) {
                var p = Ext.getCmp('design-canvas');
                if (!p || !p.el) return;
                if (on) p.el.removeClass('no-grid');
                else p.el.addClass('no-grid');
            }

            function makeLine(cls) {
                var d = document.createElement('div');
                d.className = cls;
                overlay.dom.appendChild(d);
                return Ext.get(d);
            }

            // 元素内容区原点(页面坐标)。绝对定位子项以 padding box 为基准,
            // 故 withPadding 传 false;普通流内容需要传 true。
            function elementOrigin(el, withPadding) {
                var d = el.dom;
                return [
                    el.getXY()[0] + el.getBorderWidth('l') + (withPadding ? el.getPadding('l') : 0) - d.scrollLeft,
                    el.getXY()[1] + el.getBorderWidth('t') + (withPadding ? el.getPadding('t') : 0) - d.scrollTop
                ];
            }

            function canvasOrigin() { return elementOrigin(canvasBody, true); }
            function overlayOrigin() { return overlay.getXY(); }

            function pageToCanvas(pageX, pageY) {
                var o = canvasOrigin();
                return [pageX - o[0], pageY - o[1]];
            }

            function canvasToOverlay(x, y) {
                var co = canvasOrigin(), oo = overlayOrigin();
                return [co[0] + x - oo[0], co[1] + y - oo[1]];
            }

            function pageToOverlay(pageX, pageY) {
                var oo = overlayOrigin();
                return [pageX - oo[0], pageY - oo[1]];
            }

            // ---------- 手动辅助线 ----------
            function addGuide(vertical, pos) {
                var g = {
                    vertical: vertical,
                    pos: pos,
                    el: makeLine(vertical ? 'designer-guide designer-guide-v' : 'designer-guide designer-guide-h')
                };
                g.el.on('mousedown', function(e) { e.stopEvent(); startLineDrag(g, e); });
                g.el.on('dblclick', function(e) { e.stopEvent(); removeGuide(g); }); // 双击删除
                guides.push(g);
                placeGuide(g);
                return g;
            }

            function placeGuide(g) {
                var p = canvasToOverlay(g.vertical ? g.pos : 0, g.vertical ? 0 : g.pos);
                if (g.vertical) g.el.setStyle({ left: p[0] + 'px', top: '0px', height: '100%' });
                else g.el.setStyle({ top: p[1] + 'px', left: '0px', width: '100%' });
            }

            function removeGuide(g) {
                g.el.remove();
                for (var i = 0; i < guides.length; i++) {
                    if (guides[i] === g) { guides.splice(i, 1); break; }
                }
            }

            function refreshGuides() {
                for (var i = 0; i < guides.length; i++) placeGuide(guides[i]);
                hideSnapLines();
            }

            function startLineDrag(g, e) {
                var move = function(ev) {
                    var c = pageToCanvas(ev.getPageX(), ev.getPageY());
                    g.pos = g.vertical ? c[0] : c[1];
                    placeGuide(g);
                };
                var up = function(ev) {
                    Ext.getDoc().un('mousemove', move);
                    Ext.getDoc().un('mouseup', up);
                    var c = pageToCanvas(ev.getPageX(), ev.getPageY());
                    if ((g.vertical ? c[0] : c[1]) < -1) removeGuide(g); // 拖回标尺区域即删除
                };
                Ext.getDoc().on('mousemove', move);
                Ext.getDoc().on('mouseup', up);
                move(e);
            }

            // ---------- 吸附提示线 ----------
            function hideSnapLines() {
                if (snapV) snapV.setStyle('display', 'none');
                if (snapH) snapH.setStyle('display', 'none');
            }

            function showSnapLines(res) {
                if (!snapV) return;
                if (res.vx !== null) {
                    var p = pageToOverlay(res.vx, 0);
                    snapV.setStyle({ left: p[0] + 'px', top: '0px', height: '100%', display: 'block' });
                } else snapV.setStyle('display', 'none');
                if (res.hy !== null) {
                    var q = pageToOverlay(0, res.hy);
                    snapH.setStyle({ top: q[1] + 'px', left: '0px', width: '100%', display: 'block' });
                } else snapH.setStyle('display', 'none');
            }

            // 计算吸附偏移:把被拖组件左/中/右、上/中/下与兄弟组件、容器边界、
            // 手动辅助线做比对,返回 { dx, dy, vx, hy }(vx/hy 为页面坐标的对齐线)
            function computeSnap(cmp, container, x, y) {
                var origin = elementOrigin(container.body, false),
                    w = cmp.getWidth(), h = cmp.getHeight(),
                    left = origin[0] + x, top = origin[1] + y,
                    myX = [left, left + w / 2, left + w],
                    myY = [top, top + h / 2, top + h],
                    candX = [], candY = [],
                    i, a, b, d, best;

                container.items.each(function(c) {
                    if (c === cmp || !c.getEl() || !c.getEl().dom) return;
                    var box = c.getEl().getBox();
                    candX.push(box.x, box.x + box.width / 2, box.x + box.width);
                    candY.push(box.y, box.y + box.height / 2, box.y + box.height);
                });

                var cw = container.body.getWidth(), ch = container.body.getHeight();
                candX.push(origin[0], origin[0] + cw / 2, origin[0] + cw);
                candY.push(origin[1], origin[1] + ch / 2, origin[1] + ch);

                var co = canvasOrigin();
                for (i = 0; i < guides.length; i++) {
                    if (guides[i].vertical) candX.push(co[0] + guides[i].pos);
                    else candY.push(co[1] + guides[i].pos);
                }

                var res = { dx: 0, dy: 0, vx: null, hy: null };
                best = SNAP + 1;
                for (a = 0; a < myX.length; a++) {
                    for (b = 0; b < candX.length; b++) {
                        d = candX[b] - myX[a];
                        if (Math.abs(d) <= SNAP && Math.abs(d) < best) { best = Math.abs(d); res.dx = d; res.vx = candX[b]; }
                    }
                }
                best = SNAP + 1;
                for (a = 0; a < myY.length; a++) {
                    for (b = 0; b < candY.length; b++) {
                        d = candY[b] - myY[a];
                        if (Math.abs(d) <= SNAP && Math.abs(d) < best) { best = Math.abs(d); res.dy = d; res.hy = candY[b]; }
                    }
                }
                return res;
            }

            // ---------- 标尺绘制 ----------
            function drawRulers() {
                if (!canvasBody) return;
                drawRuler(rulerH, true);
                drawRuler(rulerV, false);
            }

            function drawRuler(bc, horizontal) {
                if (!bc || !bc.rendered || !bc.el || !bc.el.dom) return;
                var cv = bc.el.dom,
                    w = bc.el.getWidth(), h = bc.el.getHeight(),
                    len = horizontal ? w : h,
                    thick = horizontal ? h : w;
                if (!len || !thick) return;
                // 画布缓冲区必须与元素 CSS 尺寸一致,否则绘制会被拉伸
                if (cv.width !== w) cv.width = w;
                if (cv.height !== h) cv.height = h;

                var ctx = cv.getContext('2d');
                ctx.clearRect(0, 0, cv.width, cv.height);
                ctx.fillStyle = '#f0f0f0';
                ctx.fillRect(0, 0, cv.width, cv.height);

                // 标尺 0 刻度相对画布内容原点的像素偏移(两者左/上边缘对齐时为 0)
                var co = canvasOrigin(), ro = bc.el.getXY();
                var off = horizontal ? (co[0] - ro[0]) : (co[1] - ro[1]);
                var scroll = horizontal ? canvasBody.dom.scrollLeft : canvasBody.dom.scrollTop;
                var start = Math.floor(scroll / 10) * 10, end = scroll + len - off;

                ctx.strokeStyle = '#b5b5b5';
                ctx.beginPath();
                for (var v = start; v <= end; v += 10) {
                    var p = Math.round(v - scroll + off) + 0.5;
                    if (p < 0 || p > len) continue;
                    var major = (v % 50 === 0);
                    if (horizontal) { ctx.moveTo(p, major ? thick - 10 : thick - 4); ctx.lineTo(p, thick); }
                    else { ctx.moveTo(major ? thick - 10 : thick - 4, p); ctx.lineTo(thick, p); }
                }
                ctx.stroke();

                ctx.fillStyle = '#555';
                ctx.font = '9px tahoma, arial, sans-serif';
                for (var v2 = start; v2 <= end; v2 += 50) {
                    if (v2 < 0) continue;
                    var p2 = Math.round(v2 - scroll + off) + 0.5;
                    if (p2 < 0 || p2 > len) continue;
                    if (horizontal) {
                        ctx.fillText(v2, p2 + 2, thick / 2 - 1);
                    } else {
                        ctx.save();
                        ctx.translate(thick / 2 - 1, p2 + 2);
                        ctx.rotate(-Math.PI / 2);
                        ctx.fillText(v2, 0, 0);
                        ctx.restore();
                    }
                }
            }

            function enableAbsoluteDrag(cmp, container) {
                if (cmp._absDragEnabled) return; // 幂等:多次切换布局不会重复挂 DragTracker
                clearAbsoluteDragFlag(cmp); // 若之前有残留实例,先销毁再重建
                cmp._absDragEnabled = true;
                cmp._absTracker = new Ext.dd.DragTracker({
                    el: cmp.getEl(),
                    // onStart 要等超过 tolerance 才触发,其事件坐标已不是按下点,
                    // 因此基准坐标必须在 onBeforeStart(真正的 mousedown)里记录,
                    // 否则组件会滞后一个 tolerance 的偏移量。
                    onBeforeStart: function(e) {
                        this._startXY = e.getXY();
                        this._startPos = [cmp.x || 0, cmp.y || 0];
                    },
                    onDrag: function(e) {
                        var xy = e.getXY();
                        var x = this._startPos[0] + (xy[0] - this._startXY[0]);
                        var y = this._startPos[1] + (xy[1] - this._startXY[1]);
                        // 限制在容器 body 内,避免拖出可视范围
                        var body = container.body;
                        x = Math.max(0, Math.min(x, body.getWidth() - cmp.getWidth()));
                        y = Math.max(0, Math.min(y, body.getHeight() - cmp.getHeight()));
                        // 自动吸附:对齐兄弟组件 / 容器边界 / 手动辅助线
                        var snap = computeSnap(cmp, container, x, y);
                        x = Math.max(0, Math.min(x + snap.dx, body.getWidth() - cmp.getWidth()));
                        y = Math.max(0, Math.min(y + snap.dy, body.getHeight() - cmp.getHeight()));
                        cmp.setPosition(x, y);
                        // 拖到画布下半截时 body 要跟着长高,否则钳位够不着(见 growCanvas)
                        growCanvas();
                        // 选中的组件被拖走时,缩放把手要跟着走
                        if (currentSelectedCmp === cmp) positionResizeHost(cmp);
                        showSnapLines(snap);
                    },
                    onEnd: function(e) {
                        hideSnapLines();
                        selectComponent(cmp);
                        growCanvas(true);
                    }
                });
            }

            // 组件离开绝对定位容器后要把拖拽器一起撤掉,
            // 否则它仍会响应鼠标并往 setPosition 写坐标。
            function clearAbsoluteDragFlag(cmp) {
                if (cmp._absTracker) {
                    if (cmp._absTracker.destroy) cmp._absTracker.destroy();
                    cmp._absTracker = null;
                }
                cmp._absDragEnabled = false;
            }

            function getNestTarget(xy) {
                var canvas = Ext.getCmp('design-canvas');
                var targets = canvas.findBy(function(c) {
                    // 分割区域本身也是可投放目标(它的 body 比外层容器小,优先生效)
                    return isDropContainer(c);
                });
                var best = null, bestArea = Infinity;
                for (var i = 0; i < targets.length; i++) {
                    var t = targets[i];
                    // 分割区域用整块面板参与命中,这样落在它的标题栏上也算落在区域里;
                    // 页签面板同理:标题栏(标签条)在 header 里,不在 body 里
                    var box = ((t._isDesignerRegion || t.getXType() === 'tabpanel') && t.getEl())
                              ? t.getEl().getBox()
                              : (t.body ? t.body.getBox() : null);
                    if (!box) continue;
                    if (xy[0] >= box.x && xy[0] <= box.x + box.width &&
                        xy[1] >= box.y && xy[1] <= box.y + box.height) {
                        var area = box.width * box.height;
                        if (area < bestArea) { best = targets[i]; bestArea = area; }
                    }
                }
                return best;
            }

            // 命中到的容器不一定能直接接住这个组件,这里做一次归属换算:
            // 返回 null 表示「不放进去」,调用方会退回到画布根上
            function resolveDropHost(target, xtype) {
                if (!target) return null;
                // 页签面板本身不存组件(没有标题的卡片建不出页签),一律投给当前页签
                if (target.getXType() === 'tabpanel') {
                    var at = target.getActiveTab ? target.getActiveTab() : null;
                    return at || null;
                }
                // 落在 border 容器本体上(标题栏、分割条等)时改投主区域:
                // 没有 region 的子项 BorderLayout 不会给它定位,会叠在左上角。
                if (target._designerLayout === 'border' && !target._isDesignerRegion) {
                    var centers = target.findBy(function(c) { return c._isDesignerRegion && c.region === 'center'; });
                    return centers.length ? centers[0] : null;
                }
                // 表单只收字段/按钮,容器类组件不往里塞
                if (target.getXType() === 'form' && CONTAINER_XTYPES.indexOf(xtype) !== -1) return null;
                return target;
            }

            // 字段名要唯一,重复 name 的表单字段提交时后面的会盖掉前面的
            var cfgSeq = 0;
            function fieldName(prefix) { return prefix + '_' + (++cfgSeq); }

            // 下拉框的选项,画布和生成代码共用同一份
            var COMBO_DATA = [['1', '选项一'], ['2', '选项二'], ['3', '选项三']];

            // ---- 表格 / 树形表格 / 交叉表的示例数据 ----
            // 表格和交叉表没数据就是一片空白,先给一份内存数据,拖进画布就有东西看。
            // 这些数组只被读(Reader 读行时会另建记录对象),共用同一份没有副作用;
            // 而列的配置会被 ColumnModel / TreeGrid.initColumns 改写,所以下面凡是
            // 列都用工厂函数现造一份。
            var GRID_FIELDS = ['id', 'name', 'dept', 'amount'];
            var GRID_DATA = [
                ['001', '张三', '研发部', 12800],
                ['002', '李四', '市场部', 9600],
                ['003', '王五', '财务部', 14300],
                ['004', '赵六', '研发部', 11200]
            ];
            function gridColumns() {
                return [
                    { header: '编号', dataIndex: 'id', width: 70 },
                    { header: '姓名', dataIndex: 'name', width: 100 },
                    { header: '部门', dataIndex: 'dept', width: 100 },
                    { header: '金额', dataIndex: 'amount', width: 90, align: 'right' }
                ];
            }
            // 树形表格的示例树。同样写成工厂函数:TreeLoader.createNode 会往传进去的
            // 节点配置上回写 loader 字段,共用同一个对象的话,第二棵树拿到的会是
            // 上一棵已经销毁的树的 loader。最深层节点要标 leaf,否则渲染出来的
            // 展开图标点下去既没数据也没回调,会一直转
            function treeGridRootConfig() {
                return {
                    text: '公司', task: '公司', expanded: true, children: [
                        { text: '研发部', task: '研发部', duration: 13, user: '张三', expanded: true, children: [
                            { text: '前台页面', task: '前台页面', duration: 5, user: '张三', leaf: true },
                            { text: '后台接口', task: '后台接口', duration: 8, user: '李四', leaf: true }
                        ] },
                        { text: '市场部', task: '市场部', duration: 6, user: '王五', expanded: true, children: [
                            { text: '渠道推广', task: '渠道推广', duration: 6, user: '王五', leaf: true }
                        ] }
                    ]
                };
            }
            function treeGridColumns() {
                return [
                    { header: '任务', dataIndex: 'task', width: 200 },
                    { header: '工时', dataIndex: 'duration', width: 70, align: 'center' },
                    { header: '负责人', dataIndex: 'user', width: 90 }
                ];
            }
            // 交叉表的示例数据:地区 × 季度 的销售额
            var PIVOT_FIELDS = ['region', 'quarter', 'amount'];
            var PIVOT_DATA = [
                ['华东', 'Q1', 120], ['华东', 'Q2', 150], ['华东', 'Q3', 138],
                ['华北', 'Q1', 100], ['华北', 'Q2', 130], ['华北', 'Q3', 118],
                ['华南', 'Q1', 90],  ['华南', 'Q2', 112], ['华南', 'Q3', 126]
            ];

            // ---- 图表的示例数据 ----
            // Ext.ux.Chart 认的数据就是 [{ label, value }],直接给一份内存数据,
            // 拖进画布立刻就有东西看。每次都现造一行新数组:属性表里改数据是整段
            // 替换(setChartData),共用一份会被改到别人头上。
            var CHART_TITLES = { pie: '占比分布', line: '走势', bar: '月度销量', scatter: '分布' };
            var CHART_CATEGORIES = ['一月', '二月', '三月', '四月', '五月', '六月'];
            var CHART_VALUES = [120, 200, 150, 80, 170, 210];
            function chartDemoData() {
                var rows = [];
                Ext.each(CHART_CATEGORIES, function(lb, i) { rows.push({ label: lb, value: CHART_VALUES[i] }); });
                return rows;
            }
            // 散点图的横轴是数值轴,标签得给数,不然点会全堆在序号上
            var CHART_SCATTER_POINTS = [[12, 40], [18, 65], [24, 52], [31, 95], [38, 78], [45, 130]];
            function chartScatterData() {
                var rows = [];
                Ext.each(CHART_SCATTER_POINTS, function(p) { rows.push({ label: String(p[0]), value: p[1] }); });
                return rows;
            }

            // 页签面板的子项:TabPanel 会把子面板的 header 去掉(见 onBeforeAdd),
            // 所以每个页签本身就是一块干净的内容区
            function newTabConfig(title) {
                return { xtype: 'panel', title: title, layout: 'auto', _spacing: 10 };
            }

            function getDefaultConfig(xtype) {
                var base = { style: 'margin:10px;' };
                switch(xtype) {
                    case 'layout-absolute':
                        return Ext.apply(base, { xtype: 'panel', title: '绝对定位容器', layout: 'absolute', width: 400, height: 300, frame: true, _spacing: 10 });
                    case 'layout-anchor':
                        return Ext.apply(base, { xtype: 'panel', title: '相对定位容器', layout: 'anchor', width: 400, height: 300, frame: true, _spacing: 10 });
                    case 'panel':
                        // 面板默认不带边框:画布上的面板是页面容器,套一圈边框加
                        // 渐变底看着像个控件。要边框的话右侧有「边框宽度」那一行,
                        // 加上去就是一条实心边框(粗细分开了,比 frame 更好控)
                        return Ext.apply(base, { xtype: 'panel', title: '新面板', width: 300, height: 200, border: false, _spacing: 10 });
                    case 'grid':
                        // 表格没有 store / columns 就是一片空白,配一份内存示例数据,
                        // 拖进来就能看到表头和行
                        var gridStore = new Ext.data.ArrayStore({
                            fields: GRID_FIELDS,
                            data: GRID_DATA
                        });
                        return Ext.apply(base, {
                            xtype: 'grid',
                            title: '新表格',
                            width: 420,
                            height: 220,
                            store: gridStore,
                            columns: gridColumns(),
                            stripeRows: true
                        });
                    case 'advgrid':
                        // 高级表格:普通表格 + 自带工具条(搜索条件、查询/重置)。
                        // 工具条在配置里就建好(Ext 在 initComponent 里把它实例化),
                        // 所以这里用一个"假组件"喂 advGridTbarItems —— 它只用到
                        // columns / getStore / _searchCols 三样东西
                        var advStore = new Ext.data.ArrayStore({
                            fields: GRID_FIELDS,
                            data: GRID_DATA
                        });
                        var advColumns = gridColumns();
                        return Ext.apply(base, {
                            xtype: 'grid',
                            title: '新高级表格',
                            width: 560,
                            height: 300,
                            store: advStore,
                            columns: advColumns,
                            stripeRows: true,
                            // _advGrid 是设计器标记:属性行、生成代码、右侧「加载列」
                            // 都靠它认这个高级表格
                            _advGrid: true,
                            _searchCols: ['name'],
                            _columnUrl: '',
                            tbar: advGridTbarItems({
                                columns: advColumns,
                                getStore: function() { return advStore; },
                                _searchCols: ['name']
                            })
                        });
                    case 'treegrid':
                        // 树形表格(Ext.ux.tree.TreeGrid)。数据走节点树而不是 store:
                        // 根节点带上 children,loader 只负责把嵌套节点建出来
                        // (preloadChildren),不需要后端接口。
                        // 根节点必须是节点实例:TreeGrid.setRootNode 会往 attributes
                        // 上写 uiProvider,传普通对象会报错
                        return Ext.apply(base, {
                            xtype: 'treegrid',
                            title: '新树形表格',
                            width: 420,
                            height: 240,
                            animate: true,
                            loader: { preloadChildren: true },
                            root: new Ext.tree.AsyncTreeNode(treeGridRootConfig()),
                            columns: treeGridColumns()
                        });
                    case 'pivotgrid':
                        // 交叉表(Ext.grid.PivotGrid,ext-all 自带)。行列轴换一个
                        // dataIndex 就是另一种透视
                        var pivotStore = new Ext.data.ArrayStore({
                            fields: PIVOT_FIELDS,
                            data: PIVOT_DATA
                        });
                        return Ext.apply(base, {
                            xtype: 'pivotgrid',
                            title: '新交叉表',
                            width: 460,
                            height: 240,
                            store: pivotStore,
                            aggregator: 'sum',
                            measure: 'amount',
                            stripeRows: true,
                            leftAxis: [{ dataIndex: 'region', width: 90 }],
                            topAxis: [{ dataIndex: 'quarter' }],
                            viewConfig: { title: '销售额' },
                            listeners: {
                                // 交叉表的行列表头和左上角标题只有 view.refresh(true)
                                // 会画:view.renderHeaders 是个空实现,正常渲染流程
                                // 不碰它们(项目里 app/view/SalesPivot.js 也是这么处理的)
                                viewready: function(g) { g.view.refresh(true); }
                            }
                        });
                    case 'chart-pie':
                    case 'chart-line':
                    case 'chart-bar':
                    case 'chart-scatter':
                        // 四个卡片共用同一个组件(Ext.ux.Chart,xtype: 'uxchart'),
                        // chart-* 只是组件库的 key,这里换掉 xtype、把 chartType 定下来。
                        // 数据集也一次给好:图表没数据就是一块空白
                        var ctype = xtype.replace('chart-', '');
                        return Ext.apply(base, {
                            xtype: 'uxchart',
                            chartType: ctype,
                            title: CHART_TITLES[ctype] || '新图表',
                            showLegend: true,
                            width: 400,
                            height: 260,
                            data: (ctype === 'scatter') ? chartScatterData() : chartDemoData()
                        });
                    case 'form':
                        return Ext.apply(base, { xtype: 'form', title: '新表单', width: 350, height: 200, frame: true, labelWidth: 60, _spacing: 10 });
                    case 'fieldset':
                        return Ext.apply(base, { xtype: 'fieldset', title: '字段集', width: 320, height: 160, _spacing: 10 });
                    case 'tabpanel':
                        return Ext.apply(base, {
                            xtype: 'tabpanel', title: '页签面板',
                            width: 420, height: 260, activeTab: 0, _spacing: 10,
                            items: [newTabConfig('页签一'), newTabConfig('页签二')]
                        });

                    // ---- 表单字段 ----
                    case 'textfield':
                        return Ext.apply(base, { xtype: 'textfield', fieldLabel: '标签', name: fieldName('field'), width: 200 });
                    case 'textarea':
                        return Ext.apply(base, { xtype: 'textarea', fieldLabel: '多行文本', name: fieldName('textarea'), width: 240, height: 80 });
                    case 'numberfield':
                        return Ext.apply(base, { xtype: 'numberfield', fieldLabel: '数字', name: fieldName('number'), width: 160 });
                    case 'datefield':
                        return Ext.apply(base, { xtype: 'datefield', fieldLabel: '日期', name: fieldName('date'), width: 160, format: 'Y-m-d', editable: false });
                    case 'timefield':
                        return Ext.apply(base, { xtype: 'timefield', fieldLabel: '时间', name: fieldName('time'), width: 160, format: 'H:i', increment: 30, editable: false });
                    case 'combo':
                        return Ext.apply(base, {
                            xtype: 'combo', fieldLabel: '下拉框', name: fieldName('combo'), width: 200,
                            mode: 'local', editable: false, triggerAction: 'all', forceSelection: true,
                            valueField: 'value', displayField: 'text',
                            store: new Ext.data.ArrayStore({ fields: ['value', 'text'], data: COMBO_DATA })
                        });
                    case 'checkbox':
                        return Ext.apply(base, { xtype: 'checkbox', fieldLabel: '复选框', boxLabel: '选项', name: fieldName('check') });
                    case 'checkboxgroup':
                        var cbBase = fieldName('cb');
                        return Ext.apply(base, {
                            xtype: 'checkboxgroup', fieldLabel: '复选框组', columns: 2, items: [
                                { xtype: 'checkbox', boxLabel: '选项一', name: cbBase + '_1' },
                                { xtype: 'checkbox', boxLabel: '选项二', name: cbBase + '_2' }
                            ]
                        });
                    case 'radio':
                        return Ext.apply(base, { xtype: 'radio', fieldLabel: '单选框', boxLabel: '选项', name: fieldName('radio') });
                    case 'radiogroup':
                        var rbName = fieldName('rb');
                        return Ext.apply(base, {
                            xtype: 'radiogroup', fieldLabel: '单选框组', columns: 2, items: [
                                { xtype: 'radio', boxLabel: '选项一', name: rbName, inputValue: '1' },
                                { xtype: 'radio', boxLabel: '选项二', name: rbName, inputValue: '2' }
                            ]
                        });
                    case 'displayfield':
                        return Ext.apply(base, { xtype: 'displayfield', fieldLabel: '展示字段', value: '展示内容' });
                    case 'sliderfield':
                        return Ext.apply(base, { xtype: 'sliderfield', fieldLabel: '滑块', name: fieldName('slider'), width: 200, minValue: 0, maxValue: 100, value: 40 });
                    case 'trigger':
                        // 光有 TriggerField 点扳机会报错,得给个空实现
                        return Ext.apply(base, { xtype: 'trigger', fieldLabel: '触发字段', name: fieldName('trigger'), width: 200, onTriggerClick: Ext.emptyFn });
                    case 'label':
                        return Ext.apply(base, { xtype: 'label', text: '文本标签' });
                    case 'htmleditor':
                        return Ext.apply(base, { xtype: 'htmleditor', fieldLabel: '富文本', name: fieldName('html'), width: 380, height: 180 });
                    case 'button':
                        return Ext.apply(base, { xtype: 'button', text: '按钮' });
                    default:
                        return Ext.apply(base, { xtype: xtype });
                }
            }

            var currentSelectedCmp = null;
            function selectComponent(cmp) {
                var prev = designerEl(currentSelectedCmp);
                if (prev) prev.removeClass('x-component-selected');
                currentSelectedCmp = cmp;
                // 区域面板选中时顺手把尺寸写回 config
                if (cmp && cmp._isDesignerRegion) syncRegionSize(cmp);
                var el = designerEl(cmp);
                if (el) {
                    el.addClass('x-component-selected');
                    // 缩放把手只跟着当前选中的组件,切走就被 buildResizeHandles 收掉
                    buildResizeHandles(cmp);
                } else {
                    clearResizeHandles();
                }
                updatePropertyGrid(cmp);
            }

            // 数据在属性表里是一行文本:「一月:120, 二月:200」。整段编辑比给
            // 每行数据做子表格省事得多,改完按回车就重画
            function chartDataText(rows) {
                var out = [];
                Ext.each(rows || [], function(r) { out.push(r.label + ':' + r.value); });
                return out.join(', ');
            }

            // 解析上面那串文本。中英文逗号、分号都当分隔符;「标签」或「:值」缺了
            // 就跳过这一项;值不是数字按 0 算(宁可显示 0,也别让图表整块画不出来)
            function parseChartData(text) {
                var rows = [];
                Ext.each(String(text == null ? '' : text).split(/[,,;;]/), function(pair) {
                    pair = Ext.util.Format.trim(pair);
                    if (!pair) return;
                    var i = pair.lastIndexOf(':');
                    var lb = Ext.util.Format.trim(i === -1 ? pair : pair.substring(0, i));
                    if (!lb) return;
                    var v = (i === -1) ? 0 : parseFloat(Ext.util.Format.trim(pair.substring(i + 1)));
                    rows.push({ label: lb, value: isFinite(v) ? v : 0 });
                });
                return rows;
            }

            function updatePropertyGrid(cmp) {
                // 没选中高级表格时这个按钮按不动,省得点了才弹提示
                if (loadColsBtn) loadColsBtn.setDisabled(!isAdvGrid(cmp));
                if (!cmp) { propertyGrid.setSource({}); return; }
                // 宽高显示实际像素值:布局算出来的尺寸和 config 里的值不一定一致,
                // 而且拖动缩放后两者都会变,只有实际值是不会骗人的
                var source = {
                    'XType': cmp.getXType(),
                    'Width': Math.round(cmp.getWidth()),
                    'Height': Math.round(cmp.getHeight())
                };
                // 区域面板有意不带 title,也就没有表头,Panel.setTitle 只会静默失败
                // (它只更新已存在的表头),所以标题相关的行一概不给,换成区域名
                if (cmp._isDesignerRegion) source['Region'] = REGION_NAMES[cmp.region] || cmp.region;
                else source['Title'] = cmp.title || '';
                // 表头 / 颜色:没有表头的面板(比如主区域)不显示 Header。
                // 注意表头是渲染时就建好的,事后没法凭空造一个,所以只给已有表头的面板开关。
                if (cmp instanceof Ext.Panel) {
                    if (cmp.header && cmp.header.dom) source['Header'] = hasHeader(cmp) ? '显示' : '隐藏';
                    source['BodyColor'] = readColor(cmp.body);
                    if (cmp.header && cmp.header.dom) source['HeaderColor'] = readColor(cmp.header);
                }
                // 其它行全归 PROP_SPECS 管:哪些组件有哪些行、值怎么读,都写在
                // 那张表里(同一个 when 也决定着生成代码时写不写)
                Ext.each(PROP_SPECS, function(spec) {
                    if (spec.when && !spec.when(cmp)) return;
                    source[spec.name] = spec.get(cmp);
                });
                // 容器组件(面板/表单)可设置布局方式。布局是结构性的(切成 border
                // 要搭区域面板),不适合放进「一个属性一个值」的表里
                if (cmp.getXType() === 'panel' || cmp.getXType() === 'form') {
                    source['Layout'] = (cmp.layout && cmp.layout.type) || 'auto';
                }
                propertyGrid.setSource(source);
            }

            function updateComponentProperty(name, value) {
                if (!currentSelectedCmp) return;
                var cmp = currentSelectedCmp;
                // 大部分行由 PROP_SPECS 处理:加一个属性只改表里那一处
                var spec = null;
                Ext.each(PROP_SPECS, function(s) {
                    if (spec || s.name !== name) return;
                    if (s.when && !s.when(cmp)) return;
                    spec = s;
                });
                if (spec) spec.set(cmp, value);
                // 剩下的是结构性的:改布局、开关表头、换底色
                else if (name === 'Title' && cmp.setTitle) cmp.setTitle(value);
                else if (name === 'Width') {
                    var w = parseInt(value, 10);
                    // 顺手记回 config:区域面板生成代码时要用
                    if (w > 0) { cmp.setWidth(w); cmp.width = w; }
                }
                else if (name === 'Height') {
                    var h = parseInt(value, 10);
                    if (h > 0) { cmp.setHeight(h); cmp.height = h; }
                }
                else if (name === 'Layout') applyLayout(cmp, value);
                else if (name === 'Header') setPanelHeader(cmp, String(value) === '显示' || value === true);
                else if (name === 'BodyColor') setPanelColor(cmp, 'body', value);
                else if (name === 'HeaderColor') setPanelColor(cmp, 'header', value);

                if (cmp.ownerCt) cmp.ownerCt.doLayout();
                // 布局可能把尺寸/坐标改掉(区域面板、自动宽度等),把手要重新贴一次,
                // 宽高两行也要回正
                positionResizeHost(cmp);
                syncSizeProps(cmp);
                // 这几项改的是外观/数值而不是 config,改完要把表里的值换成真实值
                // (比如颜色填了缩写、非法值被忽略、间距填了负数时要回退显示)。
                // spec 上标了 refresh 的也是同一类:显示的那串值和存进去的不完全
                // 一样(图表数据要重新拼、开关要换成中文)
                if (spec ? spec.refresh : false) {
                    updatePropertyGrid(cmp);
                } else if (name === 'Header' || name === 'BodyColor' || name === 'HeaderColor' ||
                           name === 'Region') { // Region 只读,改回真实值
                    updatePropertyGrid(cmp);
                }
            }

            function applyLayout(cmp, type) {
                type = String(type || 'auto').toLowerCase();
                // border 不能直接套:它必须带 region 子项,走分割区域搭建这条路
                if (type === 'border') { applySplit(cmp, 'horizontal'); return; }
                var LayoutCls = Ext.Container.LAYOUTS[type];
                if (!LayoutCls) return; // 未知布局方式,忽略
                // 从 border 切到别的布局:先把区域拆掉、组件提回来,否则区域面板
                // 会被当成普通子项留在容器里
                if (regionPanels(cmp).length) clearSplit(cmp);
                setDesignerLayout(cmp, type);
                // 记录布局类型,供生成代码 / 嵌套命中 / 绝对拖拽使用(auto 不写回,保持默认)
                cmp._designerLayout = (type === 'auto') ? undefined : type;
                // 切换到绝对定位时,为已有子项补 x/y 并启用拖拽摆放
                if (type === 'absolute' && cmp.items) {
                    var i = 0;
                    cmp.items.each(function(child) {
                        child.x = (i % 3) * 120;
                        child.y = Math.floor(i / 3) * 70;
                        enableAbsoluteDrag(child, cmp);
                        i++;
                    });
                }
                cmp.doLayout();
                if (currentSelectedCmp === cmp) {
                    updatePropertyGrid(cmp);
                    positionResizeHost(cmp); // 换布局后尺寸/坐标都变了,把手重新贴一次
                }
            }

            // ================= 获取代码 / 预览 =================

            // 下面拼的是 HTML 文本,但这段代码自己就住在 lay.html 的一个 <script>
            // 里:字符串里只要出现完整的结束标签,HTML 解析器会当场把脚本掐断,
            // 后面的代码全都变成页面文字。所以标签一律拆开拼。
            var TAG_SCRIPT = '<' + 'script';
            var TAG_SCRIPT_END = '</' + 'script>';

            // 画布上(含任意层级的子组件)有没有用到某个 xtype。树形表格需要额外的
            // ux 文件和样式,导出时必须一起带上,否则预览里那棵树根本建不出来
            function canvasHasXType(xtype) {
                var canvas = Ext.getCmp('design-canvas');
                if (!canvas || !canvas.items) return false;
                return canvas.findBy(function(c) { return c.getXType() === xtype; }).length > 0;
            }

            // 画布上有没有可拖动的分割条(导出页要不要带上分割条样式就看它)
            function canvasHasSplit() {
                var canvas = Ext.getCmp('design-canvas');
                if (!canvas || !canvas.items) return false;
                return canvas.findBy(function(c) { return c.split === true; }).length > 0;
            }

            // 树形表格的 ux 脚本,顺序照官方示例,TreeGrid.js 必须最后
            var TREE_GRID_UX = ['TreeGridSorter', 'TreeGridColumnResizer', 'TreeGridNodeUI',
                                'TreeGridLoader', 'TreeGridColumns', 'TreeGrid'];

            // Ext 3.4 默认把分割条做成了 5px 的透明抓取带(ext-all.css 里写的是
            // .x-layout-split{background-color:transparent}),平时只靠鼠标指针变成
            // 左右箭头来提示。导出的页面是给人用的,标出来才拖得着。配色和画布上
            // 那条一致。
            var SPLIT_BAR_CSS = [
                '.x-layout-split { background-color: #cfe0ef; }',
                '.x-layout-split:hover { background-color: #7fb2e0; }',
                '.x-layout-split-west, .x-layout-split-east { cursor: ew-resize; }',
                '.x-layout-split-north, .x-layout-split-south { cursor: ns-resize; }'
            ].join('\n');

            // 「获取代码」和「预览」共用同一套生成逻辑,只差这一个开关。
            // 预览是给人看设计效果的静态快照,分割条是设计期用来调区域大小的
            // 工具,不该出现在预览里——拖过之后预览里的布局就和设计对不上了,
            // 得按「刷新」才回得来。所以预览模式下不给区域面板写 split:
            // BorderLayout 见不到 split 就不生成分割条,自然也拖不动,区域宽度
            // 就是设计时定的那个值。
            // 「获取代码」出来的仍然是标准 Ext 页面,split: true 照写,真页面里
            // 可以自由拖动。
            var previewMode = false;

            // 整页代码。「获取代码」和「预览」共用这一份,免得两处各拼一套、
            // 改了一处忘了另一处,最后预览和拿到的代码对不上。
            function buildPageCode() {
                var canvas = Ext.getCmp('design-canvas');
                var items = '';
                if (canvas && canvas.items && canvas.items.getCount() > 0) {
                    // 深度 8 刚好是 16 空格缩进,和下面 items: [ 那一行对齐
                    items = renderItems(canvas.items, 8).replace(/\n$/, '');
                }
                // 树形表格不在 ext-all 里,只有当画布上真的有它时才带上它的样式和脚本
                var withTreeGrid = canvasHasXType('treegrid');
                // 图表同理:Chart.js 和 Ext.ux.Chart 都不是 ext-all 自带的东西
                var withChart = canvasHasXType('uxchart');
                var head = [];
                head.push('<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />');
                head.push('<title>设计的页面</title>');
                head.push('<link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/ext-all.css" />');
                head.push('<link rel="stylesheet" type="text/css" href="vendor/extjs/resources/css/xtheme-gray.css" />');
                if (withTreeGrid) {
                    head.push('<link rel="stylesheet" type="text/css" href="vendor/extjs/ux/treegrid/treegrid.css" />');
                }
                head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/extjs/adapter/ext/ext-base.js">' + TAG_SCRIPT_END);
                head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/extjs/ext-all-debug.js">' + TAG_SCRIPT_END);
                if (withTreeGrid) {
                    Ext.each(TREE_GRID_UX, function(f) {
                        head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/extjs/ux/treegrid/' +
                                  f + '.js">' + TAG_SCRIPT_END);
                    });
                }
                if (withChart) {
                    head.push(TAG_SCRIPT + ' type="text/javascript" src="vendor/chartjs/chart.umd.js">' + TAG_SCRIPT_END);
                    head.push(TAG_SCRIPT + ' type="text/javascript" src="ux/Ext.ux.Chart.js">' + TAG_SCRIPT_END);
                }
                var css = ['body { margin: 0; padding: 0; }'];
                if (!previewMode && canvasHasSplit()) css.push(SPLIT_BAR_CSS);
                head.push('<style type="text/css">' + css.join('\n') + '</style>');

                return [
                    '<!DOCTYPE html>',
                    '<html>',
                    '<head>'
                ].concat(head).concat([
                    '</head>',
                    '<body>',
                    TAG_SCRIPT + ' type="text/javascript">',
                    'Ext.onReady(function() {',
                    '    new Ext.Viewport({',
                    "        layout: 'fit',",
                    '        items: [{',
                    "            xtype: 'panel',",
                    '            border: false,',
                    // 设计区是绝对布局(组件带着画布上的 x/y),导出的页面得照搬,
                    // 否则组件会被布局重排成一条。body 的 min-height 给的是内容
                    // 范围——绝对定位的子项不占文档流,不撑高的话超出首屏的组件
                    // 既看不见也滚不到(设计器里 growCanvas 算的是同一件事)
                    "            layout: 'absolute',",
                    '            autoScroll: true,',
                    "            bodyStyle: 'position: relative; min-height: " + (canvasExtent() + CANVAS_PAD) + "px;',",
                    '            items: [',
                    items,
                    '            ]',
                    '        }]',
                    '    });',
                    '});',
                    TAG_SCRIPT_END,
                    '</body>',
                    '</html>'
                ]).join('\n');
            }

            // 预览用的整页 HTML = 上面那份代码 + 一行 <base>。
            // iframe 里那个 about:blank 文档没有可用的基地址,代码中的相对路径
            // (vendor/extjs/...)会解析到 about:blank 上,脚本和样式全部 404。
            // 而生成给用户的代码必须保持相对路径,所以只在预览这里补。
            function buildPreviewHtml() {
                var dir = location.href.replace(/[?#].*$/, '').replace(/[^/]*$/, '');
                var html;
                // previewMode 只在生成这一份的时候开着(见它的注释),生成完必须
                // 复位,否则之后「获取代码」出来的也变成没有分割条的版本了
                previewMode = true;
                try {
                    html = buildPageCode();
                } finally {
                    previewMode = false;
                }
                return html.replace('<head>', '<head>\n<base href="' + dir + '" />');
            }

            // 往 iframe 里写文档。用 document.write 而不是 srcdoc:预览要真的把
            // Ext 跑起来,老浏览器不认 srcdoc 的话预览会是一片空白。
            function writePreviewFrame(iframeEl, html) {
                var doc = iframeEl.contentWindow.document;
                doc.open();
                doc.write(html);
                doc.close();
            }

            // 复制到剪贴板。navigator.clipboard 只在 https / localhost 下给用,
            // 拿不到就退回 textarea + execCommand 的老办法
            function copyCode(text) {
                function legacy() {
                    var ta = document.createElement('textarea');
                    ta.value = text;
                    ta.style.position = 'fixed';
                    ta.style.left = '-9999px';
                    document.body.appendChild(ta);
                    ta.select();
                    var done = false;
                    try { done = document.execCommand('copy'); } catch (e) { done = false; }
                    document.body.removeChild(ta);
                    return done;
                }
                function tell(done) {
                    Ext.Msg.alert('提示', done ? '代码已复制到剪贴板'
                                               : '复制失败,请在代码框里手动选中复制');
                }
                if (navigator.clipboard && navigator.clipboard.writeText) {
                    navigator.clipboard.writeText(text).then(
                        function() { tell(true); },
                        function() { tell(legacy()); });
                } else {
                    tell(legacy());
                }
            }

            // 「获取代码」:整页代码放进 CodeMirror 只读展示。这里改了也回不到
            // 画布上,能编辑只会误导,要改就复制出去改。
            function showCodeWindow() {
                var code = buildPageCode();
                // 代码靠面板实例拿着,别用 id/itemId 去查:窗口可能被反复打开,
                // id 会撞上上一次已经销毁的那个实例
                var host = new Ext.Panel({ border: false });
                var win = new Ext.Window({
                    title: '页面代码',
                    width: 780,
                    height: 620,
                    layout: 'fit',
                    plain: true,
                    maximizable: true,
                    modal: true,
                    items: [host],
                    buttons: [
                        { text: '复制', handler: function() { copyCode(code); } },
                        { text: '关闭', handler: function() { win.close(); } }
                    ]
                });
                win.show();

                // show() 之后面板 body 才存在,CodeMirror 只能建在它里面
                var cm = CodeMirror(host.body.dom, {
                    value: code,
                    mode: 'javascript',
                    readOnly: true, // 只读但保留光标,方便全选复制
                    lineNumbers: true,
                    lineWrapping: false,
                    // 生成的是层层嵌套的 items 数组,能折叠起来看结构方便很多。
                    // gutters 必须把行号槽一起列上,只写折叠加会连行号一起没掉
                    gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
                    foldGutter: true
                });
                // 窗口拉伸 / 最大化之后 CodeMirror 不会自己重新量尺寸,得推一把
                win.on('resize', function() { cm.refresh(); });
                return win;
            }

            // 「预览」:把生成的代码真的跑起来。iframe 里写的就是「获取代码」看到的
            // 那一份,只在 head 多一行 <base>(见 buildPreviewHtml)
            function showPreviewWindow() {
                // 不给 src:不写 src 的 iframe 就是 about:blank,同源、能直接写
                // 文档;写上 src="about:blank" 反而会多一次隐式加载,可能把刚写
                // 进去的内容冲掉
                var frame = new Ext.BoxComponent({
                    autoEl: { tag: 'iframe', cls: 'designer-preview-frame', frameBorder: '0' }
                });

                function render() {
                    // 每次刷新都重新生成:这期间画布可能已经改过了
                    writePreviewFrame(frame.el.dom, buildPreviewHtml());
                }

                function openStandalone() {
                    // 独立窗口同样要带 <base>,否则相对路径还是解析不到
                    var w = window.open('', '_blank');
                    if (!w) {
                        Ext.Msg.alert('提示', '浏览器拦截了新窗口,请允许弹出窗口后重试');
                        return;
                    }
                    w.document.open();
                    w.document.write(buildPreviewHtml());
                    w.document.close();
                }

                var win = new Ext.Window({
                    title: '预览',
                    width: 900,
                    height: 640,
                    layout: 'fit',
                    plain: true,
                    maximizable: true,
                    items: [frame],
                    buttons: [
                        { text: '刷新', handler: render },
                        { text: '在新窗口打开', handler: openStandalone },
                        { text: '关闭', handler: function() { win.close(); } }
                    ]
                });

                // render 事件触发时 iframe 已经进了 DOM,contentWindow 才拿得到
                frame.on('render', render);
                win.show();
                return win;
            }

            function renderItems(collection, depth) {
                var pad = new Array(depth * 2 + 1).join(' ');
                var out = '';
                collection.each(function(item) {
                    out += pad + renderItem(item, depth) + ',\n';
                });
                return out;
            }

            // 下拉框的数据源要还原成 ArrayStore:Ext 的 ComboBox 只认 Store 实例或
            // storeId,直接给数组是跑不起来的
            function comboStoreLiteral(store) {
                var rows = [];
                if (store && store.each) {
                    store.each(function(rec) {
                        rows.push("['" + esc(rec.get('value')) + "', '" + esc(rec.get('text')) + "']");
                    });
                }
                return "new Ext.data.ArrayStore({ fields: ['value', 'text'], data: [" + rows.join(', ') + '] })';
            }

            // ---- 表格类组件的代码还原 ----
            // 生成代码里 store 必须是真 Store 实例,给数组是跑不起来的。字段名和
            // 行数据都从画布上那个 store 里回读(Store#fields 就是字段的
            // MixedCollection),所以画布上的数据变了,导出的页面跟着变。
            function jsLiteral(v) {
                if (typeof v === 'number' || typeof v === 'boolean') return String(v);
                if (v == null) return "''";
                return "'" + esc(v) + "'";
            }

            function arrayStoreLiteral(store) {
                var fields = (store && store.fields && store.fields.keys) ? store.fields.keys : [];
                var rows = [];
                if (store && store.each) {
                    store.each(function(rec) {
                        var row = [];
                        for (var i = 0; i < fields.length; i++) row.push(jsLiteral(rec.get(fields[i])));
                        rows.push('[' + row.join(', ') + ']');
                    });
                }
                var names = [];
                for (var j = 0; j < fields.length; j++) names.push("'" + esc(fields[j]) + "'");
                return 'new Ext.data.ArrayStore({ fields: [' + names.join(', ') +
                       '], data: [' + rows.join(', ') + '] })';
            }

            // 图表的数据还原:一串 { label, value }。label 一律按字符串写,
            // 散点图的数值标签('12')在 Ext.ux.Chart 里会用 Number() 转回数字
            function chartDataLiteral(rows) {
                var out = [];
                Ext.each(rows || [], function(r) {
                    var v = isFinite(r.value) ? r.value : 0;
                    out.push("{ label: '" + esc(r.label) + "', value: " + v + ' }');
                });
                return '[' + out.join(', ') + ']';
            }

            function columnLiteral(c) {
                var p = [];
                if (c.header != null) p.push("header: '" + esc(c.header) + "'");
                if (c.dataIndex != null) p.push("dataIndex: '" + esc(c.dataIndex) + "'");
                p.push('width: ' + (c.width || 100));
                if (c.align) p.push("align: '" + esc(c.align) + "'");
                return '{ ' + p.join(', ') + ' }';
            }

            function columnsLiteral(list) {
                var out = [];
                for (var i = 0; i < list.length; i++) out.push(columnLiteral(list[i]));
                return '[' + out.join(', ') + ']';
            }

            // 普通表格用 ColumnModel 里的列(构造时已经补过默认值,就是画布上的样子),
            // 树形表格的列在 initColumns 里被换成了 Ext.tree.Column 实例,属性同名
            function gridColumnsLiteral(item) {
                var cfg = (item.colModel && item.colModel.config) ? item.colModel.config : (item.columns || []);
                return columnsLiteral(cfg);
            }

            // 高级表格的工具条配置(生成代码用)。设计器里那份是活的控件实例,这里
            // 拼的是配置字面量,但两边的构成规则都出自 advGridTbarItems:每列一个
            // tbtext 加一个带 name 的输入框(类型也按同一份推断),再是查询 / 重置。
            // 两个 handler 直接用 String(函数) 把源码内联进去,导出页点「查询」的行为
            // 跟设计器里完全一样,不会各写一遍走样
            // 具名函数 String() 出来是带着函数名的(function advQueryHandler(btn)…),
            // 名字是设计器的内部实现,导出的页面里不该出现,去掉名字就是个匿名函数
            // 表达式,一样能跑
            function fnSource(fn) {
                return String(fn).replace(/^function\s+[A-Za-z_$][\w$]*/, 'function ');
            }

            function advGridTbarCode(item, depth) {
                var pad = new Array(depth * 2 + 1).join(' ');
                var inner = pad + '  ';
                var columns = advGridColumns(item);
                var store = item.getStore ? item.getStore() : item.store;
                var lines = [], any = false;
                Ext.each(advGridSearchCols(item), function(di) {
                    var col = advColumnByIndex(columns, di);
                    any = true;
                    lines.push("{ xtype: 'tbtext', text: '" +
                               esc((col && col.header ? col.header : di) + ':') + "' }");
                    lines.push("{ xtype: '" + advSearchFieldType(store, di) +
                               "', width: 120, name: '" + esc(di) + "' }");
                });
                if (any) lines.push("'-'");
                lines.push("{ text: '查询', handler: " + fnSource(advQueryHandler) + " }");
                lines.push("{ text: '重置', handler: " + fnSource(advResetHandler) + " }");
                return 'tbar: [\n' + inner + lines.join(',\n' + inner) + '\n' + pad + ']';
            }

            // 「列请求地址」配了的话,导出页也要自己去取一次列定义——不然导出的页面
            // 只剩设计器里那一份写死的列。用 afterrender 而不是 render:表头样式那边
            // 也可能挂一条 render 监听,同一个对象里两个 render 键只有最后一个算数
            function advGridColUrlListener(item) {
                return "afterrender: function(g) { Ext.Ajax.request({ url: '" + esc(item._columnUrl) +
                       "', success: function(resp) { var c; try { c = Ext.decode(resp.responseText); }" +
                       " catch (e) { return; } if (Ext.isArray(c) && c.length) {" +
                       " g.reconfigure(g.getStore(), new Ext.grid.ColumnModel(c)); } } }); }";
            }

            // 树形表格的节点树还原成配置字面量。id / uiProvider / loader 都是 Ext
            // 运行时塞进去的(loader 还指向已经销毁的那个 loader 对象),不能写进代码;
            // 其余属性就是各个列要显示的字段值
            var NODE_SKIP_KEYS = ['id', 'uiProvider', 'loader', 'children', 'text', 'expanded',
                                  'leaf', 'allowChildren', 'icon', 'iconCls', 'cls', 'qtip',
                                  'hidden', 'checked', 'disabled', 'draggable', 'singleClickExpand'];
            function treeNodeLiteral(node) {
                var attrs = node.attributes || {};
                var props = ["text: '" + esc(node.text) + "'"];
                for (var k in attrs) {
                    if (NODE_SKIP_KEYS.indexOf(k) !== -1) continue;
                    if (typeof attrs[k] === 'function') continue;
                    if (attrs[k] === undefined) continue;
                    props.push(k + ': ' + jsLiteral(attrs[k]));
                }
                // 展开状态照画布抄:生成出来的页面和画布长得一样
                if (node.isExpanded && node.isExpanded()) props.push('expanded: true');
                var kids = [];
                if (node.eachChild) node.eachChild(function(c) { kids.push(treeNodeLiteral(c)); });
                if (kids.length) props.push('children: [' + kids.join(', ') + ']');
                // 没有子节点就得标 leaf,否则节点前面挂着展开图标,点下去既没数据
                // 也没回调(loader 没有 dataUrl),会一直转
                else if (node.isLeaf && node.isLeaf()) props.push('leaf: true');
                return '{ ' + props.join(', ') + ' }';
            }

            // 交叉表轴:initAxes 会把配置转成 PivotAxis 实例,维度留在 dimensions 上
            function pivotAxisLiteral(axis) {
                var dims = (axis && axis.dimensions) ? axis.dimensions : (axis || {});
                var out = [];
                for (var i = 0; i < dims.length; i++) {
                    var p = [];
                    if (dims[i].dataIndex != null) p.push("dataIndex: '" + esc(dims[i].dataIndex) + "'");
                    if (dims[i].width != null) p.push('width: ' + dims[i].width);
                    out.push('{ ' + p.join(', ') + ' }');
                }
                return '[' + out.join(', ') + ']';
            }

            // 区域面板的尺寸位置全由 border 布局说了算,容器上配的「组件间距」对它们无效,
            // 生成代码时也就不能写 defaults,否则导出的效果和画布对不上
            function hasRegionChildren(item) {
                var found = false;
                // items 只有渲染过的容器才是 MixedCollection,未渲染时可能是 config 数组
                if (item && item.items && item.items.each) {
                    item.items.each(function(c) { if (c._isDesignerRegion) found = true; });
                }
                return found;
            }

            // 画布上真实的外边距。designerEl 给出的可能就是字段外层那个 wrap,外边距
            // 正是挂在它身上(见 normalizeItemMargin)。归一化过的 10px 和容器「组件
            // 间距」都是直接写在内联样式里的,getStyle 原样取得回来;区域面板没有外边距
            // (它是 border 布局摆的),算出来是 0px,这时就不写。
            function itemMarginCss(item) {
                var el = designerEl(item);
                if (!el || !el.dom) return null;
                var m = el.getStyle('margin');
                if (!m || m === '0px' || m === 'auto') return null;
                return m;
            }

            function renderItem(item, depth) {
                var pad = new Array(depth * 2 + 1).join(' ');
                var t = item.getXType();
                var props = ["xtype: '" + t + "'"];
                var listeners = [];   // 最后合成一条 listeners
                var elCss = [];       // 合成 style(落在组件外层元素上)
                var bodyCss = [];     // 合成 bodyStyle(落在 body 上)

                // ---- 结构性的东西:整块拼出来的字面量(列定义、store、坐标轴),
                //      没法拆成「一个属性一个值」写进 PROP_SPECS ----
                if (item.isFormField) {
                    if (t === 'radio' && item.inputValue != null) props.push("inputValue: '" + esc(item.inputValue) + "'");
                    if (t === 'combo') {
                        props.push("mode: '" + esc(item.mode || 'local') + "'");
                        props.push("triggerAction: '" + esc(item.triggerAction || 'all') + "'");
                        if (item.valueField) props.push("valueField: '" + esc(item.valueField) + "'");
                        if (item.displayField) props.push("displayField: '" + esc(item.displayField) + "'");
                        props.push('store: ' + comboStoreLiteral(item.store));
                    }
                    // TriggerField 不实现 onTriggerClick 的话,点扳机就会抛异常
                    if (t === 'trigger') props.push('onTriggerClick: Ext.emptyFn');
                } else if (t === 'uxchart') {
                    // 图表标题走 Chart.js 的 title 插件,不是面板表头,所以不归
                    // 「容器」那一支(参数类型和落点都不一样)
                    if (item.title) props.push("title: '" + esc(item.title) + "'");
                } else if (t === 'grid' || t === 'pivotgrid' || t === 'treegrid') {
                    if (item.title) props.push("title: '" + esc(item.title) + "'");
                    if (t === 'treegrid') {
                        // loader 只负责把嵌套节点建出来,数据全在 root 上
                        props.push('loader: { preloadChildren: true }');
                        props.push('root: new Ext.tree.AsyncTreeNode(' + treeNodeLiteral(item.getRootNode()) + ')');
                        props.push('columns: ' + columnsLiteral(item.columns || []));
                    } else {
                        props.push('store: ' + arrayStoreLiteral(item.getStore ? item.getStore() : item.store));
                        if (t === 'pivotgrid') {
                            props.push('leftAxis: ' + pivotAxisLiteral(item.leftAxis));
                            props.push('topAxis: ' + pivotAxisLiteral(item.topAxis));
                            // viewConfig.title 是左上角那格的字
                            var corner = (item.viewConfig && item.viewConfig.title) || '';
                            props.push("viewConfig: { title: '" + esc(corner) + "' }");
                            // 行列表头和左上角标题只有 refresh(true) 会画,
                            // 少了这句导出的页面顶部一片空白
                            listeners.push('viewready: function(g) { g.view.refresh(true); }');
                        } else {
                            props.push('columns: ' + gridColumnsLiteral(item));
                        }
                    }
                    // 高级表格:自带的那条搜索工具条(结构性的,跟着列走)
                    if (isAdvGrid(item)) props.push(advGridTbarCode(item, depth));
                    // 配了列请求地址,导出页也照着自己取一次
                    if (isAdvGrid(item) && item._columnUrl) {
                        listeners.push(advGridColUrlListener(item));
                    }
                } else {
                    if (item._designerLayout) props.push("layout: '" + esc(item._designerLayout) + "'");
                    if (item.title) props.push("title: '" + esc(item.title) + "'");
                    // 属性表里把表头设成「隐藏」的,生成代码要带上 header: false
                    if (item._headerHidden) props.push('header: false');
                    // 面板默认没有边框,这件事得写进代码:Ext 的 Panel 默认是
                    // border: true,不写的话导出的页面里会凭空多出一圈蓝边
                    if (item.border === false) props.push('border: false');
                }

                // ---- 属性表里配出来的那些行,全在 PROP_SPECS 那张表里:一个属性
                //      一个值,或者一段 css 片段。什么时候有这一项由 spec.when 说 ----
                Ext.each(PROP_SPECS, function(spec) {
                    if (spec.when && !spec.when(item)) return;
                    var frag = spec.code ? spec.code(item) : null;
                    if (frag) props.push(frag);
                    if (!spec.cssOf) return;
                    var css = spec.cssOf(item);
                    if (!css) return;
                    if (spec.cssTarget === 'body') bodyCss.push(css);
                    else if (spec.cssTarget === 'both') { elCss.push(css); bodyCss.push(css); }
                    else elCss.push(css);
                });

                // 标题栏样式:Ext 3.4 的 Panel 其实不认 headerCfg(源码里只判断它
                // 存不存在,从没拿它配过什么),表头又是渲染时按 title 一次性建出来
                // 的,只能挂个 render 监听,等表头建好再把样式补上去
                var titleCss = [];
                if (item._titleAlign) titleCss.push('text-align: ' + item._titleAlign + ';');
                if (item._titleSize) titleCss.push('font-size: ' + item._titleSize + 'px;');
                if (item._titleColor) titleCss.push('color: ' + item._titleColor + ';');
                if (titleCss.length && hasHeader(item)) {
                    // 必须用 applyStyles:setStyle 只认「属性名, 值」或对象,喂它整串
                    // CSS 会被当成一个属性名赋给 style,静悄悄地什么都不做
                    listeners.push("render: function(p) { var t = p.header && " +
                        "p.header.child('span.x-panel-header-text'); if (t) t.applyStyles('" +
                        titleCss.join(' ') + "'); }");
                }

                // 分割区域:region 是 border 布局识别子项的唯一依据,必须带上
                if (item._isDesignerRegion) {
                    props.push("region: '" + item.region + "'");
                    if (item.region === 'north' || item.region === 'south') props.push('height: ' + (item.height || 110));
                    else if (item.region !== 'center') props.push('width: ' + (item.width || 170));
                    // 预览模式不写 split,见 previewMode 的注释
                    if (item.split && !previewMode) {
                        props.push('split: true');
                        props.push('minSize: ' + (item.minSize || 30));
                    }
                } else {
                    // 画布上量到的尺寸要跟着代码走。Ext 的 auto 布局不会给子项定尺寸,
                    // 不写 width/height,组件在预览里就会撑满整行;而分割过的面板是
                    // border 布局,容器自己没有高度就只剩一条标题栏,区域全都不显示。
                    // 区域面板例外(尺寸由 border 布局决定,上面刚写过 region/width/height)。
                    if (typeof item.width === 'number' && item.width > 0) {
                        props.push('width: ' + Math.round(item.width));
                    }
                    if (typeof item.height === 'number' && item.height > 0) {
                        props.push('height: ' + Math.round(item.height));
                    }
                }
                // 画布上真实的外边距(归一化的 10px 或所在容器的「组件间距」)。
                // 容器的 defaults 已经发过一次同样的值,这里再写一份并不冲突,
                // 反倒是容器没有 defaults 时(非间距容器)唯一的来源
                var marginCss = itemMarginCss(item);
                if (marginCss) elCss.push('margin: ' + marginCss + ';');
                // 合成一条 style / bodyStyle。分开写的话后一个会把前一个顶掉
                // (对象字面量里同名的键只有最后一个算数),外边距就丢了
                if (elCss.length) props.push("style: '" + elCss.join(' ') + "'");
                if (bodyCss.length) props.push("bodyStyle: '" + bodyCss.join(' ') + "'");
                if (listeners.length) props.push('listeners: { ' + listeners.join(', ') + ' }');
                if (item.items && item.items.getCount && item.items.getCount() > 0) {
                    props.push('items: [\n' + renderItems(item.items, depth + 1) + pad + ']');
                }
                return '{ ' + props.join(', ') + ' }';
            }

            function esc(s) {
                return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
            }

            // ================= 开箱即用 =================
            // 中间画布进来就有一个面板当页面容器(不带边框,布局方式在右侧属性窗口
            // 里改),而且是选中状态:右边的属性表一上来就有东西可配,不必先从左边
            // 拖一个进来。
            // 必须放在 onReady 这一块的最末尾:建组件会走到选中、缩放把手这些逻辑,
            // 它们依赖的 canvasBody、resizeHost/resizeTrackers、属性表都得先初始化
            // 完(那几个 var 都在下面才赋值)。Window 在这里也已经渲染过了,画布
            // body 是有尺寸的。
            createDefaultCanvasPanel();
        });
    </script>
</head>
<body>
</body>
</html>

 

posted on 2026-10-11 20:14  gstsyyb  阅读(3)  评论(0)    收藏  举报