feat(swaptrade): 债券三字段计算器交互改为回车触发并加源/AUTO/REV 三态标识

- 约定1:仅在净价/全价/收益率输入框敲回车触发计算器,失焦不触发
- 约定2:计算成功->源字段标"源"、另两字段标"AUTO";失败->保留源值、清空另两字段与标识
- 约定3:编辑某字段未回车->不联动另两字段,清源/AUTO、仅本字段标"REV"
- swapCalc.js 新增 applyBondCalcSuccess/Failure/ManualEdit 纯函数,applyBondCalcResult 第三参由 manualSet 改为 driver(跳过源字段写回)
- fastVue.base.js 新增 enter 事件区分回车/失焦,向后兼容旧的 input 行为
- TradeEdit.cshtml 三字段 input/enter 分离绑定 + 源/AUTO/REV 三态标签(去除旧铅笔图标)
- 重写 bondCalc.test.js / 更新 swapCalc.test.js 覆盖状态机;jest 68 项全过
This commit is contained in:
hjhan
2026-07-29 14:34:25 +08:00
parent 8c4f452482
commit b9f2bd14f6
6 changed files with 271 additions and 197 deletions
@@ -201,19 +201,20 @@
}
/**
* 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。
* - manualSet: { CP:bool, DP:bool, YD:bool },标记哪些字段是用户本次会话中手动输入/修改过的
* 债券净价/全价/收益率三字段互算:以 driver(CP/DP/YD) 为"源",回写时跳过源字段(保留用户手输值),
* 其余两字段用计算器结果覆盖。
* - driver: 'CP'净价 / 'DP'全价 / 'YD'收益率,即本次回车的输入依据
* - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj
* - state: 持有三个字段的对象(直接原地写回)
* 设计(回应"计算结果不认可时如何优雅手动覆盖"):
* 用户每手填一个字段,该字段即被锁定;反算只填充"未手填"的字段,已手填的(含刚编辑的)一律不回写。
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个
* 设计(对应交互约定1+2):
* 回车某字段即以其为源调计算器,另两个字段被反算覆盖;源字段保持用户手输值不被回写。
* 故用户回车净价→全价/收益率被重算;再次回车全价→净价/收益率被重算(最后回车者恒为源)
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
*/
function applyBondCalcResult(state, calc, manualSet) {
function applyBondCalcResult(state, calc, driver) {
var EPS = 1e-4;
function write(field, type, value) {
if (manualSet && manualSet[type]) return; // 用户手填过的字段:绝不回写
if (driver === type) return; // 源字段:保留用户手输值,不回写
if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖
var cur = state[field];
if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动
@@ -241,15 +242,58 @@
return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数
}
/**
* 清空债券三字段互算的【手动/源】标志(纯函数,jest 可测)。
* 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的手填状态(bondManual/bondDriverType)
* 污染新债券——否则旧债券标记过的字段在新债券上会被错误跳过 / 沿用旧态(D1 修复)
* 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象(首次交互已 $set 过 bondManual),
* 故重赋值能正常触发响应式更新;全新未交互过的标的清不清都无副作用。
* 清空债券三字段互算的【源/AUTO/REV】标志(纯函数,jest 可测)。
* 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的状态污染新债券(D1 修复)。
* 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象,重赋值能正常触发响应式更新
*/
function clearBondCalcFlags(state) {
state.bondManual = { CP: false, DP: false, YD: false };
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定2(纯函数,jest 可测):计算器调用【成功】后的标识落地。
* - 以 driver(CP/DP/YD) 为"源",标记 bondDriverType=driver
* - 其余两字段标记 bondAuto=true"AUTO"=计算器算出的);
* - 清除 bondRev(人工输入标记)。
* 不写任何价格数值——数值由 applyBondCalcResult 回写。
*/
function applyBondCalcSuccess(state, driver) {
state.bondDriverType = driver;
state.bondAuto = { CP: driver !== 'CP', DP: driver !== 'DP', YD: driver !== 'YD' };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定2(纯函数,jest 可测):计算器调用【失败】后的标识与数值落地。
* - 保留用户刚回车的"源"字段值(driver),让用户可就地修改;
* - 另两个字段清空为空白("另外两个数值都更新为空白");
* - 三个数值旁边的标识全部清空("三个数值旁边都清空标识")。
*/
function applyBondCalcFailure(state, driver) {
var fields = { CP: 'PosiNetNoFeePrice', DP: 'PosiGrossPrice', YD: 'InitYtm' };
Object.keys(fields).forEach(function (t) {
if (t !== driver) state[fields[t]] = null; // 非源字段清空
});
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定3(纯函数,jest 可测):编辑某字段但未回车(失焦/输入)时的标识落地。
* - 清除 源 与 AUTO 标识(不再代表任何已算结果);
* - 仅本字段标 bondRev[type]=true"REV"=人工输入),不联动另外两字段。
*/
function applyBondManualEdit(state, type) {
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
if (type) state.bondRev[type] = true;
return state;
}
@@ -296,16 +340,16 @@
}
/**
* D2 修复(纯函数,jest 可测):重开(审批重开/刷新)一只【已保存且三字段齐全】的债券成交单时,
* 把三字段互算的手动标志一次性全置 true,使计算器在重开期间不再自动反算、覆盖当初保存的其他两个值
* - 不设置 bondDriverType:重开时没有任何字段作为"计算源"calcBondForItem 会早返回(要求 driver 非空),
* 故纯展示保存值、零自动推导;用户点"重算"才清除标志并重新推导
* - 用户若编辑其中某格:onBondPriceInput 会把它设为 driver 并重算,但因另两格仍是 manual=true
* 不会被覆盖 → 满足"重开后手填覆盖跨会话 sticky、编辑不联动另两格"。
* 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券不会被误锁。
* 加载路径(重开/刷新已保存债券单)调用(纯函数,jest 可测):
* 把三字段互算的状态标识全部清空,使已保存的净价/全价/收益率纯展示、无任何 源/AUTO/REV 标记
* - 不锁定、不置源:交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格;
* 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)
* 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券无副作用。
*/
function markExistingBondManual(state) {
state.bondManual = { CP: true, DP: true, YD: true };
function clearBondCalcMarksOnLoad(state) {
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
@@ -342,7 +386,10 @@
bondPriceToCalc: bondPriceToCalc,
bondCalcPriceToStorage: bondCalcPriceToStorage,
clearBondCalcFlags: clearBondCalcFlags,
markExistingBondManual: markExistingBondManual,
applyBondCalcSuccess: applyBondCalcSuccess,
applyBondCalcFailure: applyBondCalcFailure,
applyBondManualEdit: applyBondManualEdit,
clearBondCalcMarksOnLoad: clearBondCalcMarksOnLoad,
shouldShowBondErr: shouldShowBondErr,
getBondStartDateMissingMsg: getBondStartDateMissingMsg,
roundHalfAwayFromZero: roundHalfAwayFromZero,
@@ -328,51 +328,61 @@ const vue = new Vue({
changeClearingAgency() {
this.trade.MetaDic["清算机构"] = this.viewState.Extend.ClearingAgency;
},
//全价(DP)列输入:先按标的单价重算名义本金(非债券标的也需要),再对债券触发三字段反算
// 组件 vue-number-input 只 $emit('input')(失焦/回车时由底层 numberInput.onchange 触发),
// 故此列绑 v-on:input,一个入口同时覆盖普通标的与债券两种情形
//交互约定1+2:在净价/全价/收益率任一输入框【敲回车】→ 以该字段为源调计算器反算另两个
// 成功:源字段标"源",另两个标"AUTO"SwapCalc.applyBondCalcSuccess);
// 失败:保留源字段值、另两个清空、三字段标识全清(SwapCalc.applyBondCalcFailure
// 注意:计算触发只在回车(由 vue-number-input 的 enter 事件驱动),失焦不触发(见 onBondPriceEdit 约定3)。
onBondPriceEnter(item, type) { // type: 'CP'净价 / 'DP'全价 / 'YD'收益率
if (!item.isBond) return;
if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; }
var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); };
var price = type === 'CP' ? item.PosiNetNoFeePrice
: type === 'DP' ? item.PosiGrossPrice
: item.InitYtm;
if (!hasVal(price)) return; // 空/非数:不触发计算器
this.calcBondForItem(item, type);
},
//交互约定3:编辑某字段(失焦/输入)但未回车 → 不联动另两字段,清掉 源/AUTO 标识,
// 仅本字段标"REV"(人工输入),由 SwapCalc.applyBondManualEdit 落地。
// 由 vue-number-input 的 input 事件(失焦/回车都会触发)驱动;回车时 enter 事件会随后把标识修正为 源/AUTO。
onBondPriceEdit(item, type) {
if (!item.isBond) return;
SwapCalc.applyBondManualEdit(item, type);
},
//全价列:普通标的重算名义本金;债券标的按交互约定3 标 REV(不联动)。
// 回车计算由独立的 v-on:enter="onBondPriceEnter(item,'DP')" 负责。
onDpPriceInput(item) {
this.changeSpotPrice(item); // 名义本金随全价变化(非债券标的的既有逻辑)
this.onBondPriceInput(item, 'DP'); // 债券:以全价为源反算净价/收益率(非债券内部会早返回)
if (item.isBond) SwapCalc.applyBondManualEdit(item, 'DP'); // 交互约定3:失焦未回车→REV
},
//债券收益互换:编辑某一浮动腿的净价/全价/收益率之一 → 该字段标记为"已手动设过"(锁定)
// 并以它为源调计算器反算"尚未手动设过"的另两个字段(已手动设过的一律不回写)
onBondPriceInput(item, type) { // type: 'CP'净价 / 'DP'全价 / 'YD'收益率
if (!item.isBond) return;
var m = item.bondManual || { CP: false, DP: false, YD: false };
m[type] = true;
this.$set(item, 'bondManual', m); // 锁定用户手填字段
this.$set(item, 'bondDriverType', type); // 以该字段为本次计算源
this.calcBondForItem(item);
},
//放弃手动覆盖、以当前某字段为源重新自动反算(清空所有手动锁定标记)
//放弃手动标记、以当前某字段为源重新自动反算("重算"按钮)。
resetBondCalc(item) {
if (!item.isBond) return;
this.$set(item, 'bondManual', { CP: false, DP: false, YD: false });
// 优先沿用用户最后一次编辑锁定的源(bondDriverType),避免"净价残留脏值"时误以净价为源反算出连锁错误;
// 优先沿用用户最后一次回车锁定的源(bondDriverType),避免"净价残留脏值"时误以净价为源反算出连锁错误;
// 无历史源时再按 净价→全价→收益率 回退挑一个有值的字段。
var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); };
var driver = item.bondDriverType
|| (hasVal(item.PosiNetNoFeePrice) ? 'CP'
: hasVal(item.PosiGrossPrice) ? 'DP'
: hasVal(item.InitYtm) ? 'YD' : null);
this.$set(item, 'bondDriverType', driver);
this.calcBondForItem(item);
if (!driver) return;
this.calcBondForItem(item, driver);
},
//以该浮动腿的 bondDriverType 为源调 /Bond/CalcBond;回写时跳过"已手动设过的字段"(bondManual)
//仅反向填充用户尚未手动设过的字段——故用户可逐个手填全部三个而互算不会冲掉任一个
//以 driver(回车字段) 为源调 /Bond/CalcBond;回写时跳过源字段(保留用户手输值)、覆盖另两个。
//错误处理(对齐 bond-oms-ui / zszq-bond-oms):
// - 业务/参数错误(success=false 或 errCode!=0,如债券不存在/信息不全/参数非法)
// 以非阻塞 toast(main.message) 反馈真实原因,绝不回写、不弹模态框、不阻止手工输入;
// - 网络/服务异常:框架 main.post 已弹"请求失败",此处不再二次提示。
calcBondForItem(item) {
if (!item.isBond || !item.bondDriverType) return;
calcBondForItem(item, explicitDriver) {
var driver = explicitDriver || item.bondDriverType;
if (!item.isBond || !driver) return;
item.bondDriverType = driver; // 先定源,回写时 applyBondCalcResult 会跳过该字段
if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; }
var price = item.bondDriverType === 'CP' ? item.PosiNetNoFeePrice
: item.bondDriverType === 'DP' ? item.PosiGrossPrice
var price = driver === 'CP' ? item.PosiNetNoFeePrice
: driver === 'DP' ? item.PosiGrossPrice
: item.InitYtm;
if (price === null || price === '' || isNaN(price)) return; // 编辑中(空/非数)静默,不提示
var priceType = item.bondDriverType === 'CP' ? 'CP' : item.bondDriverType === 'DP' ? 'DP' : 'YD';
var priceType = driver === 'CP' ? 'CP' : driver === 'DP' ? 'DP' : 'YD';
var self = this;
// 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值。
// 该字段在页面上本就是可选择的日期控件(vue-datepicker),用户可改;现已默认即有。
@@ -395,32 +405,33 @@ const vue = new Vue({
targetDate: targetDate
}, { alertFn: main.message }).done(function (resp) {
if (!resp || !resp.obj) return;
// 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不覆盖手工输入
// 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不清空手工输入
var err = SwapCalc.getBondCalcErrorMessage(resp.obj);
if (err) {
// D3 修复:不可算债券上用户逐键手填时,每次按键都触发一次失败计算并弹 toast,会连刷相同提示;
// 同一错误文案连续出现只弹一次(shouldShowBondErr 维护 item._lastBondErr),抑制噪声、不影响任何计算逻辑。
if (SwapCalc.shouldShowBondErr(item, err)) main.message(err);
SwapCalc.applyBondCalcFailure(item, driver); // 交互约定2:失败→清另两字段+标识
return;
}
item._lastBondErr = null; // 成功则清标记,便于下次真出不同错误时仍能提示
// 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回。
// 以既有三字段为代理,调用纯函数(跳过源字段),再写回。
// 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。
// 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100)
// 若直接用存储态初始化,则用户手填字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态,
// 若直接用存储态初始化,则字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态,
// 后续再 ÷100 就会导致该字段被二次缩小(如输入 100 变成 1)。
var proxy = {
cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice),
dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice),
ytm: SwapCalc.bondPriceToCalc(item.InitYtm)
};
var manual = item.bondManual || { CP: false, DP: false, YD: false };
SwapCalc.applyBondCalcResult(proxy, resp.obj, manual);
SwapCalc.applyBondCalcResult(proxy, resp.obj, driver);
// 回写前 bondCalcPriceToStorage(÷100)(展示态→存储态小数):proxy 里均为展示态;
// 模型字段存存储态(0.995),须 ÷100 落回模型,否则配合 percent:true 显示会 ×100 成离谱值。
item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice);
item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice);
item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm);
SwapCalc.applyBondCalcSuccess(item, driver); // 交互约定2:成功→标 源/AUTO
// 名义本金依赖全价(PosiGrossPrice):以净价/收益率为源反算出的全价被回写后,
// 直接赋值不会触发组件 input 事件,需在此显式重算,保持名义本金与全价一致。
if (self.calcNotional) self.calcNotional();
@@ -1517,14 +1528,13 @@ const vue = new Vue({
arr[num].index = num;
arr[num].PosiFeeType = thisObj.normalizePosiFeeType(arr[num].PosiFeeType);
this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice;
// D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失;
// 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格
// 故在加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,直接把三格标为手动锁定,
// 计算器在重开期间不自动推导;用户点"重算"才清除并重新计算。
// 加载(重开/刷新)已保存的债券成交单三字段互算状态标识全部清空,纯展示保存值、无任何 源/AUTO/REV 标记。
// 交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格
// 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)。
var hasV = function (v) { return v !== null && v !== undefined && v !== '' && !isNaN(Number(v)); };
var isBond = val.isBond || tradeHelper.IsBond(val.UnderlyingInstrumentType);
if (isBond && hasV(val.PosiNetNoFeePrice) && hasV(val.PosiGrossPrice) && hasV(val.InitYtm)) {
thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true });
SwapCalc.clearBondCalcMarksOnLoad(arr[num]);
}
});
if (thisObj.paySwapList.length > 0) {
@@ -332,6 +332,7 @@
if (!event) return false;
_chnInput = -1;
this._enterFired = false; // 每次按键先清 Enter 标记;仅在回车分支重新置位,避免误触发
let charCode = event.which || event.keyCode;
@@ -365,6 +366,7 @@
return _options.precision > 0 && !event.shiftKey && checkDotInput.call(this);
case 13: //enter
case 108: //enter
this._enterFired = true; // 标记回车触发,供 __change 区分"回车"与"失焦"
$(this).blur();
return true;
case 67: //ctrl+c
@@ -469,7 +471,9 @@
else if (_options.append === '‱') f /= 10000;
}
_options.onchange && _options.onchange(f, this.value);
let isEnter = !!this._enterFired;
this._enterFired = false;
_options.onchange && _options.onchange(f, this.value, isEnter);
}
//功能禁用
@@ -543,9 +547,10 @@
this.inputctrl.setValue(this.value)
},
methods: {
onchange(value, text) {
onchange(value, text, isEnter) {
this.init = false;
this.$emit('input', this.ret_type === 1 ? text : value);
if (isEnter) this.$emit('enter', this.ret_type === 1 ? text : value);
}
},
watch: {