@nstc-business/imes 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nstc-business/imes",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
4
4
  "private": false,
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -85,8 +85,9 @@
85
85
 
86
86
  <script>
87
87
  import { InputNumber } from './ui'
88
- import { toFixedTrunc, addThousands } from '../common'
88
+ import { addThousands } from '../common'
89
89
  import { formatScoreText } from './helpers'
90
+ import { formatFixedDecimal } from './ui/numberFormat'
90
91
 
91
92
  export default {
92
93
  name: 'MeasureStatCell',
@@ -120,9 +121,9 @@ export default {
120
121
  originalValue(cell) {
121
122
  const val = cell.indicatorResult
122
123
  if (val === '' || val == null) return ''
123
- let text = Number(val).toFixed(this.digits)
124
+ let text = formatFixedDecimal(val, this.digits)
124
125
  if (cell.showStyle === 2) {
125
- text = addThousands(toFixedTrunc(val, this.digits))
126
+ text = addThousands(text)
126
127
  }
127
128
  const suffix = cell.displayStyle === 1 && (val || val === 0) ? '%' : ''
128
129
  return text + suffix
@@ -144,9 +145,9 @@ export default {
144
145
  const val = cell.value
145
146
  if (val === '' || val == null) return ''
146
147
  if (typeof val === 'object' && val.scoreText) return val.scoreText
147
- let text = val || val === 0 ? Number(val).toFixed(this.digits) : ''
148
+ let text = val || val === 0 ? formatFixedDecimal(val, this.digits) : ''
148
149
  if (cell.showStyle === 2 && text) {
149
- text = addThousands(toFixedTrunc(val, this.digits))
150
+ text = addThousands(text)
150
151
  }
151
152
  const suffix = cell.displayStyle === 1 && (val || val === 0) ? '%' : ''
152
153
  return text + suffix
@@ -11,6 +11,7 @@
11
11
  import { N } from 'n20-common-lib'
12
12
  import { addThousands } from '../common'
13
13
  import { resolveManualFillValue, statItemChildren } from './statModelHelper'
14
+ import { formatFixedDecimal } from './ui/numberFormat'
14
15
 
15
16
  export const ITEM_TYPE = {
16
17
  CALC_MODEL: 11,
@@ -140,8 +141,6 @@ export function sumIndicatorScores(nodes) {
140
141
  items.forEach(n => {
141
142
  const val = nodeScoreValue(n)
142
143
  if (val === null) return
143
- const num = Number(val)
144
- if (Number.isNaN(num)) return
145
144
  has = true
146
145
  total += num
147
146
  })
@@ -237,9 +236,7 @@ export function modelResultDigits(modelWrap) {
237
236
  */
238
237
  export function formatModelResultText(val, modelWrap, options = {}) {
239
238
  if (val === '' || val == null) return ''
240
- const num = Number(val)
241
- if (Number.isNaN(num)) return String(val)
242
- let text = num.toFixed(modelResultDigits(modelWrap))
239
+ let text = formatFixedDecimal(val, modelResultDigits(modelWrap))
243
240
  if (options.thousands) {
244
241
  text = addThousands(text)
245
242
  }
@@ -253,9 +250,7 @@ export function formatModelResultText(val, modelWrap, options = {}) {
253
250
  */
254
251
  export function formatScoreText(val, options = {}) {
255
252
  if (val === '' || val == null) return ''
256
- const num = Number(val)
257
- if (Number.isNaN(num)) return String(val)
258
- let text = num.toFixed(SCORE_DIGITS)
253
+ let text = formatFixedDecimal(val, SCORE_DIGITS)
259
254
  if (options.thousands) {
260
255
  text = addThousands(text)
261
256
  }
@@ -396,7 +391,7 @@ export function indicatorResultText(row, modelWrap) {
396
391
  const suffix =
397
392
  row.displayStyle === 1 && (raw || raw === 0) ? '%' : ''
398
393
  if (raw || raw === 0) {
399
- value = Number(raw).toFixed(digitsCalc(row, modelWrap))
394
+ value = formatFixedDecimal(raw, digitsCalc(row, modelWrap))
400
395
  if (row.showStyle === 2) {
401
396
  value = addThousands(value)
402
397
  }
@@ -1,24 +1,146 @@
1
1
  <template>
2
- <N20InputNumber
3
- ref="inner"
4
- v-bind="$attrs"
5
- v-on="$listeners"
6
- @copy.native="onCopy"
7
- @cut.native="onCopy"
8
- />
2
+ <div
3
+ class="n20-num-w"
4
+ @mouseenter="isHove = true"
5
+ @mouseleave="isHove = false"
6
+ >
7
+ <el-input
8
+ ref="input"
9
+ v-model="valueStr"
10
+ v-bind="$attrs"
11
+ class="n20-stc"
12
+ :disabled="disabled"
13
+ :placeholder="phd"
14
+ :clearable="isClearable"
15
+ :validate-event="false"
16
+ @focus="focusFn"
17
+ @input="inputFn"
18
+ @blur="blurFn"
19
+ @clear="clearFn"
20
+ @change="commit"
21
+ @keydown.native="stepFn"
22
+ @copy.native="onCopy"
23
+ @cut.native="onCopy"
24
+ >
25
+ <template v-if="type === 'rate'">
26
+ <span v-if="suffixV" slot="suffix" class="el-input__icon">{{
27
+ suffix
28
+ }}</span>
29
+ </template>
30
+ </el-input>
31
+ </div>
9
32
  </template>
10
33
 
11
34
  <script>
12
- import { InputNumber as N20InputNumber } from 'n20-common-lib'
13
- import {
14
- resolveNumberClipboardText,
15
- clearInputMaxlength
16
- } from './numberCopy'
35
+ import { formatFixedDecimal } from './numberFormat'
36
+ import { resolveNumberClipboardText, clearInputMaxlength } from './numberCopy'
37
+ import { addDecimalText } from './numberStep'
38
+ import { addThousands } from '../../common'
39
+
40
+ /**
41
+ * 输入过程允许的中间态(去千分位后校验):空、-、.、1、1.、.5、-1.2 等
42
+ */
43
+ const NUM_RE = /^[+-]?(\d*(\.\d*)?|\.\d*)$/
17
44
 
18
45
  export default {
19
46
  name: 'InputNumber',
20
47
  inheritAttrs: false,
21
- components: { N20InputNumber },
48
+ props: {
49
+ value: {
50
+ type: [Number, String],
51
+ default: undefined
52
+ },
53
+ type: {
54
+ type: String,
55
+ default: 'money',
56
+ validator(v) {
57
+ return ['money', 'rate'].includes(v)
58
+ }
59
+ },
60
+ min: {
61
+ type: Number,
62
+ default: -9999999999999.99
63
+ },
64
+ max: {
65
+ type: Number,
66
+ default: 9999999999999.99
67
+ },
68
+ step: {
69
+ type: Number,
70
+ default: 1
71
+ },
72
+ stepStrictly: {
73
+ type: Boolean,
74
+ default: false
75
+ },
76
+ disabled: {
77
+ type: Boolean,
78
+ default: undefined
79
+ },
80
+ isClearable: {
81
+ type: Boolean,
82
+ default: false
83
+ },
84
+ placeholder: {
85
+ type: String,
86
+ default: undefined
87
+ },
88
+ dNum: {
89
+ type: Number,
90
+ default: undefined
91
+ },
92
+ format: {
93
+ type: String,
94
+ default: undefined
95
+ },
96
+ suffix: {
97
+ type: String,
98
+ default: '%'
99
+ },
100
+ rangeAuto: {
101
+ type: Boolean,
102
+ default: false
103
+ }
104
+ },
105
+ data() {
106
+ return {
107
+ valueStr: '',
108
+ isHove: false,
109
+ isFocus: false,
110
+ /** 输入过程/上次提交保留的精确数字文本(去千分位),回写不经过 Number() */
111
+ preciseText: '',
112
+ preValue: ''
113
+ }
114
+ },
115
+ computed: {
116
+ suffixV() {
117
+ if (!this.isClearable) return true
118
+ if (!this.valueStr) return true
119
+ if (!(this.isHove || this.isFocus)) return true
120
+ return false
121
+ },
122
+ phd() {
123
+ return this.placeholder || '请输入'
124
+ },
125
+ fNum() {
126
+ if (this.format) {
127
+ const i = this.format.indexOf('.')
128
+ return i === -1 ? 0 : this.format.length - i - 1
129
+ }
130
+ if (this.dNum || this.dNum === 0) {
131
+ return this.dNum
132
+ }
133
+ return this.type === 'rate' ? 4 : 2
134
+ }
135
+ },
136
+ watch: {
137
+ value: {
138
+ handler() {
139
+ this.renderFromValue()
140
+ },
141
+ immediate: true
142
+ }
143
+ },
22
144
  mounted() {
23
145
  this.clearMaxlength()
24
146
  },
@@ -57,10 +179,127 @@ export default {
57
179
  )
58
180
  : ''
59
181
  const text = resolveNumberClipboardText(input && input.value, selected)
60
- const clip = (e && e.clipboardData) || (e && e.originalEvent && e.originalEvent.clipboardData)
182
+ const clip =
183
+ (e && e.clipboardData) ||
184
+ (e && e.originalEvent && e.originalEvent.clipboardData)
61
185
  if (!text || text === selected || !clip) return
62
186
  e.preventDefault()
63
187
  clip.setData('text/plain', text)
188
+ },
189
+ /**
190
+ * 内部 input 当前输入(去千分位、去空格)
191
+ */
192
+ rawText() {
193
+ const input = this.$el && this.$el.querySelector('input')
194
+ return input
195
+ ? String(input.value || '')
196
+ .replace(/,/g, '')
197
+ .trim()
198
+ : ''
199
+ },
200
+ /**
201
+ * 外部 value 变化 -> 精确格式化展示
202
+ * 字符串入参直接按十进制处理(formatFixedDecimal 不经过 Number),不丢高精度
203
+ */
204
+ renderFromValue() {
205
+ const val = this.value
206
+ if (val === '' || val == null) {
207
+ this.valueStr = ''
208
+ this.preciseText = ''
209
+ return
210
+ }
211
+ const text = formatFixedDecimal(val, this.fNum)
212
+ this.preciseText = text
213
+ this.valueStr = addThousands(text)
214
+ },
215
+ focusFn() {
216
+ this.isFocus = true
217
+ if (!this.disabled && this.valueStr) {
218
+ this.valueStr = this.valueStr.replace(/,/g, '')
219
+ }
220
+ },
221
+ /**
222
+ * 输入过程只做合法性校验,不格式化(保留完整精度)
223
+ */
224
+ inputFn(valStr) {
225
+ const raw = String(valStr || '')
226
+ .replace(/,/g, '')
227
+ .trim()
228
+ if (raw !== '-' && raw !== '' && !NUM_RE.test(raw)) {
229
+ this.valueStr = this.preValue
230
+ } else {
231
+ this.preValue = valStr
232
+ this.preciseText = raw
233
+ }
234
+ },
235
+ blurFn() {
236
+ this.isFocus = false
237
+ this.commit()
238
+ this.$emit('blur')
239
+ },
240
+ /**
241
+ * 失焦提交:按 fNum 精确四舍五入(字符串路径),回写字符串值,不经过 Number()
242
+ */
243
+ commit() {
244
+ if (this.disabled) return
245
+ const raw = this.preciseText
246
+ if (raw === '' || raw == null) {
247
+ this.valueStr = ''
248
+ const oVal = this.value
249
+ if (oVal || oVal === 0) {
250
+ this.$emit('input', undefined)
251
+ this.$emit('change', undefined)
252
+ }
253
+ return
254
+ }
255
+ let text = formatFixedDecimal(raw, this.fNum)
256
+ // min/max 钳制(值域内 Number 比较安全;钳制值用精确字符串格式化)
257
+ const num = Number(text)
258
+ if (num < this.min) {
259
+ text = formatFixedDecimal(this.min, this.fNum)
260
+ } else if (num > this.max) {
261
+ text = formatFixedDecimal(this.max, this.fNum)
262
+ }
263
+ this.preciseText = text
264
+ this.valueStr = addThousands(text)
265
+ const oVal = this.value
266
+ if (oVal !== text) {
267
+ this.$emit('input', text)
268
+ this.$emit('change', text)
269
+ }
270
+ },
271
+ clearFn() {
272
+ this.$emit('clear')
273
+ },
274
+ /**
275
+ * 上下键步进:基于精确字符串做十进制加减,避免 number 累加丢精度
276
+ */
277
+ stepFn(ev) {
278
+ if (
279
+ (ev.code === 'ArrowUp' || ev.code === 'ArrowDown') &&
280
+ !this.disabled
281
+ ) {
282
+ const raw = this.rawText()
283
+ if (raw === '' || raw === '-' || raw === '.') return
284
+ if (!NUM_RE.test(raw)) return
285
+ ev.preventDefault()
286
+ const delta = ev.code === 'ArrowUp' ? this.step : -this.step
287
+ let text = addDecimalText(raw, delta)
288
+ if (this.stepStrictly) {
289
+ const num = Number(text)
290
+ if (num % this.step !== 0) {
291
+ text = String(Math.round(num / this.step) * this.step)
292
+ }
293
+ }
294
+ const num = Number(text)
295
+ if (num < this.min) {
296
+ text = formatFixedDecimal(this.min, this.fNum)
297
+ } else if (num > this.max) {
298
+ text = formatFixedDecimal(this.max, this.fNum)
299
+ }
300
+ this.preciseText = text
301
+ this.valueStr = addThousands(text)
302
+ }
64
303
  }
65
304
  }
66
305
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 测算数字展示格式化
3
+ * 时间:2026-09-14
4
+ * 人员:AI && 徐红飞
5
+ * 入参:val 原始值;digits 小数位数
6
+ * 出参:指定小数位的十进制字符串
7
+ * 方法内容:不用 Number.toFixed。JS 二进制浮点会把 132898923.63 格式化成 132898923.6299999952
8
+ */
9
+
10
+ /**
11
+ * 按指定小数位四舍五入格式化数字
12
+ * 时间:2026-09-14
13
+ * 人员:AI && 徐红飞
14
+ * 入参:val 数字或数字字符串;digits 小数位数
15
+ * 出参:格式化后的字符串;空值返回 '';非数字字符串原样返回
16
+ * 方法内容:先转最短十进制串再按位四舍五入补零,避开原生 toFixed 的精度噪声;
17
+ * 字符串输入直接按十进制字符串处理,不经过 Number()(双精度只有约 15~17 位有效数字,
18
+ * 字符串一旦转 number 高精度小数即被舍入,例如 '160835267.0579509644' 会变成
19
+ * 160835267.05795097,精度无法恢复)
20
+ */
21
+ export function formatFixedDecimal(val, digits) {
22
+ if (val === '' || val == null) return ''
23
+ const raw = typeof val === 'string' ? val.replace(/,/g, '').trim() : val
24
+ if (raw === '') return ''
25
+ const d = digits == null || digits === '' ? 2 : Number(digits)
26
+ if (!Number.isFinite(d) || d < 0) return String(val)
27
+
28
+ // 字符串数字:直接按十进制字符串四舍五入,不经过 Number()
29
+ if (typeof raw === 'string') {
30
+ if (/[eE]/.test(raw)) {
31
+ // 科学计数法字符串(如 '1.6e8'):转 number 后展开,保持原行为
32
+ const n = Number(raw)
33
+ if (Number.isNaN(n)) return String(val)
34
+ return roundPlainDecimal(numberToPlainString(n), d)
35
+ }
36
+ if (!/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(raw)) {
37
+ return String(val) // 非数字字符串原样返回
38
+ }
39
+ return roundPlainDecimal(raw, d)
40
+ }
41
+
42
+ const n = raw
43
+ if (Number.isNaN(n)) return String(val)
44
+ return roundPlainDecimal(numberToPlainString(n), d)
45
+ }
46
+
47
+ /**
48
+ * 把 Number 转成普通十进制字符串(处理科学计数法)
49
+ * 时间:2026-09-14
50
+ * 人员:AI && 徐红飞
51
+ * 入参:n 有限数字
52
+ * 出参:不含 e 的十进制字符串
53
+ * 方法内容:String(n) 遇到科学计数法时按指数展开
54
+ */
55
+ function numberToPlainString(n) {
56
+ const s = String(n)
57
+ if (!/[eE]/.test(s)) return s
58
+ const sign = s.charAt(0) === '-' ? '-' : ''
59
+ const body = sign ? s.slice(1) : s
60
+ const [coeff, expStr] = body.split(/[eE]/)
61
+ let exp = Number(expStr)
62
+ const digits = coeff.replace('.', '')
63
+ const decLen = (coeff.split('.')[1] || '').length
64
+ exp -= decLen
65
+ if (exp >= 0) {
66
+ return sign + digits + '0'.repeat(exp)
67
+ }
68
+ const zeros = Math.abs(exp) - 1
69
+ return sign + '0.' + '0'.repeat(zeros) + digits
70
+ }
71
+
72
+ /**
73
+ * 对普通十进制字符串按小数位四舍五入并补零
74
+ * 时间:2026-09-14
75
+ * 人员:AI && 徐红飞
76
+ * 入参:str 十进制字符串;digits 小数位数
77
+ * 出参:补齐小数位后的字符串
78
+ * 方法内容:用整数进位避免浮点乘法误差
79
+ */
80
+ function roundPlainDecimal(str, digits) {
81
+ const neg = str.charAt(0) === '-'
82
+ if (neg) str = str.slice(1)
83
+ let [intPart, frac = ''] = str.split('.')
84
+ intPart = intPart || '0'
85
+ if (digits === 0) {
86
+ const roundUp = (frac.charAt(0) || '0') >= '5'
87
+ let i = BigInt(intPart)
88
+ if (roundUp) i += 1n
89
+ return (neg && i !== 0n ? '-' : '') + i.toString()
90
+ }
91
+ frac = frac.padEnd(digits + 1, '0')
92
+ const keep = frac.slice(0, digits)
93
+ const next = frac.charAt(digits)
94
+ if (next >= '5') {
95
+ const width = intPart.length + digits
96
+ const asInt = BigInt(intPart + keep) + 1n
97
+ let s = asInt.toString()
98
+ if (s.length < width) s = s.padStart(width, '0')
99
+ intPart = s.slice(0, s.length - digits) || '0'
100
+ frac = s.slice(s.length - digits)
101
+ } else {
102
+ frac = keep
103
+ }
104
+ return (neg ? '-' : '') + intPart + '.' + frac
105
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 数字展示精度:禁止 Number.toFixed 把 132898923.63 显示成 132898923.6299999952
3
+ */
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+
7
+ const src = fs
8
+ .readFileSync(path.join(__dirname, 'numberFormat.js'), 'utf8')
9
+ .replace(/export /g, '')
10
+ const exportsFromSrc = {}
11
+ new Function('exports', src + '\nexports.formatFixedDecimal = formatFixedDecimal')(
12
+ exportsFromSrc
13
+ )
14
+ const { formatFixedDecimal } = exportsFromSrc
15
+
16
+ const nativeBug = (132898923.63).toFixed(10)
17
+ if (nativeBug !== '132898923.6299999952') {
18
+ throw new Error('用例前提变化:原生 toFixed(10) 不再复现 6299999952,实际: ' + nativeBug)
19
+ }
20
+
21
+ const padded = formatFixedDecimal(132898923.63, 10)
22
+ if (padded !== '132898923.6300000000') {
23
+ throw new Error('10 位应补零为 132898923.6300000000,实际: ' + padded)
24
+ }
25
+
26
+ const two = formatFixedDecimal(132898923.63, 2)
27
+ if (two !== '132898923.63') {
28
+ throw new Error('2 位应为 132898923.63,实际: ' + two)
29
+ }
30
+
31
+ const empty = formatFixedDecimal(null, 10)
32
+ if (empty !== '') {
33
+ throw new Error('空值应返回空字符串,实际: ' + empty)
34
+ }
35
+
36
+ const text = formatFixedDecimal('abc', 2)
37
+ if (text !== 'abc') {
38
+ throw new Error('非数字字符串应原样返回,实际: ' + text)
39
+ }
40
+
41
+ const rounded = formatFixedDecimal(1.239, 2)
42
+ if (rounded !== '1.24') {
43
+ throw new Error('四舍五入 1.239 保留 2 位应为 1.24,实际: ' + rounded)
44
+ }
45
+
46
+ // 高精度 number 字面量:双精度只有约 15~17 位有效数字,入参在解析时已舍入为最近可表示值,
47
+ // 格式化结果只能基于该近似值(语言限制,非格式化层可恢复)
48
+ const highPrecNumber = formatFixedDecimal(160835267.0579509644, 10)
49
+ if (highPrecNumber !== '160835267.0579509700') {
50
+ throw new Error('number 入参受双精度限制应为 160835267.0579509700,实际: ' + highPrecNumber)
51
+ }
52
+
53
+ // 高精度数字字符串:不经过 Number(),应完整保留每一位小数
54
+ const highPrecString = formatFixedDecimal('160835267.0579509644', 10)
55
+ if (highPrecString !== '160835267.0579509644') {
56
+ throw new Error('字符串入参应完整保留 160835267.0579509644,实际: ' + highPrecString)
57
+ }
58
+
59
+ // 字符串入参四舍五入进位
60
+ const carry = formatFixedDecimal('9.99999', 4)
61
+ if (carry !== '10.0000') {
62
+ throw new Error('字符串进位 9.99999 保留 4 位应为 10.0000,实际: ' + carry)
63
+ }
64
+
65
+ console.log('numberFormat 用例全部通过')