Appearance
数值展示组件
本节目标:理解“数值展示组件”的核心思路,并能把它用于实际开发或面试表达。 <template> <span class="inline-flex items-baseline"> <span v-if="unitBefore" :style="unitStyle" data-role="unit-before"> {{ unitBefore }} </span> <span data-role="number" :class="numberTextClass"> {{ signPrefix }}{{ formattedNumber }} </span> <span v-if="unitAfter" :style="unitStyle" data-role="unit-after"> {{
${unitAfter}}} </span> </span> </template> <script setup lang="ts"> import { computed } from 'vue' import Decimal from 'decimal.js' type AmountInput = number | string | null | undefined interface Props { amount: AmountInput precision?: number | string separator?: boolean locale?: string emptyPlaceholder?: string unitBefore?: string unitAfter?: string // ✅ 开关:是否按正/负/其它上色 profitColor?: boolean // ✅ 开关:是否给正数加 “+” showSign?: boolean // ✅ 单位独立样式 unitFontSize?: string unitColor?: string // ✅ 可覆盖默认色 positiveClass?: string negativeClass?: string neutralClass?: string } const props = withDefaults(defineProps<Props>(), { precision: 2, separator: true, emptyPlaceholder: '--', profitColor: false, showSign: false, positiveClass: 'text-success-500', negativeClass: 'text-danger', neutralClass: 'text-ink-950', }) function parseAmount(value: AmountInput): number { if (typeof value === 'number') return value if (typeof value !== 'string') return Number.NaN // ✅ 统一清洗:去掉空白与常见千分位(逗号、中文逗号、空格分组等) const cleaned = value.trim().replace(/\s+/g, '').replace(/[,,]/g, '') if (cleaned.length === 0) return Number.NaN // ✅ 白名单校验:整数/小数/科学计数法(允许 + / -) // 例:1,234.56 -> 1234.56; -0.12; 1e-6; +3.14 const isNumeric = /^[+-]?(?:\d+.?\d*|.\d+)(?:e[+-]?\d+)?$/i.test(cleaned) if (!isNumeric) return Number.NaN const n = Number(cleaned) return Number.isFinite(n) ? n : Number.NaN } function safePrecision(value: number | string): number { const n = typeof value === 'number' ? value : Number.parseInt(value, 10) if (!Number.isFinite(n) || n < 0) return 2 // Intl.NumberFormat 实际可支持更大,但这里给个合理上限 return Math.min(n, 20) } function truncateDown(value: number, precision: number): number { if (!Number.isFinite(value)) return value if (!Number.isFinite(precision) || precision < 0) return value return new Decimal(value).toDecimalPlaces(precision, Decimal.ROUND_DOWN).toNumber() } const numberValue = computed<number>(() => parseAmount(props.amount)) const precisionValue = computed<number>(() => safePrecision(props.precision)) const unitStyle = computed<Record<string, string>>(() => { const style: Record<string, string> = {} if (props.unitFontSize) style.fontSize = props.unitFontSize if (props.unitColor) style.color = props.unitColor return style }) const userLocale = computed<string>(() => { if (props.locale) return props.locale // ✅ 兼容 SSR/非浏览器环境 if (typeof navigator !== 'undefined' && navigator.language) return navigator.language return 'en-US' }) const formattedNumber = computed<string>(() => { const n = numberValue.value if (!Number.isFinite(n)) return props.emptyPlaceholder const p = precisionValue.value const truncated = truncateDown(n, p) try { const formatter = new Intl.NumberFormat(userLocale.value, { minimumFractionDigits: p, maximumFractionDigits: p, useGrouping: props.separator, }) return formatter.format(truncated) } catch { // ✅ 极端情况下的兜底 return truncated.toFixed(p) } }) const signPrefix = computed<string>(() => { const n = numberValue.value if (!props.showSign) return '' if (!Number.isFinite(n)) return '' return n > 0 ? '+' : '' }) const numberTextClass = computed<string>(() => { if (!props.profitColor) return '' const n = numberValue.value if (!Number.isFinite(n)) return props.neutralClass if (n > 0) return props.positiveClass if (n < 0) return props.negativeClass return props.neutralClass }) </script>
