Files
zszq-trs/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapPricePrecisionHelper.js
T
hjhan 74c434bdd8 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 触发,便于排查价格计算链路
2026-07-31 09:07:08 +08:00

276 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
var swapPricePrecision = (function (global) {
const defaults = Object.freeze({
Stock: { integerDigits: 7, precision: 2 },
StockIndex: { integerDigits: 7, precision: 2 },
StockIF: { integerDigits: 7, precision: 4 },
CommodityFutures: { integerDigits: 7, precision: 4 },
CommoditySpot: { integerDigits: 7, precision: 4 },
NewOtcStock: { integerDigits: 7, precision: 4 },
HKStock: { integerDigits: 7, precision: 4 },
HKStockIndex: { integerDigits: 7, precision: 4 },
Fund: { integerDigits: 7, precision: 4 },
Bond: {
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBonds: {
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
CreditBonds: {
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
OtherBonds: {
grossPrice: { integerDigits: 6, precision: 9 },
netPrice: { integerDigits: 6, precision: 9 },
yield: { integerDigits: 2, precision: 4 }
},
TBFutures: { integerDigits: 8, precision: 4 },
OtherFutures: { integerDigits: 8, precision: 4 },
GoldSpot: { integerDigits: 8, precision: 4 },
OtherSpot: { integerDigits: 8, precision: 4 },
AbroadFutures: { integerDigits: 8, precision: 4 },
AbroadSpot: { integerDigits: 8, precision: 4 },
AbroadStock: { integerDigits: 8, precision: 2 },
AbroadStockIndex: { integerDigits: 8, precision: 4 },
ExRate: { integerDigits: 2, precision: 8 },
Shibor: { integerDigits: 2, precision: 4 },
FixingRepoRate: { integerDigits: 2, precision: 4 }
// TODO: Add InterestYield, BondIndex and GoldFutures after their enum values are confirmed.
});
function normalizeDecimal(value) {
if (value === null || value === undefined || value === '') return '';
let text = String(value).trim();
if (/[eE]/.test(text)) {
const number = Number(text);
if (!Number.isFinite(number)) return null;
text = number.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
}
if (!/^[+-]?(?:\d+|\d*\.\d+)$/.test(text)) return null;
const negative = text.charAt(0) === '-';
text = text.replace(/^[+-]/, '');
const parts = text.split('.');
const integerPart = parts[0].replace(/^0+(?=\d)/, '') || '0';
const decimalPart = parts.length > 1 ? parts[1] : '';
const result = integerPart + (decimalPart ? '.' + decimalPart : '');
return negative && !/^0(?:\.0*)?$/.test(result) ? '-' + result : result;
}
function shiftDecimal(value, places) {
let normalized = normalizeDecimal(value);
if (!normalized || !Number.isInteger(places) || places === 0) return normalized;
const negative = normalized.charAt(0) === '-';
const parts = (negative ? normalized.substring(1) : normalized).split('.');
const integerPart = parts[0];
const decimalPart = parts.length > 1 ? parts[1] : '';
const digits = integerPart + decimalPart;
const decimalIndex = integerPart.length + places;
let text;
if (decimalIndex <= 0) text = '0.' + '0'.repeat(-decimalIndex) + digits;
else if (decimalIndex >= digits.length) text = digits + '0'.repeat(decimalIndex - digits.length);
else text = digits.substring(0, decimalIndex) + '.' + digits.substring(decimalIndex);
return normalizeDecimal((negative ? '-' : '') + text);
}
function incrementDigits(value) {
let carry = 1;
let result = '';
for (let index = value.length - 1; index >= 0; index--) {
const digit = value.charCodeAt(index) - 48 + carry;
if (digit === 10) {
result = '0' + result;
carry = 1;
} else {
result = String(digit) + result;
carry = 0;
}
}
return carry ? '1' + result : result;
}
function roundDecimal(value, precision) {
const normalized = normalizeDecimal(value);
if (normalized === null || !Number.isInteger(precision) || precision < 0) return value;
const negative = normalized.charAt(0) === '-';
const parts = (negative ? normalized.substring(1) : normalized).split('.');
let integerPart = parts[0];
const decimalPart = parts.length > 1 ? parts[1] : '';
if (decimalPart.length <= precision) return normalized;
let digits = integerPart + decimalPart.substring(0, precision);
if (decimalPart.charAt(precision) >= '5') digits = incrementDigits(digits);
if (digits.length <= precision) digits = digits.padStart(precision + 1, '0');
integerPart = precision === 0 ? digits : digits.substring(0, digits.length - precision);
const roundedDecimal = precision === 0 ? '' : digits.substring(digits.length - precision);
return normalizeDecimal((negative ? '-' : '') + integerPart + (roundedDecimal ? '.' + roundedDecimal : ''));
}
function normalizeRule(rule) {
if (!rule || typeof rule !== 'object') return null;
const integerDigits = Number(rule.integerDigits);
const precision = Number(rule.precision);
if (!Number.isInteger(integerDigits) || integerDigits < 1 || integerDigits > 18
|| !Number.isInteger(precision) || precision < 0 || precision > 13) return null;
return { integerDigits: integerDigits, precision: precision };
}
function findRule(source, instrumentType, field) {
const typeRule = source && source[instrumentType];
return typeRule ? normalizeRule(typeRule[field] || typeRule) : null;
}
function getRule(instrumentType, field) {
const fallback = findRule(defaults, instrumentType, field);
if (!fallback) return null;
return findRule(global.main && global.main.swapPricePrecision, instrumentType, field) || fallback;
}
function format(value, instrumentType, field) {
const rule = getRule(instrumentType, field);
if (value === null || value === undefined || value === '') return '';
if (!rule) return global.otcformat.trading.umprice(value);
const rounded = roundDecimal(value, rule.precision);
return rounded === null ? '' : rounded.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
}
function normalizeInput(value, format, shouldRound) {
const options = format || {};
const maxIntegerDigits = Number(options.integerDigits) || 0;
const precision = Number(options.precision) || 0;
const source = String(value === null || value === undefined ? '' : value).trim().replaceAll(',', '').replaceAll('。', '.');
let negative = false;
let hasDot = false;
let integerPart = '';
let decimalPart = '';
for (let index = 0; index < source.length; index++) {
const ch = source.charAt(index);
if (ch >= '0' && ch <= '9') {
if (hasDot) {
if (shouldRound || decimalPart.length < precision) decimalPart += ch;
} else if (!maxIntegerDigits || integerPart.length < maxIntegerDigits) {
integerPart += ch;
}
} else if (ch === '.' && !hasDot && precision > 0) {
hasDot = true;
} else if (ch === '-' && index === 0 && options.negative) {
negative = true;
}
}
if (!integerPart && !decimalPart) return negative ? '-' : '';
const text = (negative ? '-' : '') + (integerPart || '0') + (hasDot ? '.' + decimalPart : '');
if (!shouldRound || text.endsWith('.')) return text;
return roundDecimal(text, precision);
}
function createVueInputComponent() {
return {
props: {
value: { type: [Number, String], default: '' },
format: { type: Object, default: function () { return {}; } },
disabled: { type: Boolean }
},
data: function () {
return { text: '', enterPressed: false, formatSnapshot: '' };
},
mounted: function () {
this.text = this.toDisplay(this.value);
this.formatSnapshot = JSON.stringify(this.format || {});
},
methods: {
toDisplay: function (value) {
if (value === null || value === undefined || value === '') return '';
const displayValue = this.format && this.format.percent ? shiftDecimal(value, 2) : String(value);
return normalizeInput(displayValue, this.format, true);
},
toModel: function (value) {
if (!value || value === '-') return '';
return this.format && this.format.percent ? shiftDecimal(value, -2) : value;
},
updateValue: function (value, shouldRound, shouldCommit) {
this.text = normalizeInput(value, this.format, shouldRound);
if (this.text.endsWith('.') && !shouldRound) return;
if (shouldCommit) this.$emit('input', this.toModel(this.text));
},
onInput: function (event) {
this.updateValue(event.target.value, false, false);
// When the normalized value is unchanged, Vue skips the DOM patch.
// Write it directly so excess digits do not remain in the native input.
event.target.value = this.text;
},
onPaste: function (event) {
const clipboard = event.clipboardData || global.clipboardData;
if (!clipboard) return;
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;
// 回车=用户显式提交意图:无论值是否相对聚焦时变化,都直接派发 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);
event.target.value = this.text;
}
},
watch: {
value: function (value) {
const display = this.toDisplay(value);
if (display !== this.text) this.text = display;
},
format: {
deep: true,
handler: function () {
const snapshot = JSON.stringify(this.format || {});
if (snapshot === this.formatSnapshot) return;
this.formatSnapshot = snapshot;
this.text = this.toDisplay(this.value);
}
}
},
template: '<input type="text" :disabled="disabled" :value="text" @input="onInput" @paste="onPaste" @keydown="onKeydown" @change="onChange">'
};
}
return Object.freeze({
getRule: getRule,
getInputFormat: function (instrumentType, field, options) {
const rule = getRule(instrumentType, field);
return rule ? Object.assign({}, options, rule) : Object.assign({}, options);
},
format: format,
roundForSubmit: function (value, instrumentType, field, storagePrecisionOffset) {
const rule = getRule(instrumentType, field);
if (value === null || value === undefined || value === '' || !rule) return value;
const offset = Number.isInteger(storagePrecisionOffset) ? storagePrecisionOffset : 0;
return roundDecimal(value, rule.precision + offset);
},
shiftDecimal: shiftDecimal,
createVueInputComponent: createVueInputComponent
});
}(window));