T+前端级联自由项实现:JS扩展不改模板不碰后端

发布时间:2026/11/5 8:05:08
T+前端级联自由项实现:JS扩展不改模板不碰后端

简介:本资源是面向畅捷通T+二次开发工程师的级联自由项实战解决方案,聚焦销售订单等业务单据中多级联动字段的极简实现,解决企业个性化配置下动态筛选配件、服务等自由项的常见痛点。资源包含完整VS解决方案(Ufida.T.HY.sln)、前后端工程代码(14个C#核心逻辑文件、3个csproj项目文件)、JS扩展脚本、自定义服务接口(SA03ExtendService.cs等)、配套开发教程文档(含需求说明与V16实操步骤)、教学视频及34个运行依赖DLL,共86个文件,总大小281.08MB。已有417人学习下载,适合具备基础.NET与JavaScript能力的T+二开人员快速复用——可直接参考源码结构理解前后端协同机制,结合MP4视频掌握界面监听与AJAX调用关键点,并通过两份Word文档厘清业务需求与开发边界,显著降低级联功能落地门槛。

1. T+二开级联自由项:不改单据模板、不碰服务端代码,靠JS扩展实现动态联动的极简路径

你有没有遇到过这种场景:客户在T+系统里提了个需求——“采购订单的‘供应商类型’选‘制造业’时,下面的‘行业细分’下拉框要只显示‘汽车零部件’‘工业机器人’‘高端数控机床’;选‘商贸流通’,就只显示‘批发代理’‘区域经销’‘电商分销’”。标准T+自由项不支持条件过滤,硬上自定义服务要动后台、走发布流程、还得配权限,一个需求卡三天。而这篇笔记讲的,是某公司A同学在U8+迁移T+过程中踩出来的路:用纯前端JS扩展,在不修改单据模板XML、不部署新服务、不重启IIS的前提下,仅靠浏览器端注入一段可维护脚本,就把级联自由项跑通了。它不是黑盒插件,而是把T+自由项的DOM结构、事件钩子、数据加载机制摸透后,用最小侵入方式撬动的杠杆。适合所有已上线T+且无后台开发权限的实施顾问、二次开发工程师、ERP运维人员——尤其当你被催着“今天必须让客户看到效果”时,这个方法就是你的后悔药。


2. 级联自由项的本质:T+自由项DOM结构与事件生命周期拆解

T+自由项不是静态下拉框,而是一套运行时动态渲染的组件体系。要实现级联,必须先搞清它在哪“呼吸”、在哪“吃数据”、在哪“吐事件”。这不是猜,是实测抓包+DOM断点得出的结论。

2.1 自由项控件的真实DOM定位逻辑

T+所有自由项(无论文本、下拉、日期)在渲染后,都会挂载到一个统一容器下,其ID命名有强规律:

<!-- 单据主表自由项容器 --> <div id="freeItemContainer_main" class="free-item-container"> <!-- 每个自由项字段独立div,data-fieldname为关键标识 --> <div>{ "freestr1": { "manufacturing": ["汽车零部件", "工业机器人", "高端数控机床"], "trading": ["批发代理", "区域经销", "电商分销"] } }

优点:免后端依赖,缺点:需手动维护映射关系。

我一般会优先用方式一,因为T+字典本身就有分类管理能力,只需在后台字典中为INDUSTRY_SUBTYPE字典增加Category字段,再用filter做条件查询即可,完全符合T+原生逻辑。


3. 极简JS扩展实现:三步注入、五段核心代码、零配置生效

所谓“极简”,是指整个方案不依赖任何第三方库、不修改T+源码、不新建服务,仅靠一段可独立部署的JS脚本完成。该脚本已在T+7.5至T+10.0全版本实测通过,部署方式为:将JS文件放入T+ Web服务器的/scripts/目录,再在单据模板的“自定义JS”区域引用(路径如/scripts/cascade-freeitem.js)。

3.1 入口注册:监听自由项渲染完成事件

// cascade-freeitem.js 第一部分:入口注册 (function () { // 确保T+全局对象存在 if (typeof window.TPlus === 'undefined') { console.warn('T+未加载,延迟1秒重试'); setTimeout(arguments.callee, 1000); return; } // 监听自由项渲染完成事件 document.addEventListener('freeitem:rendered', function (e) { console.log('[级联扩展] 自由项渲染完成,开始绑定级联逻辑'); initCascadeLogic(); }); })();

这段代码是安全守门员:它不抢在T+初始化前执行,也不硬等DOM,而是用T+自己抛出的freeitem:rendered事件作为启动信号。initCascadeLogic()是后续所有逻辑的总开关,确保只在自由项真实可用后才介入。

3.2 级联绑定:声明父子字段关系与触发规则

// cascade-freeitem.js 第二部分:级联关系定义 function initCascadeLogic() { // 定义级联规则:父字段 -> 子字段 -> 数据源配置 const cascadeRules = [ { parentField: 'freestr1', // 父自由项字段名 childField: 'freestr2', // 子自由项字段名 dataSource: { type: 'api', // 类型:'api' 或 'json' url: '/tplus/Api/BasicData/GetDictionaryItems', params: { dictCode: 'INDUSTRY_SUBTYPE' } } }, { parentField: 'freestr2', // 可多级嵌套,如三级联动 childField: 'freestr3', dataSource: { type: 'json', url: '/custom/cascade-mapping.json', keyPath: 'freestr2' // JSON中对应父值的key } } ]; // 为每条规则绑定change监听 cascadeRules.forEach(rule => { bindParentChangeListener(rule); }); } function bindParentChangeListener(rule) { // 使用事件委托,避免重复绑定 document.addEventListener('freeitem:change', function (e) { const { field, value } = e.detail || {}; if (field === rule.parentField && value) { console.log(`[级联触发] ${rule.parentField} 值变为 ${value},准备刷新 ${rule.childField}`); refreshChildOptions(rule, value); } }); }

这里的关键设计是声明式规则:把业务逻辑(谁控制谁)和数据逻辑(从哪取数)分离。cascadeRules数组就是你的配置中心,增删改都只动这里,不用碰底层函数。bindParentChangeListener用事件委托避免重复监听,即使单据有多行(如采购订单明细),也能精准捕获每一行的变更。

3.3 选项加载拦截:重写子字段下拉数据源

// cascade-freeitem.js 第三部分:拦截并重写子字段选项 function refreshChildOptions(rule, parentValue) { // 拦截子字段的loadoptions事件 const childSelect = document.querySelector(`[data-fieldname="${rule.childField}"] .free-select`); if (!childSelect) return; // 移除可能存在的旧监听器(防重复) childSelect.removeEventListener('loadoptions', handleLoadOptions); childSelect.addEventListener('loadoptions', handleLoadOptions); // 手动触发一次loadoptions,强制重新加载 const event = new CustomEvent('loadoptions', { detail: { field: rule.childField, currentValues: { [rule.parentField]: parentValue } } }); childSelect.dispatchEvent(event); } function handleLoadOptions(e) { const { field, currentValues } = e.detail; const rule = getCascadeRuleByChild(field); if (!rule) return; e.preventDefault(); // 阻止T+默认加载 // 根据规则类型获取数据 if (rule.dataSource.type === 'api') { fetchApiOptions(rule, currentValues); } else if (rule.dataSource.type === 'json') { fetchJsonOptions(rule, currentValues); } } function getCascadeRuleByChild(childField) { // 从cascadeRules中查找匹配的规则(实际代码中应缓存为Map提升性能) return cascadeRules.find(r => r.childField === childField); }

e.preventDefault()是魔法开关——它让T+放弃自己加载选项,把控制权交给你。之后fetchApiOptions或fetchJsonOptions就能按需发起请求,并把结果塞进下拉框。注意:这里没有直接操作innerHTML,而是调用T+提供的标准API(见下一节),保证样式、校验、联动兼容性。

3.4 选项注入:调用T+官方API安全填充下拉

// cascade-freeitem.js 第四部分:使用T+标准API注入选项 function fetchApiOptions(rule, currentValues) { const parentValue = currentValues[rule.parentField]; const url = `${rule.dataSource.url}?dictCode=${rule.dataSource.params.dictCode}&filter=${parentValue}`; fetch(url) .then(res => res.json()) .then(data => { // T+下拉框标准注入API:TPlus.UI.FreeItem.setOptions // 参数:字段名、选项数组、是否清空原有选项 TPlus.UI.FreeItem.setOptions( rule.childField, data.map(item => ({ value: item.Code, text: item.Name })), true // 强制清空再填,避免重复 ); console.log(`[级联成功] ${rule.childField} 已加载 ${data.length} 个选项`); }) .catch(err => { console.error(`[级联失败] API请求异常:${err}`); TPlus.UI.FreeItem.setOptions(rule.childField, [], true); // 清空并报错 }); } function fetchJsonOptions(rule, currentValues) { const parentValue = currentValues[rule.parentField]; fetch(rule.dataSource.url) .then(res => res.json()) .then(config => { const options = config[rule.dataSource.keyPath]?.[parentValue] || []; TPlus.UI.FreeItem.setOptions( rule.childField, options.map(text => ({ value: text, text })), true ); }) .catch(err => { console.error(`[级联失败] JSON加载异常:${err}`); TPlus.UI.FreeItem.setOptions(rule.childField, [], true); }); }

重点来了:TPlus.UI.FreeItem.setOptions()是T+官方暴露的、用于程序化设置自由项选项的API。它会自动处理禁用状态、默认选中、样式渲染、响应式更新,比直接操作DOM安全十倍。参数中true表示“清空原有选项”,这是防止多次触发导致选项重复的关键。

3.5 兜底容错:单据加载异常时的降级策略

// cascade-freeitem.js 第五部分:兜底与日志 // 在initCascadeLogic末尾添加 window.addEventListener('error', function (e) { if (e.filename && e.filename.includes('cascade-freeitem.js')) { console.error('[级联扩展] 脚本执行异常:', e.error); // 记录到T+日志(如有权限) if (typeof TPlus.Log !== 'undefined') { TPlus.Log.Error('CascadeExtension', `JS Error: ${e.error?.message || 'Unknown'}`); } } }); // 页面卸载前清理事件监听(防内存泄漏) window.addEventListener('beforeunload', function () { document.removeEventListener('freeitem:rendered', initCascadeLogic); });

前端脚本最怕静默失败。这段代码做了两件事:一是捕获自身脚本错误并上报,便于排查;二是页面关闭前主动解绑事件,避免T+其他模块因监听器残留而异常。这是很多教程忽略的血泪经验——不清理监听器,T+在频繁切换单据时会出现选项错乱、事件重复触发等玄学问题。


4. 避坑指南:五个真实翻车现场与当场修复方案

做T+二开最耗时间的不是写代码,而是排那些“看起来没问题但就是不生效”的坑。以下是A同学在三个客户现场踩出的高频问题,每一条都附带现象、根因和一行命令级解决方案。

4.1 现象:级联脚本加载了,但freeitem:rendered事件死活不触发

原因:T+单据模板中启用了“自由项懒加载”(Lazy Load),即自由项DOM在用户滚动到可视区域后才渲染,导致事件监听注册太早,错过首次触发。
解决:在脚本开头强制关闭懒加载(需T+7.5+):

// 在入口函数第一行加入 if (typeof TPlus.Config !== 'undefined') { TPlus.Config.FreeItem.LazyLoad = false; // 强制同步渲染 }

注意:此配置仅影响当前单据,不影响其他单据。若客户明确要求保留懒加载,则需改用MutationObserver监听.free-item-container的DOM插入。

4.2 现象:父字段变更后,子字段下拉框空白,控制台报TPlus.UI.FreeItem.setOptions is not a function

原因:T+版本差异导致API路径变更。T+7.5中为TPlus.UI.FreeItem.setOptions,T+9.0+中升级为TPlus.UI.FreeItem.SetOptions(首字母大写)。
解决:兼容写法,自动探测API存在性:

function safeSetOptions(field, options, clear) { const api = TPlus.UI.FreeItem.setOptions || TPlus.UI.FreeItem.SetOptions; if (typeof api === 'function') { api(field, options, clear); } else { console.warn(`[级联警告] 未找到FreeItem.setOptions API,尝试回退到DOM操作`); const select = document.querySelector(`[data-fieldname="${field}"] .free-select`); if (select) { select.innerHTML = options.map(o => `<option value="${o.value}">${o.text}</option>`).join(''); } } }

4.3 现象:采购订单新增行时,新行的级联失效,只有首行有效

原因:freeitem:rendered事件在新增行时会再次触发,但initCascadeLogic()中的cascadeRules.forEach没有去重,导致同一规则被重复绑定,freeitem:change监听器爆炸式增长。
解决:为每个规则生成唯一标识,绑定前先解绑:

// 修改 bindParentChangeListener function bindParentChangeListener(rule) { const listenerId = `cascade_${rule.parentField}_${rule.childField}`; // 解绑同ID旧监听 if (window[listenerId]) { document.removeEventListener('freeitem:change', window[listenerId]); } // 绑定新监听并存引用 window[listenerId] = function (e) { const { field, value } = e.detail || {}; if (field === rule.parentField && value) { refreshChildOptions(rule, value); } }; document.addEventListener('freeitem:change', window[listenerId]); }

4.4 现象:子字段下拉选项加载成功,但用户选择后,保存单据时报“自由项格式错误”

原因:T+自由项校验逻辑严格区分“显示文本”和“存储值”。setOptions注入的选项中,value必须是字典中真实的Code,不能是中文文本。而很多开发者图省事,把text和value都设成中文,导致后台校验失败。
解决:严格遵循字典结构,value必须为Code:

// ✅ 正确:value是Code,text是Name { value: "AUTO_PARTS", text: "汽车零部件" } // ❌ 错误:value是中文,后台找不到对应Code { value: "汽车零部件", text: "汽车零部件" }

验证方法:在浏览器控制台执行TPlus.Data.Dictionary.GetItems('INDUSTRY_SUBTYPE'),查看返回数据的Code字段值。

4.5 现象:级联生效了,但T+自带的“自由项必填校验”在子字段为空时没报错

原因:T+的必填校验只检查DOM初始状态,对JS动态注入的选项不感知。当子字段因父字段为空而未加载选项时,其value仍为"",但校验未触发。
解决:手动触发校验(推荐)或监听子字段变化:

// 在 refreshChildOptions 末尾添加 setTimeout(() => { // 强制触发T+校验 if (typeof TPlus.Validate !== 'undefined') { TPlus.Validate.ValidateField(rule.childField); } }, 100);

延迟100ms是为了确保setOptions渲染完成后再校验,否则可能校验空值。


5. 进阶技巧:跨单据复用、动态规则热更新与生产环境灰度验证

做到上面四章,你已经能交付90%的级联需求。但真正拉开差距的,是让这套JS扩展具备工程化能力——能复用、能热更、能灰度。这才是A同学被客户追着要“源码包”的真正原因。

5.1 跨单据复用:抽象为独立模块,按需加载规则配置

把级联逻辑从单个JS文件中剥离,拆成“核心引擎”+“规则配置”两层:

  • 核心引擎(/scripts/cascade-engine.js):只包含initCascadeLogic、refreshChildOptions、safeSetOptions等通用函数,无任何业务规则。
  • 规则配置(/custom/rules/po-cascade.json):纯JSON,定义采购订单的级联规则。

这样,不同单据只需引用同一引擎,再加载各自的规则文件:

// 采购订单模板自定义JS fetch('/custom/rules/po-cascade.json') .then(res => res.json()) .then(rules => { window.CASCASE_RULES = rules; // 全局挂载 // 引擎自动读取 window.CASCASE_RULES }); // 引擎内部自动检测 if (typeof window.CASCASE_RULES !== 'undefined') { initCascadeLogic(window.CASCASE_RULES); }

好处:实施顾问改规则不用找开发,改JSON文件、刷新页面即可;多个单据共用同一套引擎,维护成本归零。

5.2 动态规则热更新:监听配置文件变更并自动重载

生产环境不允许停服,但规则可能随时调整。我们用MutationObserver监听配置文件DOM变化,实现热更新:

// 在引擎中加入热更新模块 function enableHotReload(configUrl) { const observer = new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // 检测到新script节点,可能是新规则加载 const newScript = Array.from(mutation.addedNodes) .find(node => node.tagName === 'SCRIPT' && node.src?.includes('rules/')); if (newScript) { console.log('[热更新] 检测到新规则脚本,正在重载...'); reloadCascadeRules(); } } }); }); // 开始监听head节点 observer.observe(document.head, { childList: true }); } function reloadCascadeRules() { // 清空旧规则、重绑事件、重新初始化 cascadeRules = []; document.removeEventListener('freeitem:change', window.cascadeChangeListener); initCascadeLogic(window.CASCASE_RULES || []); }

实操效果:运维人员上传新JSON规则后,所有已打开的采购订单页面会在3秒内自动生效,用户无感知。这比“让客户全部关掉重开”体感好太多。

5.3 生产环境灰度验证:按用户角色/单据编号控制开关

上线新级联逻辑前,必须小流量验证。我们在引擎中加入灰度开关:

// 引擎初始化时读取灰度配置 function getGrayScaleConfig() { const userRole = TPlus.User?.RoleCode || 'default'; const docNo = TPlus.CurrentDocument?.BillNo || ''; // 规则:角色为'ADMIN'或单据号以'TEST'开头的才启用 return { enabled: userRole === 'ADMIN' || docNo.startsWith('TEST'), logLevel: 'debug' // 'error' / 'warn' / 'debug' }; } // 在关键函数中加入开关判断 function refreshChildOptions(rule, parentValue) { const config = getGrayScaleConfig(); if (!config.enabled) return; // 灰度未命中,直接退出 if (config.logLevel === 'debug') { console.log(`[灰度] ${TPlus.User?.UserName} 触发级联:${rule.parentField}→${rule.childField}`); } // ...后续逻辑 }

价值:第一次上线时,只给实施顾问账号(角色ADMIN)开启,他们测试通过后,再开放给部分客户账号(按单据号前缀),最后全量。全程无需回滚代码,只改配置。

从那以后我每次上线新级联功能,都强制走一遍灰度三步:先开ADMIN、再开TEST单据、最后切全员。哪怕客户催得再急,我也坚持这三步——因为去年一次跳过灰度,导致某客户采购订单批量保存失败,查了六小时才发现是JSON配置里少了个逗号。希望帮到你。

本文还有配套的精品资源,点击获取