fix(swaptrade): 修复重输/重贴原值后回车不触发计算、粘贴不标记REV的静默失效

- swapPricePrecisionHelper: onKeydown 回车时无条件派发 input+enter,
  不再依赖仅值变化才触发的原生 change;onPaste 粘贴即派发 input 标 REV;
  onChange 不再派发 enter(改由 onKeydown 唯一派发,避免重复计算)
- 新增 fe-tests/swapPriceInput.component.test.js 回归守卫(6 用例,
  回退旧版即变红,锁定该 QA 高优 Bug 修复)
- bundle/bundleV2: otcDebug 调试开关改为 localStorage 持久化 +
  URL ?otcdebug=1 触发,便于排查价格计算链路
This commit is contained in:
hjhan
2026-07-31 09:07:08 +08:00
parent cb2076d9ff
commit 74c434bdd8
4 changed files with 161 additions and 22 deletions
@@ -0,0 +1,100 @@
/**
* swapPriceInput.component.test.js — vue-swap-price-input 事件派发回归守卫
* ============================================================================
* 目的:锁定债券三字段输入组件「粘贴 / 重输原值 不派发 input/enter」的 bug。
* 症状:粘贴或重输原值后,点空白(标REV/清其他标识)与回车(触发计算)均无反应。
* 根因:组件把 input/enter 全部锁在原生 change 事件里,而 change 仅在「值相对聚焦时
* 变动」才触发;onPaste 甚至完全不派发任何事件。
*
* 做法:直接实例化组件 options 对象(createVueInputComponent),用 mock this + $emit 间谍
* 驱动 onPaste/onKeydown/onChange,断言事件派发。不依赖 Vue 运行时挂载、不改动源文件。
*
* 运行:cd YLErpWeb/fe-tests && npx jest swapPriceInput.component
* 设 SWAP_PRICE_HELPER 指向其它版本文件可做对照(如改之前的旧代码应使用例变红)。
*/
const fs = require('fs');
const path = require('path');
function loadComponent() {
const rel = process.env.SWAP_PRICE_HELPER
|| '../wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js';
const file = path.resolve(__dirname, rel);
const code = fs.readFileSync(file, 'utf8');
// swapPricePrecisionHelper.js: var swapPricePrecision = (function(global){...})(window);
// 在受控作用域求值并取出 createVueInputComponentIIFE 不挂 window,故用 new Function 取返回值)。
const factory = new Function('window', 'global', 'console', code + '\n;return swapPricePrecision;');
const sandbox = {};
const swapPricePrecision = factory(sandbox, sandbox, console);
return swapPricePrecision.createVueInputComponent();
}
function makeCtx(component, opts) {
opts = opts || {};
const emitted = [];
const ctx = {
text: opts.text != null ? String(opts.text) : '',
enterPressed: false,
formatSnapshot: '{}',
format: opts.format || { integerDigits: 2, precision: 4, percent: true },
$emit: function (name, payload) { emitted.push({ name: name, payload: payload }); },
};
Object.keys(component.methods).forEach(function (m) {
ctx[m] = component.methods[m].bind(ctx);
});
return { ctx: ctx, emitted: emitted };
}
describe('vue-swap-price-input 事件派发(粘贴 / 重输原值场景)', () => {
const component = loadComponent();
test('粘贴原值应派发 input(点空白标REV / 清其他标识)', () => {
const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: true } });
const evt = { preventDefault() {}, clipboardData: { getData: () => '3.5' }, target: { value: '' } };
ctx.onPaste(evt);
const inputs = emitted.filter(e => e.name === 'input');
expect(inputs.length).toBe(1);
expect(inputs[0].payload).toBe('0.035'); // percent:true → ÷100
});
test('回车(原值未变)应派发 enter(触发自动计算)', () => {
const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: true } });
const evt = { which: 13, keyCode: 13, key: 'Enter', target: { blur() {}, value: '3.5' } };
ctx.onKeydown(evt);
const enters = emitted.filter(e => e.name === 'enter');
expect(enters.length).toBe(1);
expect(enters[0].payload).toBe('0.035');
});
test('回车(值已改)应派发 input+enter 各一次,且不重复派发 enter', () => {
const { ctx, emitted } = makeCtx(component, { text: '3.6', format: { percent: true } });
const evt = { which: 13, keyCode: 13, key: 'Enter', target: { blur() {}, value: '3.6' } };
ctx.onKeydown(evt);
expect(emitted.filter(e => e.name === 'input').length).toBe(1);
expect(emitted.filter(e => e.name === 'enter').length).toBe(1);
});
test('非 Enter 按键不应派发 enter / input', () => {
const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { percent: true } });
const evt = { which: 65, keyCode: 65, key: 'a', target: { blur() {} } };
ctx.onKeydown(evt);
expect(emitted.filter(e => e.name === 'enter').length).toBe(0);
expect(emitted.filter(e => e.name === 'input').length).toBe(0);
});
test('失焦(onChange)即使值未变也应派发 input(点空白标REV),且不再派发 enter', () => {
const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { percent: true } });
const evt = { target: { value: '3.5' } };
ctx.onChange(evt);
expect(emitted.filter(e => e.name === 'input').length).toBe(1);
expect(emitted.filter(e => e.name === 'enter').length).toBe(0);
});
test('粘贴但格式为 percent:false 也应派发 input(原值透传)', () => {
const { ctx, emitted } = makeCtx(component, { text: '3.5', format: { integerDigits: 2, precision: 4, percent: false } });
const evt = { preventDefault() {}, clipboardData: { getData: () => '3.5' }, target: { value: '' } };
ctx.onPaste(evt);
expect(emitted.filter(e => e.name === 'input').length).toBe(1);
expect(emitted.filter(e => e.name === 'input')[0].payload).toBe('3.5');
});
});
@@ -208,22 +208,32 @@ var swapPricePrecision = (function (global) {
event.preventDefault();
this.updateValue(clipboard.getData('text'), true, false);
event.target.value = this.text;
// 粘贴即显式赋值意图:直接派发 input,使「单击空白处标REV/其余清空」「回车前已标记」等行为生效。
// 原实现只把粘贴内容写回DOM、等失焦时的原生change才派发,而原值粘贴时change不触发→静默无反应。
this.$emit('input', this.toModel(this.text));
},
onKeydown: function (event) {
this.enterPressed = false;
this.$emit('keydown', event);
const keyCode = event.which || event.keyCode;
if (event.key !== 'Enter' && keyCode !== 13 && keyCode !== 108) return;
this.enterPressed = true;
event.target.blur();
// 回车=用户显式提交意图:无论值是否相对聚焦时变化,都直接派发 input(标REV) + enter(触发计算)。
// 原实现把 enter 锁在原生 change 里,而 change 仅在值变化时触发,
// 导致「重输/重贴原值 + 回车」静默不计算(QA 高优 Bug)。
this.updateValue(this.text, true, false);
const value = this.toModel(this.text);
event.target.blur(); // 同步触发原生 change(值变化时再派发一次 input,幂等无害)
this.$emit('input', value);
this.$emit('enter', value);
this.enterPressed = false;
},
onChange: function (event) {
if (this.text.endsWith('.')) this.text = this.text.substring(0, this.text.length - 1);
this.updateValue(this.text, true, false);
const value = this.toModel(this.text);
// 失焦(非回车)→ 仅派发 input 标 REVenter(计算) 改由 onKeydown 直接派发,
// 避免 change 不触发时丢失,也避免与 onKeydown 重复派发 enter 造成重复计算。
this.$emit('input', value);
if (this.enterPressed) this.$emit('enter', value);
this.enterPressed = false;
event.target.value = this.text;
}
},
+24 -9
View File
@@ -14667,6 +14667,7 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
};
}(window.FastVue));
//日期选择组件FastVue.DatePicker
(function (global) {
global.vueDatePicker = function () {
@@ -15009,21 +15010,35 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru
//依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js
// 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。
// 开启方式URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。
// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log
// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。
var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false };
// 开启:URL 加 ?otcdebug=1(会自动写入 localStorage,同源 iframe/弹窗自动继承);
// 或控制台 localStorage.setItem('otcdebug','1')。关闭:localStorage.removeItem('otcdebug')。
// 业务脚本用 otcDebug.banner / otcDebug.log 替代裸 console.log(见 swapTradeEdit.js / fastVue.base.js)。
//
// 命名约定(避免 __私有对象 + otcDebug.API 大小写混搭):
// __diag —— 版本元数据对象(仅 _MainLayout 灌入 ylotc.__diag 时存在,仅供 banner 展示,不参与开关判定)
// __debug —— 调试开关布尔值(唯一真相来源:localStorage 优先 → URL 参数 → 兜底 __diag.debug
var __diag = (window.ylotc && window.ylotc.__diag) || {};
var __debug = (function () {
try {
// 1) URL 参数优先(排查最直观);命中即写入 localStorage,供同源 iframe/弹窗继承
if (/[?&]otcdebug=1/.test(location.search)) { localStorage.setItem('otcdebug', '1'); return true; }
// 2) 已持久化的开关(父页面开过一次,_InfoLayout 弹窗自动生效)
if (localStorage.getItem('otcdebug') === '1') return true;
} catch (e) { /* localStorage 不可用时忽略,继续走兜底 */ }
// 3) 兜底:layout 灌入的 ylotc.__diag.debug(仅 _MainLayout 有)
return !!__diag.debug;
})();
window.otcDebug = {
// 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存"
// 模块加载横幅:F12 一眼看到模块名+版本,用于排查"是不是加载了旧代码/旧缓存"
banner: function (name, ver) {
if (!__otcDiag.debug || !window.console) return;
var git = (__otcDiag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')',
if (!__debug || !window.console) return;
var git = (__diag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')',
'color:#06c;font-weight:bold');
},
// 普通调试日志:透传参数,仅 debug 开启时输出
log: function () {
if (!__otcDiag.debug || !window.console) return;
if (!__debug || !window.console) return;
console.log.apply(console, arguments);
}
};
+23 -9
View File
@@ -802,21 +802,35 @@ An}();typeof define=="function"&&typeof define.amd=="object"&&define.amd?($n._=r
//依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js
// 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。
// 开启方式URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。
// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log
// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。
var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false };
// 开启:URL 加 ?otcdebug=1(会自动写入 localStorage,同源 iframe/弹窗自动继承);
// 或控制台 localStorage.setItem('otcdebug','1')。关闭:localStorage.removeItem('otcdebug')。
// 业务脚本用 otcDebug.banner / otcDebug.log 替代裸 console.log(见 swapTradeEdit.js / fastVue.base.js)。
//
// 命名约定(避免 __私有对象 + otcDebug.API 大小写混搭):
// __diag —— 版本元数据对象(仅 _MainLayout 灌入 ylotc.__diag 时存在,仅供 banner 展示,不参与开关判定)
// __debug —— 调试开关布尔值(唯一真相来源:localStorage 优先 → URL 参数 → 兜底 __diag.debug
var __diag = (window.ylotc && window.ylotc.__diag) || {};
var __debug = (function () {
try {
// 1) URL 参数优先(排查最直观);命中即写入 localStorage,供同源 iframe/弹窗继承
if (/[?&]otcdebug=1/.test(location.search)) { localStorage.setItem('otcdebug', '1'); return true; }
// 2) 已持久化的开关(父页面开过一次,_InfoLayout 弹窗自动生效)
if (localStorage.getItem('otcdebug') === '1') return true;
} catch (e) { /* localStorage 不可用时忽略,继续走兜底 */ }
// 3) 兜底:layout 灌入的 ylotc.__diag.debug(仅 _MainLayout 有)
return !!__diag.debug;
})();
window.otcDebug = {
// 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存"
// 模块加载横幅:F12 一眼看到模块名+版本,用于排查"是不是加载了旧代码/旧缓存"
banner: function (name, ver) {
if (!__otcDiag.debug || !window.console) return;
var git = (__otcDiag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')',
if (!__debug || !window.console) return;
var git = (__diag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')',
'color:#06c;font-weight:bold');
},
// 普通调试日志:透传参数,仅 debug 开启时输出
log: function () {
if (!__otcDiag.debug || !window.console) return;
if (!__debug || !window.console) return;
console.log.apply(console, arguments);
}
};