@nstc-business/imes 1.0.34 → 1.0.36
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 +1 -1
- package/src/components/measureCalc/MeasureStatCell.vue +6 -5
- package/src/components/measureCalc/helpers.js +4 -3
- package/src/components/measureCalc/ui/InputNumber.vue +67 -0
- package/src/components/measureCalc/ui/index.js +3 -6
- package/src/components/measureCalc/ui/numberCopy.js +38 -0
- package/src/components/measureCalc/ui/numberCopy.test.js +48 -0
- package/src/components/measureCalc/ui/numberFormat.js +90 -0
- package/src/components/measureCalc/ui/numberFormat.test.js +46 -0
package/package.json
CHANGED
|
@@ -85,8 +85,9 @@
|
|
|
85
85
|
|
|
86
86
|
<script>
|
|
87
87
|
import { InputNumber } from './ui'
|
|
88
|
-
import {
|
|
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 =
|
|
124
|
+
let text = formatFixedDecimal(val, this.digits)
|
|
124
125
|
if (cell.showStyle === 2) {
|
|
125
|
-
text = addThousands(
|
|
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 ?
|
|
148
|
+
let text = val || val === 0 ? formatFixedDecimal(val, this.digits) : ''
|
|
148
149
|
if (cell.showStyle === 2 && text) {
|
|
149
|
-
text = addThousands(
|
|
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,
|
|
@@ -239,7 +240,7 @@ export function formatModelResultText(val, modelWrap, options = {}) {
|
|
|
239
240
|
if (val === '' || val == null) return ''
|
|
240
241
|
const num = Number(val)
|
|
241
242
|
if (Number.isNaN(num)) return String(val)
|
|
242
|
-
let text = num
|
|
243
|
+
let text = formatFixedDecimal(num, modelResultDigits(modelWrap))
|
|
243
244
|
if (options.thousands) {
|
|
244
245
|
text = addThousands(text)
|
|
245
246
|
}
|
|
@@ -255,7 +256,7 @@ export function formatScoreText(val, options = {}) {
|
|
|
255
256
|
if (val === '' || val == null) return ''
|
|
256
257
|
const num = Number(val)
|
|
257
258
|
if (Number.isNaN(num)) return String(val)
|
|
258
|
-
let text = num
|
|
259
|
+
let text = formatFixedDecimal(num, SCORE_DIGITS)
|
|
259
260
|
if (options.thousands) {
|
|
260
261
|
text = addThousands(text)
|
|
261
262
|
}
|
|
@@ -396,7 +397,7 @@ export function indicatorResultText(row, modelWrap) {
|
|
|
396
397
|
const suffix =
|
|
397
398
|
row.displayStyle === 1 && (raw || raw === 0) ? '%' : ''
|
|
398
399
|
if (raw || raw === 0) {
|
|
399
|
-
value =
|
|
400
|
+
value = formatFixedDecimal(raw, digitsCalc(row, modelWrap))
|
|
400
401
|
if (row.showStyle === 2) {
|
|
401
402
|
value = addThousands(value)
|
|
402
403
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<N20InputNumber
|
|
3
|
+
ref="inner"
|
|
4
|
+
v-bind="$attrs"
|
|
5
|
+
v-on="$listeners"
|
|
6
|
+
@copy.native="onCopy"
|
|
7
|
+
@cut.native="onCopy"
|
|
8
|
+
/>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script>
|
|
12
|
+
import { InputNumber as N20InputNumber } from 'n20-common-lib'
|
|
13
|
+
import {
|
|
14
|
+
resolveNumberClipboardText,
|
|
15
|
+
clearInputMaxlength
|
|
16
|
+
} from './numberCopy'
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
name: 'InputNumber',
|
|
20
|
+
inheritAttrs: false,
|
|
21
|
+
components: { N20InputNumber },
|
|
22
|
+
mounted() {
|
|
23
|
+
this.clearMaxlength()
|
|
24
|
+
},
|
|
25
|
+
updated() {
|
|
26
|
+
this.clearMaxlength()
|
|
27
|
+
},
|
|
28
|
+
methods: {
|
|
29
|
+
/**
|
|
30
|
+
* 去掉 n20 写死的 maxlength=16
|
|
31
|
+
* 时间:2026-09-14
|
|
32
|
+
* 人员:AI && 徐红飞
|
|
33
|
+
* 入参:无
|
|
34
|
+
* 出参:无
|
|
35
|
+
* 方法内容:带千分位的长数字展示超过 16 位时,原生复制会被截断
|
|
36
|
+
*/
|
|
37
|
+
clearMaxlength() {
|
|
38
|
+
this.$nextTick(() => {
|
|
39
|
+
clearInputMaxlength(this.$el)
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* 复制/剪切时写入完整展示值
|
|
44
|
+
* 时间:2026-09-14
|
|
45
|
+
* 人员:AI && 徐红飞
|
|
46
|
+
* 入参:e 剪贴板事件
|
|
47
|
+
* 出参:无
|
|
48
|
+
* 方法内容:用 input 完整 value 覆盖被 maxlength 截断的选区
|
|
49
|
+
*/
|
|
50
|
+
onCopy(e) {
|
|
51
|
+
const input = this.$el && this.$el.querySelector('input')
|
|
52
|
+
const selected =
|
|
53
|
+
input && typeof input.selectionStart === 'number'
|
|
54
|
+
? String(input.value || '').slice(
|
|
55
|
+
input.selectionStart,
|
|
56
|
+
input.selectionEnd
|
|
57
|
+
)
|
|
58
|
+
: ''
|
|
59
|
+
const text = resolveNumberClipboardText(input && input.value, selected)
|
|
60
|
+
const clip = (e && e.clipboardData) || (e && e.originalEvent && e.originalEvent.clipboardData)
|
|
61
|
+
if (!text || text === selected || !clip) return
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
clip.setData('text/plain', text)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* measureCalc 使用的 n20-common-lib 组件集中出口。
|
|
3
3
|
* 局部注册,不依赖宿主项目的 Vue.use(n20, { prefix }) 前缀。
|
|
4
|
+
* InputNumber 使用本地包装:去掉 maxlength=16,复制完整展示值。
|
|
4
5
|
*/
|
|
5
|
-
export {
|
|
6
|
-
|
|
7
|
-
SecondaryTab,
|
|
8
|
-
Table,
|
|
9
|
-
Descriptions
|
|
10
|
-
} from 'n20-common-lib'
|
|
6
|
+
export { default as InputNumber } from './InputNumber.vue'
|
|
7
|
+
export { SecondaryTab, Table, Descriptions } from 'n20-common-lib'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数字输入框复制文本处理
|
|
3
|
+
* 时间:2026-09-14
|
|
4
|
+
* 人员:AI && 徐红飞
|
|
5
|
+
* 入参:inputValue 输入框完整 value;selectedText 浏览器选区文本
|
|
6
|
+
* 出参:写入剪贴板的完整展示字符串
|
|
7
|
+
* 方法内容:n20 InputNumber 写死 maxlength=16,原生复制会截断带千分位的长数字;选区刚好是 16 位前缀时补回完整展示值
|
|
8
|
+
*/
|
|
9
|
+
export function resolveNumberClipboardText(inputValue, selectedText) {
|
|
10
|
+
const full = inputValue == null ? '' : String(inputValue)
|
|
11
|
+
const selected = selectedText || ''
|
|
12
|
+
if (!full) return selected
|
|
13
|
+
if (!selected || selected === full) return full
|
|
14
|
+
// maxlength=16 截断选区:选中的是完整值前缀且刚好 16 位时,补回完整展示值
|
|
15
|
+
if (selected.length === 16 && full.startsWith(selected) && full.length > 16) {
|
|
16
|
+
return full
|
|
17
|
+
}
|
|
18
|
+
return selected
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 去掉原生 input 的 maxlength,避免选区/复制被截到 16 位
|
|
23
|
+
* 时间:2026-09-14
|
|
24
|
+
* 人员:AI && 徐红飞
|
|
25
|
+
* 入参:rootEl 组件根节点
|
|
26
|
+
* 出参:找到的 input 元素,没有则 null
|
|
27
|
+
* 方法内容:查询内部 input 并移除 maxlength
|
|
28
|
+
*/
|
|
29
|
+
export function clearInputMaxlength(rootEl) {
|
|
30
|
+
if (!rootEl || typeof rootEl.querySelector !== 'function') {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
const input = rootEl.querySelector('input')
|
|
34
|
+
if (input) {
|
|
35
|
+
input.removeAttribute('maxlength')
|
|
36
|
+
}
|
|
37
|
+
return input
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数字输入框复制回归:maxlength=16 会把千分位数字截成 16 个字符
|
|
3
|
+
*/
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
|
|
7
|
+
const src = fs
|
|
8
|
+
.readFileSync(path.join(__dirname, 'numberCopy.js'), 'utf8')
|
|
9
|
+
.replace(/export /g, '')
|
|
10
|
+
const exportsFromSrc = {}
|
|
11
|
+
new Function('exports', src + '\nexports.resolveNumberClipboardText = resolveNumberClipboardText\nexports.clearInputMaxlength = clearInputMaxlength')(exportsFromSrc)
|
|
12
|
+
const { resolveNumberClipboardText, clearInputMaxlength } = exportsFromSrc
|
|
13
|
+
|
|
14
|
+
const shown = '132,898,923.6299999952'
|
|
15
|
+
const truncatedByMaxlength = shown.slice(0, 16)
|
|
16
|
+
|
|
17
|
+
if (truncatedByMaxlength !== '132,898,923.6299') {
|
|
18
|
+
throw new Error('用例前提变化:16 位截断结果不是 132,898,923.6299')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const copied = resolveNumberClipboardText(shown, truncatedByMaxlength)
|
|
22
|
+
if (copied !== shown) {
|
|
23
|
+
throw new Error('应复制完整展示值,实际: ' + copied)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const partial = resolveNumberClipboardText(shown, '132,898')
|
|
27
|
+
if (partial !== '132,898') {
|
|
28
|
+
throw new Error('部分选区应保持原样,实际: ' + partial)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const emptyFallback = resolveNumberClipboardText('', '132,898,923.6299')
|
|
32
|
+
if (emptyFallback !== '132,898,923.6299') {
|
|
33
|
+
throw new Error('完整值为空时应回退选区,实际: ' + emptyFallback)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fakeInput = { removeAttribute(name) { this[name] = null } }
|
|
37
|
+
const fakeRoot = { querySelector() { return fakeInput } }
|
|
38
|
+
if (clearInputMaxlength(fakeRoot) !== fakeInput) {
|
|
39
|
+
throw new Error('应返回内部 input')
|
|
40
|
+
}
|
|
41
|
+
if (fakeInput.maxlength !== null) {
|
|
42
|
+
throw new Error('应移除 maxlength')
|
|
43
|
+
}
|
|
44
|
+
if (clearInputMaxlength(null) !== null) {
|
|
45
|
+
throw new Error('无根节点时应返回 null')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log('numberCopy.test.js passed')
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
*/
|
|
18
|
+
export function formatFixedDecimal(val, digits) {
|
|
19
|
+
if (val === '' || val == null) return ''
|
|
20
|
+
const raw = typeof val === 'string' ? val.replace(/,/g, '').trim() : val
|
|
21
|
+
if (raw === '') return ''
|
|
22
|
+
if (typeof raw === 'string' && Number.isNaN(Number(raw))) {
|
|
23
|
+
return String(val)
|
|
24
|
+
}
|
|
25
|
+
const n = typeof raw === 'number' ? raw : Number(raw)
|
|
26
|
+
if (Number.isNaN(n)) return String(val)
|
|
27
|
+
const d = digits == null || digits === '' ? 2 : Number(digits)
|
|
28
|
+
if (!Number.isFinite(d) || d < 0) return String(n)
|
|
29
|
+
return roundPlainDecimal(numberToPlainString(n), d)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把 Number 转成普通十进制字符串(处理科学计数法)
|
|
34
|
+
* 时间:2026-09-14
|
|
35
|
+
* 人员:AI && 徐红飞
|
|
36
|
+
* 入参:n 有限数字
|
|
37
|
+
* 出参:不含 e 的十进制字符串
|
|
38
|
+
* 方法内容:String(n) 遇到科学计数法时按指数展开
|
|
39
|
+
*/
|
|
40
|
+
function numberToPlainString(n) {
|
|
41
|
+
const s = String(n)
|
|
42
|
+
if (!/[eE]/.test(s)) return s
|
|
43
|
+
const sign = s.charAt(0) === '-' ? '-' : ''
|
|
44
|
+
const body = sign ? s.slice(1) : s
|
|
45
|
+
const [coeff, expStr] = body.split(/[eE]/)
|
|
46
|
+
let exp = Number(expStr)
|
|
47
|
+
const digits = coeff.replace('.', '')
|
|
48
|
+
const decLen = (coeff.split('.')[1] || '').length
|
|
49
|
+
exp -= decLen
|
|
50
|
+
if (exp >= 0) {
|
|
51
|
+
return sign + digits + '0'.repeat(exp)
|
|
52
|
+
}
|
|
53
|
+
const zeros = Math.abs(exp) - 1
|
|
54
|
+
return sign + '0.' + '0'.repeat(zeros) + digits
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 对普通十进制字符串按小数位四舍五入并补零
|
|
59
|
+
* 时间:2026-09-14
|
|
60
|
+
* 人员:AI && 徐红飞
|
|
61
|
+
* 入参:str 十进制字符串;digits 小数位数
|
|
62
|
+
* 出参:补齐小数位后的字符串
|
|
63
|
+
* 方法内容:用整数进位避免浮点乘法误差
|
|
64
|
+
*/
|
|
65
|
+
function roundPlainDecimal(str, digits) {
|
|
66
|
+
const neg = str.charAt(0) === '-'
|
|
67
|
+
if (neg) str = str.slice(1)
|
|
68
|
+
let [intPart, frac = ''] = str.split('.')
|
|
69
|
+
intPart = intPart || '0'
|
|
70
|
+
if (digits === 0) {
|
|
71
|
+
const roundUp = (frac.charAt(0) || '0') >= '5'
|
|
72
|
+
let i = BigInt(intPart)
|
|
73
|
+
if (roundUp) i += 1n
|
|
74
|
+
return (neg && i !== 0n ? '-' : '') + i.toString()
|
|
75
|
+
}
|
|
76
|
+
frac = frac.padEnd(digits + 1, '0')
|
|
77
|
+
const keep = frac.slice(0, digits)
|
|
78
|
+
const next = frac.charAt(digits)
|
|
79
|
+
if (next >= '5') {
|
|
80
|
+
const width = intPart.length + digits
|
|
81
|
+
const asInt = BigInt(intPart + keep) + 1n
|
|
82
|
+
let s = asInt.toString()
|
|
83
|
+
if (s.length < width) s = s.padStart(width, '0')
|
|
84
|
+
intPart = s.slice(0, s.length - digits) || '0'
|
|
85
|
+
frac = s.slice(s.length - digits)
|
|
86
|
+
} else {
|
|
87
|
+
frac = keep
|
|
88
|
+
}
|
|
89
|
+
return (neg ? '-' : '') + intPart + '.' + frac
|
|
90
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
console.log('numberFormat.test.js passed')
|