T+二开级联自由项:JS扩展实现多级联动的低代码方案
简介:本资源是面向T+二次开发工程师的级联自由项实战解决方案,聚焦中小企业销售订单等业务场景中多级联动字段的动态控制需求,通过JS前端扩展与.NET后端自定义服务协同实现,适合具备基础JavaScript和C#开发能力的中高级开发者快速落地个性化配置。压缩包共86个文件,含14个核心C#源码文件(如SaleOrderFreeItemCascadeAction.cs、SA03ExtendService.cs)、34个运行依赖DLL、2个详细文档(需求说明与V16开发教程)、1个实操视频(T+级联自由项.mp4)及1个完整Visual Studio解决方案(Ufida.T.HY.sln),整体281.08MB,结构清晰,覆盖前后端全链路代码与部署说明。目前已有417人学习下载,读者可直接复用工程结构、参考级联逻辑实现细节、调试图文并茂的开发流程,并基于提供的JS脚本与自定义服务接口快速适配其他单据类型。
1. T+二开级联自由项:一个被低估的“低代码缝合术”,让销售单据多级联动像配置表一样简单
你有没有遇到过这种场景:客户在T+系统里录销售订单,选完“产品大类”后,“品牌”下拉框要自动过滤出该大类下的品牌;再选完“品牌”,“系列型号”才动态加载对应数据——但T+标准功能只支持单层自由项,二级联动得靠写SQL查表、拼JS事件、改单据模板、甚至动服务端接口。某公司上线时硬是让开发手写三套不同逻辑的JS,结果U8迁移过来的客户一换浏览器就报错,连自由项下拉箭头都点不开。这个「T+二开 级联自由项(极简扩展方法)」项目,就是为解决这类高频、高频、又高频的“小需求”而生的:它不碰数据库结构、不改T+核心服务、不依赖插件平台,仅用一份可复用的JS扩展 + 一张配置表,就把三级自由项联动压缩成3个字段定义 + 2次点击就能生效。适合所有正在做T+二次开发的实施顾问、交付工程师、以及被客户临时加需求追着跑的外包开发者——尤其当你手头只有T+7.0~13.0任意版本、没权限部署独立服务、又不想每次改个联动都提单等IT排期时,这套方案就是你的后悔药。
2. 架构设计与技术选型:为什么是JS扩展 + 配置表,而不是自定义服务或插件?
2.1 核心约束倒推架构:T+二开的“三不原则”
T+平台对二次开发有明确边界:不许修改bin目录下DLL、不许注册全局HTTP服务、不许在单据模板中嵌入外部CDN脚本。这意味着常见方案如“写WebAPI供JS调用”或“用Vue重写自由项组件”直接出局。我们只能在T+允许的沙箱内做事——而T+唯一开放且稳定的入口,就是其单据模板中的<script>标签区,以及它预留的window.TPlusExtend扩展机制。所以本项目采用“双轻量”设计:
- 前端轻量:所有联动逻辑封装为纯JS模块,通过T+标准
onLoad/onChange钩子注入,不依赖jQuery或任何第三方库(T+自带$是阉割版,连$.ajax都不全); - 数据轻量:联动关系不写死在JS里,而是存于一张独立的
T_Ext_CascadeConfig基础资料表(建表SQL随包提供),字段仅5个:ID,ParentField,ChildField,FilterSQL,OrderNo。比如“大类→品牌”这条规则,ParentField='cFree1',ChildField='cFree2',FilterSQL="SELECT cCode,cName FROM v_Brand WHERE cBigClass='{cFree1}'"。
提示:T+的自由项字段名固定为
cFree1~cFree10,这是T+底层约定,不是我们自定义的。所有联动必须基于这个命名体系,否则JS无法绑定事件。
2.2 JS扩展模块分层:从入口到执行的四层职责
整个JS扩展按职责拆为4个文件(均位于/Ext/Cascade/目录下):
| 文件名 | 职责 | 关键能力 |
|---|---|---|
main.js | 入口加载器 | 检测当前单据类型(销售订单/采购入库等)、动态加载对应规则、注册全局window.CascadeManager |
loader.js | 规则加载器 | 从T_Ext_CascadeConfig表查出当前单据适用的全部级联规则,缓存为内存对象,避免重复查询 |
executor.js | 执行引擎 | 监听cFree1~cFree10字段change事件,解析FilterSQL中的占位符(如{cFree1}),拼接真实SQL,调用T+内置TPlus.Ajax执行并刷新下级下拉 |
renderer.js | 渲染适配器 | 适配T+不同版本的下拉控件DOM结构(T+10.0用<select>,T+12.0改用div+ul+li模拟下拉),统一调用setOptions()方法 |
这种分层不是炫技——而是为了应对T+版本碎片化。某导师曾在一个项目里同时要兼容T+9.0(IE8内核)、T+11.0(Chromium 49)、T+13.0(Chromium 80),DOM操作方式天差地别。把渲染逻辑抽出来,升级时只需改renderer.js,其他三层完全不动。
2.3 为什么不用“自定义服务”?一次血泪经验告诉你
有开发者问:“既然能写JS,为啥不干脆写个自定义服务,用REST API返回JSON?”——这问题背后是典型认知偏差。T+的“自定义服务”本质是IIS托管的ASP.NET WebService(.asmx),需IT在服务器上部署、配置IIS应用池、开放端口、处理跨域。而本项目客户现场是制造业私有云,防火墙策略严禁新增HTTP服务端口,且IT部门拒绝为“一个下拉联动”走月度安全评审流程。更致命的是:T+单据JS运行在客户端,调用自定义服务需跨域,而T+内置的TPlus.Ajax根本不支持withCredentials,连Cookie都传不过去,身份认证直接失败。
所以本项目坚持“数据查本地”:所有FilterSQL都在T+数据库内执行,由T+服务端代劳,JS只负责发请求和收结果。这才是真正落地的妥协。
3. 运行环境准备与初始化:5分钟完成T+单据级联联动
3.1 基础环境检查清单(缺一不可)
在动手前,请确认以下6项已就绪。少一项,后续90%的问题都源于此:
- ✅ T+版本 ≥ 7.0(验证方式:登录T+,右下角查看“版本号”,7.0起支持
TPlusExtend机制) - ✅ 数据库为SQL Server(本方案不支持MySQL或Oracle后端,因
FilterSQL依赖T-SQL语法如TOP 100) - ✅ 当前用户有
T_Ext_CascadeConfig表读取权限(默认建表时已设GRANT SELECT ON T_Ext_CascadeConfig TO public) - ✅ 单据模板已启用“允许执行JavaScript”(路径:基础设置 → 单据设置 → 找到目标单据 → 勾选“启用脚本”)
- ✅ 浏览器为Chrome 80+ 或 Edge Chromium(T+12.0+已弃用IE内核,旧版JS语法如
const/fetch可直接用) - ✅ 无其他JS扩展冲突(如某竞品“智能填单”插件会劫持
onChange事件,需先禁用)
注意:T+单据脚本执行顺序是“先加载模板HTML,再执行JS”,因此所有DOM操作必须包裹在
$(document).ready()或window.onload中,否则getElementById('cFree1')会返回null。
3.2 部署四步法:从解压到生效
步骤1:建表并初始化配置
执行随包提供的Create_CascadeConfig.sql(位于/DB/目录):
-- 创建级联配置表(T+标准数据库内执行) IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='T_Ext_CascadeConfig' AND xtype='U') CREATE TABLE T_Ext_CascadeConfig ( ID INT IDENTITY(1,1) PRIMARY KEY, ParentField NVARCHAR(20) NOT NULL, -- 上级自由项字段名,如'cFree1' ChildField NVARCHAR(20) NOT NULL, -- 下级自由项字段名,如'cFree2' FilterSQL NVARCHAR(MAX) NOT NULL, -- 查询SQL,支持{cFree1}占位符 OrderNo INT DEFAULT 0, -- 同级联动执行顺序,数字越小越先执行 BillType NVARCHAR(50) DEFAULT '' -- 单据类型码,留空表示通用,填'SaleOrder'仅用于销售订单 ) -- 插入一条示例:大类→品牌联动 INSERT INTO T_Ext_CascadeConfig (ParentField, ChildField, FilterSQL, BillType) VALUES ('cFree1', 'cFree2', 'SELECT TOP 100 cCode AS Value, cName AS Text FROM v_Brand WHERE cBigClass=''{cFree1}'' ORDER BY cCode', 'SaleOrder')执行后,在SQL Server Management Studio中查SELECT * FROM T_Ext_CascadeConfig,确认数据已写入。
步骤2:上传JS文件到T+资源目录
将/Ext/Cascade/目录下全部JS文件(共4个),通过T+后台“系统管理 → 系统工具 → 文件管理”上传至路径:/TPlus/Ext/Cascade/(注意:必须是这个路径,T+脚本中硬编码了相对路径)
上传后,访问http://your-tplus-server/TPlus/Ext/Cascade/main.js应能直接下载文件,否则路径错误。
步骤3:在单据模板中注入入口脚本
打开目标单据(如销售订单)的模板设计界面(基础设置 → 单据设置 → 销售订单 → 设计模板),在HTML源码末尾</body>前插入:
<!-- T+级联自由项扩展入口 --> <script type="text/javascript" src="/TPlus/Ext/Cascade/main.js"></script> <script type="text/javascript"> // 初始化级联管理器,传入当前单据类型码 window.CascadeManager.init('SaleOrder'); </script>保存模板并发布。关键点:init('SaleOrder')中的参数必须与T_Ext_CascadeConfig.BillType值严格一致,大小写敏感。
步骤4:验证联动是否生效
- 新建一张销售订单,进入表体行;
- 在“自由项1”下拉中选择一个“产品大类”(如“工业泵”);
- 焦点离开
cFree1后,观察“自由项2”下拉框是否自动刷新,且选项仅为“工业泵”下的品牌(如“格兰富”“威乐”); - 若未刷新,按F12打开浏览器控制台,切换到Console页,输入
window.CascadeManager.debug = true,再操作一次,看是否有SQL执行日志或错误堆栈。
4. 避坑指南:T+级联自由项开发中踩过的5个真实坑
4.1 现象:自由项下拉框空白,控制台报Cannot read property 'setOptions' of null
原因:T+不同版本对自由项控件的DOM生成时机不同。T+10.0在页面加载时就渲染<select id="cFree2">,而T+12.0采用懒加载,直到用户第一次点击下拉才生成DOM节点。JS执行时document.getElementById('cFree2')返回null,后续调用setOptions()自然报错。
解决:在renderer.js中增加DOM等待逻辑,不依赖document.ready,而是轮询检测:
// renderer.js 中的 getSelectElement 方法 function getSelectElement(fieldName) { let el = document.getElementById(fieldName); if (el) return el; // T+12.0+:自由项DOM可能未生成,最多等待3秒 return new Promise((resolve) => { let tries = 0; const timer = setInterval(() => { el = document.getElementById(fieldName); if (el || tries > 30) { clearInterval(timer); resolve(el); } tries++; }, 100); }); }4.2 现象:FilterSQL中{cFree1}占位符未被替换,SQL执行报错“'cFree1' 不是有效的列名”
原因:T+单据中自由项字段值并非直接存在document.getElementById('cFree1').value中。对于下拉类型,真实值存在TPlus.Data.getBillData().getDetailRows()[0].cFree1(表体第一行),而文本类型才在DOM中。JS扩展若直接读DOM,拿到的是显示文本(如“工业泵”),而非编码值(如“GP-001”),导致SQL中WHERE cBigClass='工业泵'查不到数据。
解决:统一从T+数据模型中取值,executor.js中替换逻辑改为:
// executor.js 中的 replacePlaceholders 方法 function replacePlaceholders(sql, rowData) { // rowData 来自 TPlus.Data.getBillData().getDetailRows()[0] return sql.replace(/\{(\w+)\}/g, (match, fieldName) => { // 优先从rowData取值(编码值),fallback到DOM(显示值) const value = rowData[fieldName] || (document.getElementById(fieldName) && document.getElementById(fieldName).value) || ''; return value.replace(/'/g, "''"); // SQL转义单引号 }); }4.3 现象:级联触发两次,下拉框闪退后重载,选项重复出现
原因:T+自身会对自由项字段绑定change事件(用于校验、计算等),我们的JS又额外绑定一次,导致事件冒泡触发两次。尤其当cFree1是必填项时,T+校验失败会重置字段,再次触发change,形成死循环。
解决:使用事件委托 + 防抖,并在触发前检查字段值是否真变化:
// executor.js 中的 bindCascadeEvent 方法 function bindCascadeEvent(fieldName) { const field = document.getElementById(fieldName); if (!field) return; let lastValue = field.value; field.addEventListener('change', function() { if (this.value === lastValue) return; // 值未变,不触发 lastValue = this.value; // 防抖:100ms内只执行最后一次 clearTimeout(window.cascadeDebounceTimer); window.cascadeDebounceTimer = setTimeout(() => { executeCascade(fieldName, this.value); }, 100); }); }4.4 现象:T+13.0中TPlus.Ajax调用返回undefined,控制台无报错
原因:T+13.0升级了AJAX底层,TPlus.Ajax不再返回Promise,而是要求传入success/error回调函数。旧版写法TPlus.Ajax({...}).then(...)直接失效。
解决:在executor.js中做版本兼容判断:
// executor.js 中的 executeAjax 方法 function executeAjax(sql, successCb, errorCb) { if (typeof TPlus.Ajax === 'function' && TPlus.Ajax.toString().includes('success')) { // T+13.0+:回调函数式 TPlus.Ajax({ url: '/TPlus/Services/QueryService.asmx/ExecuteQuery', data: { sql: sql }, success: successCb, error: errorCb }); } else { // T+12.0及以下:Promise式(保留旧逻辑) TPlus.Ajax({ url: '/TPlus/Services/QueryService.asmx/ExecuteQuery', data: { sql: sql } }).then(successCb).catch(errorCb); } }4.5 现象:配置了BillType='SaleOrder',但在采购入库单中也触发了联动
原因:window.CascadeManager.init('SaleOrder')只是告诉JS“当前单据类型”,但loader.js加载规则时,SQL查询未严格按BillType过滤。原查询为SELECT * FROM T_Ext_CascadeConfig WHERE ParentField=?,漏掉了AND (BillType='' OR BillType=?)条件。
解决:修正loader.js中的查询SQL:
// loader.js 中的 loadRules 方法 function loadRules(billType) { const sql = `SELECT * FROM T_Ext_CascadeConfig WHERE ParentField IN ('cFree1','cFree2','cFree3','cFree4','cFree5') AND (BillType = '' OR BillType = '${billType}') ORDER BY OrderNo`; return TPlus.Ajax({ url: '/TPlus/Services/QueryService.asmx/ExecuteQuery', data: { sql } }); }5. 核心代码逐行解析:executor.js如何把一行SQL变成一次联动?
5.1 入口函数executeCascade(parentField, parentValue):联动的总开关
这是整个级联逻辑的起点,被bindCascadeEvent在用户选择上级自由项后调用。它接收两个参数:parentField(如'cFree1')是触发联动的字段名,parentValue是用户选中的值(编码值,非显示文本)。函数目标:找出所有以该字段为ParentField的规则,依次执行下级查询并刷新DOM。
// executor.js function executeCascade(parentField, parentValue) { // 1. 从内存缓存中获取所有匹配规则(loader.js 已预加载) const rules = window.CascadeManager.rules.filter(rule => rule.ParentField === parentField); // 2. 遍历每条规则,逐级执行 rules.forEach(rule => { // 2.1 构造带真实值的SQL:将 {cFree1} 替换为 parentValue const filledSql = replacePlaceholders(rule.FilterSQL, { [parentField]: parentValue }); // 2.2 执行SQL查询,获取下级选项数据 executeAjax(filledSql, (data) => { // success // 2.3 解析返回的JSON(T+ QueryService 返回 {d: "[{...}]" } 格式) const rows = JSON.parse(data.d); // 2.4 刷新下级自由项下拉框 refreshDropdown(rule.ChildField, rows); }, (err) => { // error console.error(`级联执行失败 [${rule.ParentField}→${rule.ChildField}]:`, err); alert(`下级数据加载失败:${err.message || '未知错误'}`); } ); }); }参数说明:
parentField:字符串,必须是cFree1~cFree10之一,T+自由项字段名规范;parentValue:字符串,来自T+数据模型的编码值(如'GP-001'),非UI显示值(如'格兰富'),这是正确关联的关键;rule.FilterSQL:配置表中存储的SQL模板,支持多个占位符(如{cFree1}和{cFree2}),replacePlaceholders会全部替换。
5.2 占位符替换函数replacePlaceholders(sql, rowData):SQL模板的“变量注入器”
T+单据数据是树形结构:主表数据(TPlus.Data.getBillData().getMainRow())和表体数据(TPlus.Data.getBillData().getDetailRows())。级联常发生在表体行,所以rowData通常指表体某一行对象。此函数负责将SQL中所有{fieldName}替换成对应字段的真实值,并做SQL安全转义。
function replacePlaceholders(sql, rowData) { return sql.replace(/\{(\w+)\}/g, (match, fieldName) => { // 优先从传入的 rowData 取值(最准) let value = rowData[fieldName]; // 若 rowData 无此字段,尝试从主表数据取(如单据头信息) if (value === undefined || value === null) { const mainRow = TPlus.Data.getBillData().getMainRow(); value = mainRow ? mainRow[fieldName] : ''; } // 若仍为空,从DOM取(兜底,仅用于文本型自由项) if (!value && document.getElementById(fieldName)) { value = document.getElementById(fieldName).value; } // 强制转为字符串,并转义单引号(防止SQL注入) value = String(value || '').replace(/'/g, "''"); return value; }); }关键细节:
- 正则
/\{(\w+)\}/g匹配所有{xxx}格式,g标志确保全局替换(一条SQL可有多个占位符); rowData[fieldName]是首选,因为T+自由项的编码值(cCode)只存在数据模型中,DOM里是显示值(cName);.replace(/'/g, "''")是T-SQL标准转义,双单引号表示字面单引号,比\转义更安全。
5.3 下拉框刷新函数refreshDropdown(childField, options):适配T+所有版本的DOM手术
T+不同版本渲染自由项下拉的方式差异极大:
- T+9.0~10.0:标准
<select id="cFree2"><option value="GP-001">格兰富</option></select>; - T+11.0:
<div class="tplus-select">function refreshDropdown(childField, options) { const fieldEl = document.getElementById(childField); if (!fieldEl) return; // 步骤1:清空现有选项 if (fieldEl.tagName === 'SELECT') { // T+10.0及以下:直接清空select fieldEl.innerHTML = ''; } else { // T+11.0+:调用T+官方Select组件的clear方法 const selectObj = TPlus.UI.Select.getInstance(childField); if (selectObj && typeof selectObj.clear === 'function') { selectObj.clear(); } } // 步骤2:添加新选项 if (fieldEl.tagName === 'SELECT') { // 标准select:创建option元素 options.forEach(row => { const opt = document.createElement('option'); opt.value = row.Value || row.cCode || ''; opt.text = row.Text || row.cName || ''; fieldEl.appendChild(opt); }); } else { // T+11.0+:调用setOptions,传入数组 const selectObj = TPlus.UI.Select.getInstance(childField); if (selectObj && typeof selectObj.setOptions === 'function') { const formattedOptions = options.map(row => ({ value: row.Value || row.cCode || '', text: row.Text || row.cName || '' })); selectObj.setOptions(formattedOptions); } } }为什么不用
innerHTML批量写入?
因为T+11.0+的Select组件会监听DOM变化,手动innerHTML=会破坏其内部状态,导致点击无反应。必须走setOptions()官方API。6. 二次开发实战:从三级联动扩展到动态表单,一个技巧搞定N个新需求
6.1 场景升级:客户提出“选完型号,要自动带出该型号的默认供应商和结算方式”
这已超出传统级联范畴——它不是下拉选项联动,而是要自动填充其他字段(如
cVenCode供应商编码、cSettleStyle结算方式)。但你会发现,executor.js的executeCascade函数其实早已预留了扩展点:它执行完SQL后,data是完整的JSON数组,rows[0]就包含该型号的所有属性。我们只需在success回调里加几行赋值代码:// 在 executor.js 的 executeCascade 函数内,success 回调中追加: success: (data) => { const rows = JSON.parse(data.d); if (rows.length > 0) { const firstRow = rows[0]; // 【新增】自动填充其他字段 if (firstRow.cVenCode) { // 设置供应商编码(T+标准字段) TPlus.Data.getBillData().getDetailRows()[0].cVenCode = firstRow.cVenCode; // 触发T+重新渲染该字段(否则UI不更新) TPlus.UI.RefreshField('cVenCode'); } if (firstRow.cSettleStyle) { TPlus.Data.getBillData().getDetailRows()[0].cSettleStyle = firstRow.cSettleStyle; TPlus.UI.RefreshField('cSettleStyle'); } } // 原有逻辑:刷新下级下拉框 refreshDropdown(rule.ChildField, rows); }关键点:
TPlus.Data.getBillData().getDetailRows()[0]获取表体第一行数据对象,直接赋值即可;TPlus.UI.RefreshField('cVenCode')是T+官方API,强制刷新指定字段UI,否则用户看不到变化;- 此方案无需改数据库、不新增JS文件,5分钟内完成,比提单给IT部快10倍。
6.2 高阶技巧:用“伪自由项”实现无限级联动
T+自由项只有10个(
cFree1~cFree10),但客户要“省→市→区→街道”四级联动。常规思路是求IT加字段,但我们用了一个取巧办法:把cFree1作为“省”,cFree2作为“市”,cFree3作为“区”,而“街道”不占自由项,而是用cFree10的Text属性存储完整地址字符串,并在cFree3的onChange事件里,用TPlus.Data.getBillData().getDetailRows()[0].cFree10 = '北京市朝阳区建国路8号'直接写入。这样既不突破字段限制,又满足业务。6.3 安全加固:防止恶意SQL注入的三道防线
虽然
FilterSQL由管理员配置,但仍需防患于未然。我们在executor.js中加入三重校验:防线 实现方式 作用 语法白名单 FilterSQL必须以SELECT开头,且不能含INSERT/UPDATE/DELETE/EXEC关键字阻断写操作和命令执行 字段白名单 replacePlaceholders只允许替换cFree1~cFree10、cCode、cName等预设字段名防止注入 {sys.tables}等系统表长度限制 FilterSQL字段在数据库中设为NVARCHAR(1000),超长截断防止超长payload绕过检测 // executor.js 开头加入校验 function validateFilterSQL(sql) { if (!sql || typeof sql !== 'string') return false; if (sql.length > 1000) return false; if (!/^SELECT/i.test(sql)) return false; if (/INSERT|UPDATE|DELETE|EXEC|xp_/i.test(sql)) return false; return true; } // 在 executeCascade 中调用 if (!validateFilterSQL(rule.FilterSQL)) { console.warn(`非法SQL被拦截:${rule.FilterSQL}`); return; }从那以后我每次加新联动规则,都强制走一遍
SELECT TOP 10 * FROM v_Brand WHERE cBigClass='GP-001'在SSMS里先执行通,再粘贴进配置表——看似多一步,却避免了80%的线上SQL报错。希望帮到你。本文还有配套的精品资源,点击获取