minitest2.0 0.0.5 → 0.0.6

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": "minitest2.0",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "repository": {
@@ -44,6 +44,7 @@ const ALLOWED_TAGS = new Set([
44
44
  'H2',
45
45
  'H3',
46
46
  'I',
47
+ 'IMG',
47
48
  'LI',
48
49
  'OL',
49
50
  'P',
@@ -56,6 +57,22 @@ const ALLOWED_TAGS = new Set([
56
57
  'CODE',
57
58
  ])
58
59
 
60
+ const ALLOWED_IMAGE_MIME_TYPES = new Set([
61
+ 'image/png',
62
+ 'image/jpeg',
63
+ 'image/gif',
64
+ 'image/webp',
65
+ 'image/bmp',
66
+ ])
67
+
68
+ const BASE64_IMAGE_SIGNATURES = [
69
+ { mime: 'image/png', pattern: /^iVBORw0KGgo/ },
70
+ { mime: 'image/jpeg', pattern: /^\/9j\// },
71
+ { mime: 'image/gif', pattern: /^R0lGOD/ },
72
+ { mime: 'image/webp', pattern: /^UklGR/ },
73
+ { mime: 'image/bmp', pattern: /^Qk/ },
74
+ ]
75
+
59
76
  export async function queryArticles() {
60
77
  const response = await request<unknown>({
61
78
  url: LIST_URL,
@@ -334,6 +351,11 @@ function sanitizeAttributes(element: HTMLElement) {
334
351
  Array.from(element.attributes).forEach((attribute) => {
335
352
  const name = attribute.name.toLowerCase()
336
353
 
354
+ if (element.tagName === 'IMG') {
355
+ sanitizeImageAttribute(element, attribute)
356
+ return
357
+ }
358
+
337
359
  if (element.tagName === 'A' && name === 'href') {
338
360
  if (!isSafeHref(attribute.value)) {
339
361
  element.removeAttribute(attribute.name)
@@ -369,6 +391,137 @@ function sanitizeAttributes(element: HTMLElement) {
369
391
 
370
392
  element.removeAttribute(attribute.name)
371
393
  })
394
+
395
+ if (element.tagName === 'IMG') {
396
+ finalizeImageElement(element)
397
+ }
398
+ }
399
+
400
+ function sanitizeImageAttribute(element: HTMLElement, attribute: Attr) {
401
+ const name = attribute.name.toLowerCase()
402
+
403
+ if (name === 'src') {
404
+ const src = normalizeImageSource(attribute.value)
405
+
406
+ if (src) {
407
+ element.setAttribute('src', src)
408
+ return
409
+ }
410
+ }
411
+
412
+ if (name === 'alt' || name === 'title') {
413
+ element.setAttribute(name, attribute.value.trim().slice(0, 120))
414
+ return
415
+ }
416
+
417
+ if (name === 'width' || name === 'height') {
418
+ const dimension = normalizeImageDimension(attribute.value)
419
+
420
+ if (dimension) {
421
+ element.setAttribute(name, dimension)
422
+ return
423
+ }
424
+ }
425
+
426
+ element.removeAttribute(attribute.name)
427
+ }
428
+
429
+ function finalizeImageElement(element: HTMLElement) {
430
+ if (!element.getAttribute('src')) {
431
+ element.remove()
432
+ return
433
+ }
434
+
435
+ if (!element.hasAttribute('alt')) {
436
+ element.setAttribute('alt', '')
437
+ }
438
+
439
+ element.setAttribute('loading', 'lazy')
440
+ element.setAttribute('decoding', 'async')
441
+ }
442
+
443
+ function normalizeImageSource(value: string) {
444
+ const source = value.trim()
445
+
446
+ if (!source) {
447
+ return ''
448
+ }
449
+
450
+ const dataImageSource = normalizeDataImageSource(source)
451
+
452
+ if (dataImageSource) {
453
+ return dataImageSource
454
+ }
455
+
456
+ const bareBase64Source = normalizeBareBase64ImageSource(source)
457
+
458
+ if (bareBase64Source) {
459
+ return bareBase64Source
460
+ }
461
+
462
+ try {
463
+ const url = new URL(source, window.location.origin)
464
+
465
+ return ['http:', 'https:'].includes(url.protocol) ? source : ''
466
+ } catch {
467
+ return ''
468
+ }
469
+ }
470
+
471
+ function normalizeDataImageSource(value: string) {
472
+ const match = value.match(/^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i)
473
+
474
+ if (!match) {
475
+ return ''
476
+ }
477
+
478
+ const mime = normalizeImageMimeType(match[1] || '')
479
+ const base64 = normalizeBase64Payload(match[2] || '')
480
+
481
+ if (!mime || !isBase64Payload(base64)) {
482
+ return ''
483
+ }
484
+
485
+ return `data:${mime};base64,${base64}`
486
+ }
487
+
488
+ function normalizeBareBase64ImageSource(value: string) {
489
+ const base64 = normalizeBase64Payload(value)
490
+ const mime = getBase64ImageMimeType(base64)
491
+
492
+ if (!mime || !isBase64Payload(base64)) {
493
+ return ''
494
+ }
495
+
496
+ return `data:${mime};base64,${base64}`
497
+ }
498
+
499
+ function normalizeImageMimeType(value: string) {
500
+ const mime = value.trim().toLowerCase().replace(/^image\/jpg$/, 'image/jpeg')
501
+
502
+ return ALLOWED_IMAGE_MIME_TYPES.has(mime) ? mime : ''
503
+ }
504
+
505
+ function normalizeBase64Payload(value: string) {
506
+ return value.replace(/\s+/g, '')
507
+ }
508
+
509
+ function isBase64Payload(value: string) {
510
+ return Boolean(value) && value.length % 4 !== 1 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
511
+ }
512
+
513
+ function getBase64ImageMimeType(value: string) {
514
+ return BASE64_IMAGE_SIGNATURES.find(({ pattern }) => pattern.test(value))?.mime || ''
515
+ }
516
+
517
+ function normalizeImageDimension(value: string) {
518
+ const dimension = Number.parseInt(value, 10)
519
+
520
+ if (!Number.isFinite(dimension) || dimension <= 0 || dimension > 4096) {
521
+ return ''
522
+ }
523
+
524
+ return String(dimension)
372
525
  }
373
526
 
374
527
  function normalizeCodeLanguageClass(value: string) {
@@ -0,0 +1,142 @@
1
+ import {
2
+ buildTamRequestPayload,
3
+ tamRequest,
4
+ type TamRequestPayload,
5
+ type TamResponse,
6
+ } from '@/api/tam'
7
+ import { useAppStore } from '@/stores/app'
8
+
9
+ export type ForeignForecastRecord = {
10
+ /** 业务类型。 */
11
+ busiType?: string
12
+ /** 币种字段,不同后端版本可能返回不同命名。 */
13
+ ccy?: string
14
+ currency?: string
15
+ currencyCode?: string
16
+ currCode?: string
17
+ curType?: string
18
+ tranCcy?: string
19
+ /** 客户类型或客户名称,用于列表副标题兜底。 */
20
+ clientName?: string
21
+ clientType?: string
22
+ competName?: string
23
+ /** 交易对手或清算行信息。 */
24
+ bankSwift?: string
25
+ bicCode?: string
26
+ routeCode?: string
27
+ swiftCode?: string
28
+ /** 落地机构,列表主标题展示字段。 */
29
+ groundBranch?: string
30
+ /** 预报金额,接口按元级金额返回,页面转换为万元级展示。 */
31
+ predAmt?: string
32
+ /** 预报流水号,后续详情查询主键。 */
33
+ predRefNo?: string
34
+ /** 预报状态。 */
35
+ predStatus?: string
36
+ /** 交易日期。 */
37
+ tranDate?: string
38
+ /** 核销状态。 */
39
+ verifyStatus?: string
40
+ }
41
+
42
+ export type ForeignForecastQueryForm = {
43
+ /** 交易时间,当前视图必填筛选项。 */
44
+ tranDate: string
45
+ /** 落地机构关键字。 */
46
+ groundBranch?: string
47
+ /** 币种,当前后端交易未明确入参时页面做兼容筛选。 */
48
+ currency?: string
49
+ /** 业务类型。 */
50
+ busiType?: string
51
+ /** 预报状态。 */
52
+ predStatus?: string
53
+ /** 起始金额,页面单位为万元。 */
54
+ startTranAmt?: string
55
+ /** 截止金额,页面单位为万元。 */
56
+ endTranAmt?: string
57
+ }
58
+
59
+ export type ForeignForecastQueryPayload = {
60
+ appHead: TamRequestPayload<ForeignForecastQueryBody>['appHead']
61
+ body: ForeignForecastQueryBody
62
+ head: TamRequestPayload<ForeignForecastQueryBody>['head']
63
+ }
64
+
65
+ export type ForeignForecastQueryResult = {
66
+ body: ForeignForecastQueryResultBody
67
+ head: TamResponse<ForeignForecastQueryResultBody>['head']
68
+ }
69
+
70
+ type ForeignForecastQueryBody = {
71
+ busiType: string
72
+ clientName: string
73
+ competName: string
74
+ endTransAmt: string
75
+ groundBranch: string
76
+ payChannel: string
77
+ payDirec: string
78
+ predStatus: string
79
+ sortRule: string
80
+ startTransAmt: string
81
+ tranDate: string
82
+ verifyStatus: string
83
+ }
84
+
85
+ type ForeignForecastQueryResultBody = {
86
+ resultList: ForeignForecastRecord[]
87
+ totalCount: string
88
+ }
89
+
90
+ type QueryOptions = {
91
+ form: ForeignForecastQueryForm
92
+ pageIndex: number
93
+ pageSize: number
94
+ }
95
+
96
+ const QUERY_TRAN_CODE = '/TAM/0001/140101001'
97
+
98
+ export function buildForeignForecastQueryPayload({
99
+ form,
100
+ pageIndex,
101
+ pageSize,
102
+ }: QueryOptions): ForeignForecastQueryPayload {
103
+ const tranDate = normalizeDate(form.tranDate || useAppStore().systemWorkDate)
104
+
105
+ return buildTamRequestPayload({
106
+ tranCode: QUERY_TRAN_CODE,
107
+ pageIndex,
108
+ pageSize,
109
+ body: {
110
+ busiType: form.busiType || '',
111
+ clientName: '',
112
+ competName: '',
113
+ endTransAmt: toRequestAmount(form.endTranAmt),
114
+ groundBranch: form.groundBranch || '',
115
+ payChannel: '',
116
+ payDirec: '',
117
+ predStatus: form.predStatus || '',
118
+ sortRule: '',
119
+ startTransAmt: toRequestAmount(form.startTranAmt),
120
+ tranDate,
121
+ verifyStatus: '',
122
+ },
123
+ })
124
+ }
125
+
126
+ export async function queryForeignForecastPage(
127
+ payload: ForeignForecastQueryPayload,
128
+ ): Promise<ForeignForecastQueryResult> {
129
+ return tamRequest<ForeignForecastQueryBody, ForeignForecastQueryResultBody>(payload)
130
+ }
131
+
132
+ function normalizeDate(value: string) {
133
+ if (/^\d{8}$/.test(value)) return value
134
+ return value.replaceAll('-', '')
135
+ }
136
+
137
+ function toRequestAmount(value?: string) {
138
+ if (!value?.trim()) return ''
139
+
140
+ const amount = Number(value)
141
+ return Number.isFinite(amount) ? String(Math.round(amount * 10000)) : ''
142
+ }
@@ -87,7 +87,7 @@ const router = createRouter({
87
87
  path: '/warning',
88
88
  name: 'Warning',
89
89
  component: () => import('@/views/warning/index.vue'),
90
- meta: { requiresAuth: true, title: '预警信息' },
90
+ meta: { requiresAuth: true, title: '头寸管理平台' },
91
91
  },
92
92
  {
93
93
  path: '/dashboard',
@@ -20,7 +20,7 @@ export const useUserStore = defineStore(
20
20
  // -------------------------------- State --------------------------------
21
21
 
22
22
  /** 登录凭证,登录成功后由后端返回,后续请求通过请求头携带 */
23
- const token = ref('1s')
23
+ const token = ref('')
24
24
  const refreshToken = ref('')
25
25
  const username = ref('')
26
26
  const realName = ref('')
@@ -0,0 +1,102 @@
1
+ export type ForeignCurrencyOption = {
2
+ label: string
3
+ shortLabel: string
4
+ value: string
5
+ unit: string
6
+ }
7
+
8
+ export type ForeignOption = {
9
+ label: string
10
+ value: string
11
+ }
12
+
13
+ export type ForeignStatusOption = ForeignOption & {
14
+ className: string
15
+ }
16
+
17
+ export const foreignCurrencyOptions: ForeignCurrencyOption[] = [
18
+ { label: '全部币种', shortLabel: '全部币种', value: '', unit: '万元' },
19
+ { label: '美元', shortLabel: '美元', value: 'USD', unit: '万美元' },
20
+ { label: '欧元', shortLabel: '欧元', value: 'EUR', unit: '万欧元' },
21
+ { label: '港币', shortLabel: '港币', value: 'HKD', unit: '万港币' },
22
+ { label: '日元', shortLabel: '日元', value: 'JPY', unit: '万日元' },
23
+ { label: '英镑', shortLabel: '英镑', value: 'GBP', unit: '万英镑' },
24
+ { label: '加拿大元', shortLabel: '加元', value: 'CAD', unit: '万加元' },
25
+ { label: '澳大利亚元', shortLabel: '澳元', value: 'AUD', unit: '万澳元' },
26
+ { label: '新加坡元', shortLabel: '新元', value: 'SGD', unit: '万新元' },
27
+ { label: '瑞士法郎', shortLabel: '瑞郎', value: 'CHF', unit: '万瑞郎' },
28
+ ]
29
+
30
+ export const foreignBusinessOptions: ForeignOption[] = [
31
+ { label: '全部业务类型', value: '' },
32
+ { label: '普通汇款', value: '1' },
33
+ { label: '外汇买卖', value: '2' },
34
+ { label: '结售汇', value: '3' },
35
+ { label: '资金调拨', value: '4' },
36
+ { label: '同业业务', value: '5' },
37
+ { label: '贸易融资', value: '6' },
38
+ ]
39
+
40
+ export const foreignStatusOptions: ForeignStatusOption[] = [
41
+ { label: '全部预报状态', value: '', className: 'status-tag-unknown' },
42
+ { label: '已确认', value: '1', className: 'status-tag-confirmed' },
43
+ { label: '待确认', value: '2', className: 'status-tag-pending' },
44
+ { label: '已撤销', value: '3', className: 'status-tag-cancelled' },
45
+ { label: '已核销', value: '4', className: 'status-tag-verified' },
46
+ { label: '已驳回', value: '5', className: 'status-tag-rejected' },
47
+ ]
48
+
49
+ export const unknownForeignStatus: ForeignStatusOption = {
50
+ label: '未知状态',
51
+ value: '',
52
+ className: 'status-tag-unknown',
53
+ }
54
+
55
+ export function resolveForeignCurrency(value?: string) {
56
+ const normalizedValue = value?.trim().toUpperCase()
57
+
58
+ if (!normalizedValue) {
59
+ return foreignCurrencyOptions[0]
60
+ }
61
+
62
+ return (
63
+ foreignCurrencyOptions.find((item) => item.value === normalizedValue) || {
64
+ label: normalizedValue,
65
+ shortLabel: normalizedValue,
66
+ value: normalizedValue,
67
+ unit: `万${normalizedValue}`,
68
+ }
69
+ )
70
+ }
71
+
72
+ export function resolveForeignBusinessType(value?: string) {
73
+ if (!value?.trim()) return '普通汇款'
74
+
75
+ return foreignBusinessOptions.find((item) => item.value === value)?.label || value
76
+ }
77
+
78
+ export function resolveForeignStatus(value?: string) {
79
+ if (!value?.trim()) {
80
+ return unknownForeignStatus
81
+ }
82
+
83
+ return foreignStatusOptions.find((item) => item.value === value) || unknownForeignStatus
84
+ }
85
+
86
+ export function formatForeignForecastDate(value?: string) {
87
+ if (value && /^\d{8}$/.test(value)) {
88
+ return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
89
+ }
90
+
91
+ return value || '-'
92
+ }
93
+
94
+ export function toTenThousandAmount(value?: string) {
95
+ const amount = Number(value?.replaceAll(',', '') || 0)
96
+ return Number.isFinite(amount) ? amount / 10000 : 0
97
+ }
98
+
99
+ export function formatForeignForecastAmount(value: number) {
100
+ const sign = value >= 0 ? '+' : '-'
101
+ return `${sign}${Math.abs(value).toFixed(2)}`
102
+ }
@@ -219,6 +219,15 @@ function articleContentToText(html: string) {
219
219
  text-decoration: none;
220
220
  }
221
221
 
222
+ .article-content :deep(img) {
223
+ display: block;
224
+ max-width: 100%;
225
+ height: auto;
226
+ margin: 8px auto 14px;
227
+ border-radius: 6px;
228
+ object-fit: contain;
229
+ }
230
+
222
231
  .article-content :deep(pre) {
223
232
  position: relative;
224
233
  overflow-x: auto;
@@ -514,6 +514,15 @@ function normalizeCodeLanguage(value: string) {
514
514
  background: #f6f7f9;
515
515
  }
516
516
 
517
+ .rich-editor :deep(img) {
518
+ display: block;
519
+ max-width: 100%;
520
+ height: auto;
521
+ margin: 8px auto 12px;
522
+ border-radius: 6px;
523
+ object-fit: contain;
524
+ }
525
+
517
526
  .rich-editor :deep(pre) {
518
527
  overflow-x: auto;
519
528
  margin: 0 0 12px;
@@ -14,22 +14,46 @@ type ResultItem = {
14
14
  icon: string
15
15
  }
16
16
 
17
+ type AmountUnitKey = 'hundredMillion' | 'tenMillion' | 'million' | 'tenThousand' | 'yuan'
18
+
19
+ type AmountUnit = {
20
+ key: AmountUnitKey
21
+ label: string
22
+ divisor: bigint
23
+ }
24
+
17
25
  const estimateDetail = ref<OpeningReserveEstimateBody | null>(null)
18
26
  const querying = ref(false)
27
+ const selectedUnitKey = ref<AmountUnitKey>('yuan')
28
+
29
+ const defaultAmountUnit: AmountUnit = { key: 'yuan', label: '元', divisor: 1n }
30
+
31
+ const amountUnits: AmountUnit[] = [
32
+ { key: 'hundredMillion', label: '亿元', divisor: 100000000n },
33
+ { key: 'tenMillion', label: '千万元', divisor: 10000000n },
34
+ { key: 'million', label: '百万元', divisor: 1000000n },
35
+ { key: 'tenThousand', label: '万元', divisor: 10000n },
36
+ defaultAmountUnit,
37
+ ]
19
38
 
20
39
  const resultConfig: ResultItem[] = [
21
- { key: 'rhAmt', label: '人行时点头寸(元)', icon: 'balance-o' },
22
- { key: 'unverifyAmtOut', label: '预报未核销支出(元)', icon: 'card' },
23
- { key: 'unverifyAmtIn', label: '预报未核销收入(元)', icon: 'gold-coin-o' },
24
- { key: 'pdAmt', label: '总分行铺底资金之和(元)', icon: 'paid' },
25
- { key: 'fzAmt', label: '法定准备金(元)', icon: 'shield-o' },
26
- { key: 'preAmt', label: '日初备款(元)', icon: 'chart-trending-o' },
40
+ { key: 'rhAmt', label: '人行时点头寸', icon: 'balance-o' },
41
+ { key: 'unverifyAmtOut', label: '预报未核销支出', icon: 'card' },
42
+ { key: 'unverifyAmtIn', label: '预报未核销收入', icon: 'gold-coin-o' },
43
+ { key: 'pdAmt', label: '总分行铺底资金之和', icon: 'paid' },
44
+ { key: 'fzAmt', label: '法定准备金', icon: 'shield-o' },
45
+ { key: 'preAmt', label: '日初备款', icon: 'chart-trending-o' },
27
46
  ]
28
47
 
48
+ const selectedUnit = computed(
49
+ () => amountUnits.find((unit) => unit.key === selectedUnitKey.value) || defaultAmountUnit,
50
+ )
51
+
29
52
  const resultItems = computed(() =>
30
53
  resultConfig.map((item) => ({
31
54
  ...item,
32
- value: formatYuanAmount(estimateDetail.value?.[item.key]),
55
+ label: `${item.label}(${selectedUnit.value.label})`,
56
+ value: formatAmountByUnit(estimateDetail.value?.[item.key], selectedUnit.value),
33
57
  })),
34
58
  )
35
59
 
@@ -62,64 +86,65 @@ async function handleQuery(options: { showSuccessToast?: boolean } = {}) {
62
86
  }
63
87
  }
64
88
 
65
- function formatYuanAmount(value?: string) {
89
+ function formatAmountByUnit(value: string | undefined, unit: AmountUnit) {
90
+ const cents = parseYuanToCents(value)
91
+
92
+ if (cents === null) return '-'
93
+
94
+ return formatMinorAmount(divideRounded(cents, unit.divisor))
95
+ }
96
+
97
+ function parseYuanToCents(value?: string) {
66
98
  const text = value?.trim()
67
99
 
68
- if (!text) return '-'
100
+ if (!text) return null
69
101
 
70
102
  const normalizedText = text.replaceAll(',', '')
71
103
 
72
104
  if (!/^[-+]?\d+(\.\d+)?$/.test(normalizedText)) {
73
- return '-'
105
+ return null
74
106
  }
75
107
 
76
- const sign = normalizedText.startsWith('-') ? '-' : ''
108
+ const isNegative = normalizedText.startsWith('-')
77
109
  const unsignedText =
78
110
  normalizedText.startsWith('-') || normalizedText.startsWith('+')
79
111
  ? normalizedText.slice(1)
80
112
  : normalizedText
81
113
  const [rawInteger = '0', rawDecimal = ''] = unsignedText.split('.')
82
- let integer = rawInteger.replace(/^0+(?=\d)/, '') || '0'
83
- let decimal = `${rawDecimal}00`.slice(0, 2)
84
-
85
- if (Number(rawDecimal[2] || '0') >= 5) {
86
- const roundedAmount = addOneCent(integer, decimal)
87
- integer = roundedAmount.integer
88
- decimal = roundedAmount.decimal
114
+ const integer = rawInteger.replace(/^0+(?=\d)/, '') || '0'
115
+ const decimalText = `${rawDecimal}000`
116
+ const decimal = decimalText.slice(0, 2)
117
+ const needsRound = Number(decimalText[2]) >= 5
118
+ let cents = BigInt(integer) * 100n + BigInt(decimal)
119
+
120
+ if (needsRound) {
121
+ cents += 1n
89
122
  }
90
123
 
91
- return `${sign}${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${decimal}`
124
+ return isNegative ? -cents : cents
92
125
  }
93
126
 
94
- function addOneCent(integer: string, decimal: string) {
95
- const centAmount = Number(decimal) + 1
127
+ function divideRounded(value: bigint, divisor: bigint) {
128
+ const isNegative = value < 0n
129
+ const absoluteValue = isNegative ? -value : value
130
+ let quotient = absoluteValue / divisor
131
+ const remainder = absoluteValue % divisor
96
132
 
97
- if (centAmount < 100) {
98
- return {
99
- integer,
100
- decimal: String(centAmount).padStart(2, '0'),
101
- }
133
+ if (remainder * 2n >= divisor) {
134
+ quotient += 1n
102
135
  }
103
136
 
104
- return {
105
- integer: incrementIntegerText(integer),
106
- decimal: '00',
107
- }
137
+ return isNegative ? -quotient : quotient
108
138
  }
109
139
 
110
- function incrementIntegerText(value: string) {
111
- const digits = value.split('')
140
+ function formatMinorAmount(value: bigint) {
141
+ const isNegative = value < 0n
142
+ const absoluteValue = isNegative ? -value : value
143
+ const integer = absoluteValue / 100n
144
+ const decimal = String(absoluteValue % 100n).padStart(2, '0')
145
+ const groupedInteger = integer.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
112
146
 
113
- for (let index = digits.length - 1; index >= 0; index -= 1) {
114
- if (digits[index] !== '9') {
115
- digits[index] = String(Number(digits[index]) + 1)
116
- return digits.join('')
117
- }
118
-
119
- digits[index] = '0'
120
- }
121
-
122
- return `1${digits.join('')}`
147
+ return `${isNegative ? '-' : ''}${groupedInteger}.${decimal}`
123
148
  }
124
149
 
125
150
  function getErrorMessage(error: unknown, fallback: string) {
@@ -131,8 +156,21 @@ function getErrorMessage(error: unknown, fallback: string) {
131
156
  <main class="opening-reserve-page">
132
157
  <section class="result-section" aria-label="日初备款数匡算查询结果">
133
158
  <div class="section-heading">
134
- <span class="section-heading__mark" aria-hidden="true"></span>
135
- <h2>查询结果</h2>
159
+ <div class="section-heading__title">
160
+ <span class="section-heading__mark" aria-hidden="true"></span>
161
+ <h2>查询结果</h2>
162
+ </div>
163
+
164
+ <label class="unit-switch">
165
+ <span>单位</span>
166
+ <em>{{ selectedUnit.label }}</em>
167
+ <select v-model="selectedUnitKey" aria-label="选择金额单位">
168
+ <option v-for="unit in amountUnits" :key="unit.key" :value="unit.key">
169
+ {{ unit.label }}
170
+ </option>
171
+ </select>
172
+ <van-icon name="arrow-down" aria-hidden="true" />
173
+ </label>
136
174
  </div>
137
175
 
138
176
  <div class="result-stack">
@@ -185,9 +223,17 @@ button {
185
223
  .section-heading {
186
224
  display: flex;
187
225
  align-items: center;
226
+ justify-content: space-between;
227
+ gap: 12px;
188
228
  margin-bottom: 21px;
189
229
  }
190
230
 
231
+ .section-heading__title {
232
+ display: flex;
233
+ align-items: center;
234
+ min-width: 0;
235
+ }
236
+
191
237
  .section-heading__mark {
192
238
  width: 7px;
193
239
  height: 24px;
@@ -206,6 +252,51 @@ button {
206
252
  letter-spacing: 0;
207
253
  }
208
254
 
255
+ .unit-switch {
256
+ position: relative;
257
+ display: inline-flex;
258
+ align-items: center;
259
+ flex: 0 0 auto;
260
+ height: 30px;
261
+ padding: 0 28px 0 11px;
262
+ border: 1px solid #ffd5da;
263
+ border-radius: 999px;
264
+ color: #ef252d;
265
+ font-size: 12px;
266
+ font-weight: 560;
267
+ line-height: 1;
268
+ background: #fff7f8;
269
+ box-shadow: 0 5px 14px rgba(255, 43, 50, 0.08);
270
+ box-sizing: border-box;
271
+ }
272
+
273
+ .unit-switch span {
274
+ margin-right: 4px;
275
+ color: #8c8f98;
276
+ font-weight: 500;
277
+ }
278
+
279
+ .unit-switch em {
280
+ font-style: normal;
281
+ }
282
+
283
+ .unit-switch select {
284
+ position: absolute;
285
+ inset: 0;
286
+ z-index: 2;
287
+ width: 100%;
288
+ height: 100%;
289
+ opacity: 0;
290
+ }
291
+
292
+ .unit-switch :deep(.van-icon) {
293
+ position: absolute;
294
+ right: 10px;
295
+ color: #ef252d;
296
+ font-size: 12px;
297
+ pointer-events: none;
298
+ }
299
+
209
300
  .result-stack {
210
301
  display: block;
211
302
  }
@@ -1,26 +1,580 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ name: 'WarningPage' })
3
+
4
+ type WarningDetail = {
5
+ title: string
6
+ status: string
7
+ id: string
8
+ time: string
9
+ type: string
10
+ source: string
11
+ messagePrefix: string
12
+ amount: string
13
+ messageSuffix: string
14
+ }
15
+
16
+ const warningDetail: WarningDetail = {
17
+ title: '净借记限额预警',
18
+ status: '有效',
19
+ id: '167749751028801',
20
+ time: '2026-07-07 17:33:50',
21
+ type: '净借记限额预警',
22
+ source: '头寸预警',
23
+ messagePrefix: '小额可用净借记限额余额低于',
24
+ amount: '60000亿元',
25
+ messageSuffix: '!',
26
+ }
27
+
28
+ const detailRows = [
29
+ { label: '预警ID', value: warningDetail.id, icon: 'description' },
30
+ { label: '预警时间', value: warningDetail.time, icon: 'clock-o' },
31
+ { label: '预警类型', value: warningDetail.type, icon: 'apps-o' },
32
+ ]
33
+ </script>
34
+
1
35
  <template>
2
- <main class="page">
3
- <section class="page-body">
4
- <p class="placeholder">页面建设中…</p>
36
+ <main class="warning-page">
37
+ <section class="warning-hero" aria-label="预警信息查询">
38
+ <div class="hero-alert" aria-hidden="true">
39
+ <span class="shield-mark shield-mark--large"></span>
40
+ <span class="alert-underline"></span>
41
+ </div>
42
+
43
+ <div class="hero-copy">
44
+ <h1>预警信息查询</h1>
45
+ <p>及时掌握头寸预警信息,防范资金风险</p>
46
+ </div>
47
+
48
+ <div class="hero-scene" aria-hidden="true">
49
+ <span class="bank-roof"></span>
50
+ <span class="bank-base"></span>
51
+ <span class="bank-step"></span>
52
+ <span class="bank-column bank-column--1"></span>
53
+ <span class="bank-column bank-column--2"></span>
54
+ <span class="bank-column bank-column--3"></span>
55
+ <span class="bank-column bank-column--4"></span>
56
+ <span class="shield-mark shield-mark--small"></span>
57
+ </div>
5
58
  </section>
59
+
60
+ <section class="warning-detail" aria-labelledby="warning-detail-title">
61
+ <div class="section-title">
62
+ <span class="section-title-mark"></span>
63
+ <h2 id="warning-detail-title">预警详情</h2>
64
+ </div>
65
+
66
+ <article class="warning-card" aria-label="净借记限额预警详情">
67
+ <header class="warning-card-header">
68
+ <span class="bank-badge" aria-hidden="true">
69
+ <van-icon name="hotel-o" />
70
+ </span>
71
+ <h3>{{ warningDetail.title }}</h3>
72
+ <span class="status-badge">{{ warningDetail.status }}</span>
73
+ </header>
74
+
75
+ <dl class="warning-fields">
76
+ <div v-for="row in detailRows" :key="row.label" class="warning-field-row">
77
+ <dt>
78
+ <span class="field-icon" aria-hidden="true">
79
+ <van-icon :name="row.icon" />
80
+ </span>
81
+ <span>{{ row.label }}</span>
82
+ </dt>
83
+ <dd>{{ row.value }}</dd>
84
+ </div>
85
+ </dl>
86
+
87
+ <section class="warning-message" aria-label="预警内容">
88
+ <span class="message-icon" aria-hidden="true">
89
+ <van-icon name="warning-o" />
90
+ </span>
91
+ <div class="message-copy">
92
+ <strong>{{ warningDetail.source }}</strong>
93
+ <p>
94
+ {{ warningDetail.messagePrefix }}<em>{{ warningDetail.amount }}</em
95
+ >{{ warningDetail.messageSuffix }}
96
+ </p>
97
+ </div>
98
+ </section>
99
+ </article>
100
+ </section>
101
+
102
+ <p class="end-text" aria-label="列表结束">
103
+ <span></span>
104
+ 已到底,没有更多数据
105
+ <span></span>
106
+ </p>
6
107
  </main>
7
108
  </template>
8
109
 
9
110
  <style scoped>
10
- .page {
111
+ .warning-page {
112
+ width: 100%;
11
113
  max-width: 750px;
12
114
  min-height: var(--app-page-min-height, 100vh);
13
115
  margin: 0 auto;
14
- padding: 0 11px;
15
- background: #f7f8fa;
116
+ padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px);
117
+ color: #171b20;
118
+ background:
119
+ radial-gradient(circle at 86% 8%, rgba(255, 223, 226, 0.8), transparent 94px),
120
+ linear-gradient(180deg, #fff6f6 0, #ffffff 178px, #ffffff 100%);
121
+ box-sizing: border-box;
122
+ }
123
+
124
+ .warning-hero {
125
+ position: relative;
126
+ display: grid;
127
+ grid-template-columns: 78px minmax(0, 1fr);
128
+ min-height: 120px;
129
+ overflow: hidden;
130
+ padding: 27px 17px 18px;
131
+ border-top: 1px solid #ffe1e2;
132
+ background:
133
+ linear-gradient(160deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 242, 243, 0.55) 100%),
134
+ linear-gradient(
135
+ 100deg,
136
+ rgba(255, 255, 255, 0) 0 68%,
137
+ rgba(255, 85, 94, 0.08) 68% 76%,
138
+ transparent 76%
139
+ );
140
+ box-sizing: border-box;
141
+ }
142
+
143
+ .warning-hero::after {
144
+ position: absolute;
145
+ right: -28px;
146
+ top: 11px;
147
+ width: 160px;
148
+ height: 98px;
149
+ background:
150
+ linear-gradient(155deg, transparent 0 42%, rgba(255, 92, 99, 0.08) 42% 54%, transparent 54%),
151
+ linear-gradient(24deg, transparent 0 38%, rgba(255, 92, 99, 0.08) 38% 50%, transparent 50%);
152
+ content: '';
153
+ pointer-events: none;
154
+ }
155
+
156
+ .hero-alert {
157
+ position: relative;
158
+ z-index: 1;
159
+ min-width: 0;
160
+ }
161
+
162
+ .shield-mark {
163
+ position: relative;
164
+ display: block;
165
+ clip-path: polygon(50% 0, 94% 18%, 91% 67%, 50% 100%, 9% 67%, 6% 18%);
166
+ background: linear-gradient(145deg, #ff5e64 0%, #ee1f2b 64%, #f7434b 100%);
167
+ box-shadow: 0 8px 18px rgba(238, 31, 43, 0.18);
168
+ }
169
+
170
+ .shield-mark::before {
171
+ position: absolute;
172
+ left: 50%;
173
+ top: 23%;
174
+ width: 5px;
175
+ height: 21px;
176
+ border-radius: 999px;
177
+ background: #ffffff;
178
+ content: '';
179
+ transform: translateX(-50%);
180
+ }
181
+
182
+ .shield-mark::after {
183
+ position: absolute;
184
+ left: 50%;
185
+ bottom: 22%;
186
+ width: 7px;
187
+ height: 7px;
188
+ border-radius: 50%;
189
+ background: #ffffff;
190
+ content: '';
191
+ transform: translateX(-50%);
192
+ }
193
+
194
+ .shield-mark--large {
195
+ width: 34px;
196
+ height: 40px;
197
+ }
198
+
199
+ .alert-underline {
200
+ display: block;
201
+ width: 36px;
202
+ height: 4px;
203
+ margin-top: 18px;
204
+ border-radius: 999px;
205
+ background: linear-gradient(90deg, #ff202b 0%, #ff696e 100%);
206
+ box-shadow: 0 5px 12px rgba(255, 42, 50, 0.18);
207
+ }
208
+
209
+ .hero-copy {
210
+ position: relative;
211
+ z-index: 1;
212
+ min-width: 0;
213
+ padding-top: 2px;
214
+ }
215
+
216
+ .hero-copy h1 {
217
+ margin: 0;
218
+ color: #181d22;
219
+ font-size: 24px;
220
+ font-weight: 800;
221
+ line-height: 1.18;
222
+ letter-spacing: 0;
223
+ }
224
+
225
+ .hero-copy p {
226
+ margin: 9px 0 0;
227
+ color: #73777f;
228
+ font-size: 13px;
229
+ font-weight: 500;
230
+ line-height: 1.45;
231
+ }
232
+
233
+ .hero-scene {
234
+ position: absolute;
235
+ right: 18px;
236
+ top: 26px;
237
+ z-index: 0;
238
+ width: 92px;
239
+ height: 68px;
240
+ opacity: 0.62;
241
+ }
242
+
243
+ .bank-roof {
244
+ position: absolute;
245
+ left: 16px;
246
+ top: 0;
247
+ width: 67px;
248
+ height: 20px;
249
+ clip-path: polygon(50% 0, 100% 72%, 100% 100%, 0 100%, 0 72%);
250
+ background: linear-gradient(180deg, rgba(250, 85, 94, 0.32), rgba(250, 85, 94, 0.14));
251
+ }
252
+
253
+ .bank-base,
254
+ .bank-step {
255
+ position: absolute;
256
+ right: 0;
257
+ border-radius: 3px;
258
+ background: rgba(247, 86, 94, 0.2);
259
+ }
260
+
261
+ .bank-base {
262
+ bottom: 12px;
263
+ width: 78px;
264
+ height: 8px;
265
+ }
266
+
267
+ .bank-step {
268
+ bottom: 3px;
269
+ width: 92px;
270
+ height: 6px;
271
+ }
272
+
273
+ .bank-column {
274
+ position: absolute;
275
+ top: 25px;
276
+ width: 11px;
277
+ height: 31px;
278
+ border-radius: 3px 3px 0 0;
279
+ background: rgba(247, 86, 94, 0.18);
280
+ }
281
+
282
+ .bank-column--1 {
283
+ left: 25px;
284
+ }
285
+
286
+ .bank-column--2 {
287
+ left: 42px;
288
+ }
289
+
290
+ .bank-column--3 {
291
+ left: 59px;
292
+ }
293
+
294
+ .bank-column--4 {
295
+ left: 76px;
296
+ }
297
+
298
+ .shield-mark--small {
299
+ position: absolute;
300
+ left: 0;
301
+ bottom: 0;
302
+ width: 31px;
303
+ height: 36px;
304
+ opacity: 0.78;
305
+ }
306
+
307
+ .shield-mark--small::before {
308
+ width: 4px;
309
+ height: 17px;
310
+ }
311
+
312
+ .shield-mark--small::after {
313
+ width: 6px;
314
+ height: 6px;
315
+ }
316
+
317
+ .warning-detail {
318
+ padding: 22px 17px 0;
319
+ }
320
+
321
+ .section-title {
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 8px;
325
+ height: 24px;
326
+ }
327
+
328
+ .section-title-mark {
329
+ width: 4px;
330
+ height: 17px;
331
+ border-radius: 999px;
332
+ background: linear-gradient(180deg, #ff1f2b 0%, #ff4c53 100%);
333
+ }
334
+
335
+ .section-title h2 {
336
+ margin: 0;
337
+ color: #151a20;
338
+ font-size: 17px;
339
+ font-weight: 800;
340
+ line-height: 1;
341
+ }
342
+
343
+ .warning-card {
344
+ margin-top: 18px;
345
+ padding: 18px 13px 15px;
346
+ border: 1px solid #eceef2;
347
+ border-radius: 9px;
348
+ background: rgba(255, 255, 255, 0.98);
349
+ box-shadow:
350
+ 0 13px 24px rgba(31, 37, 51, 0.08),
351
+ 0 2px 6px rgba(31, 37, 51, 0.04);
352
+ box-sizing: border-box;
353
+ }
354
+
355
+ .warning-card-header {
356
+ display: grid;
357
+ grid-template-columns: 46px minmax(0, 1fr) auto;
358
+ align-items: center;
359
+ gap: 12px;
360
+ padding-bottom: 17px;
361
+ border-bottom: 1px dashed #e4e5e9;
16
362
  }
17
- .page-body {
18
- padding: 24px 0;
363
+
364
+ .bank-badge {
365
+ display: inline-flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ width: 40px;
369
+ height: 40px;
370
+ border-radius: 50%;
371
+ color: #f23a41;
372
+ font-size: 25px;
373
+ background: #ffe7e9;
374
+ }
375
+
376
+ .warning-card-header h3 {
377
+ min-width: 0;
378
+ margin: 0;
379
+ color: #161b21;
380
+ font-size: 16px;
381
+ font-weight: 800;
382
+ line-height: 1.2;
383
+ overflow-wrap: anywhere;
384
+ }
385
+
386
+ .status-badge {
387
+ display: inline-flex;
388
+ align-items: center;
389
+ justify-content: center;
390
+ min-width: 47px;
391
+ min-height: 31px;
392
+ padding: 0 9px;
393
+ border: 1px solid #ffb8bb;
394
+ border-radius: 4px;
395
+ color: #ed2832;
396
+ font-size: 16px;
397
+ font-weight: 500;
398
+ line-height: 1;
399
+ background: #fff0f1;
400
+ box-sizing: border-box;
401
+ }
402
+
403
+ .warning-fields {
404
+ margin: 0;
405
+ padding: 14px 0 7px;
406
+ }
407
+
408
+ .warning-field-row {
409
+ display: grid;
410
+ grid-template-columns: minmax(0, 112px) minmax(0, 1fr);
411
+ align-items: center;
412
+ min-height: 46px;
413
+ column-gap: 12px;
414
+ }
415
+
416
+ .warning-field-row + .warning-field-row {
417
+ border-top: 1px solid #edf0f2;
418
+ }
419
+
420
+ .warning-field-row dt {
421
+ display: flex;
422
+ align-items: center;
423
+ min-width: 0;
424
+ margin: 0;
425
+ color: #666b73;
426
+ font-size: 15px;
427
+ font-weight: 500;
428
+ line-height: 1.2;
429
+ }
430
+
431
+ .field-icon {
432
+ display: inline-flex;
433
+ align-items: center;
434
+ justify-content: center;
435
+ width: 28px;
436
+ height: 28px;
437
+ flex: 0 0 28px;
438
+ margin-right: 12px;
439
+ border-radius: 8px;
440
+ color: #ffffff;
441
+ font-size: 17px;
442
+ background: linear-gradient(145deg, #aeb3bd, #858b96);
19
443
  }
20
- .placeholder {
21
- text-align: center;
22
- color: #969799;
444
+
445
+ .warning-field-row dd {
446
+ min-width: 0;
447
+ margin: 0;
448
+ color: #131820;
449
+ font-size: 15px;
450
+ font-weight: 500;
451
+ line-height: 1.25;
452
+ text-align: right;
453
+ overflow-wrap: anywhere;
454
+ }
455
+
456
+ .warning-message {
457
+ display: grid;
458
+ grid-template-columns: 39px minmax(0, 1fr);
459
+ gap: 10px;
460
+ min-height: 68px;
461
+ margin-top: 4px;
462
+ padding: 14px 12px 12px;
463
+ border: 1px solid #ffbdc1;
464
+ border-radius: 7px;
465
+ background:
466
+ radial-gradient(circle at 95% 82%, rgba(255, 229, 231, 0.74), transparent 72px),
467
+ linear-gradient(135deg, #fffafa 0%, #fff4f4 100%);
468
+ box-sizing: border-box;
469
+ }
470
+
471
+ .message-icon {
472
+ display: inline-flex;
473
+ align-items: center;
474
+ justify-content: center;
475
+ width: 32px;
476
+ height: 42px;
477
+ color: #f23a41;
478
+ font-size: 29px;
479
+ }
480
+
481
+ .message-copy {
482
+ min-width: 0;
483
+ }
484
+
485
+ .message-copy strong {
486
+ display: block;
487
+ color: #e23239;
488
+ font-size: 15px;
489
+ font-weight: 740;
490
+ line-height: 1.25;
491
+ }
492
+
493
+ .message-copy p {
494
+ margin: 9px 0 0;
495
+ color: #131820;
496
+ font-size: 16px;
497
+ font-weight: 600;
498
+ line-height: 1.4;
499
+ overflow-wrap: anywhere;
500
+ }
501
+
502
+ .message-copy em {
503
+ color: #e42d36;
504
+ font-style: normal;
505
+ font-weight: 800;
506
+ }
507
+
508
+ .end-text {
509
+ display: flex;
510
+ align-items: center;
511
+ justify-content: center;
512
+ gap: 10px;
513
+ margin: 37px 0 0;
514
+ color: #888d96;
23
515
  font-size: 14px;
24
- padding-top: 120px;
516
+ font-weight: 500;
517
+ line-height: 1;
518
+ }
519
+
520
+ .end-text span {
521
+ width: 23px;
522
+ height: 1px;
523
+ background: #c8cbd1;
524
+ }
525
+
526
+ @media (max-width: 350px) {
527
+ .warning-hero {
528
+ grid-template-columns: 58px minmax(0, 1fr);
529
+ padding-right: 13px;
530
+ padding-left: 13px;
531
+ }
532
+
533
+ .hero-copy h1 {
534
+ font-size: 22px;
535
+ }
536
+
537
+ .hero-scene {
538
+ right: 6px;
539
+ opacity: 0.38;
540
+ }
541
+
542
+ .warning-detail {
543
+ padding-right: 13px;
544
+ padding-left: 13px;
545
+ }
546
+
547
+ .warning-card {
548
+ padding-right: 11px;
549
+ padding-left: 11px;
550
+ }
551
+
552
+ .warning-card-header {
553
+ grid-template-columns: 40px minmax(0, 1fr) auto;
554
+ gap: 9px;
555
+ }
556
+
557
+ .bank-badge {
558
+ width: 36px;
559
+ height: 36px;
560
+ font-size: 22px;
561
+ }
562
+
563
+ .warning-field-row {
564
+ grid-template-columns: minmax(0, 104px) minmax(0, 1fr);
565
+ }
566
+
567
+ .warning-field-row dt,
568
+ .warning-field-row dd {
569
+ font-size: 14px;
570
+ }
571
+
572
+ .field-icon {
573
+ margin-right: 9px;
574
+ }
575
+
576
+ .message-copy p {
577
+ font-size: 15px;
578
+ }
25
579
  }
26
580
  </style>
package/vite.config.ts CHANGED
@@ -14,6 +14,8 @@ export default defineConfig(({ command, mode }) => {
14
14
  const env = loadEnv(mode, process.cwd(), '')
15
15
  const apiBaseUrl = env.VITE_API_BASE_URL || '/api'
16
16
  const appVersion = getAppVersion(mode, command)
17
+ const enableVueDevTools =
18
+ command === 'serve' && env.VITE_ENABLE_VUE_DEVTOOLS !== 'false'
17
19
 
18
20
  return {
19
21
  base: './',
@@ -22,7 +24,7 @@ export default defineConfig(({ command, mode }) => {
22
24
  },
23
25
  plugins: [
24
26
  vue(),
25
- env.VITE_ENABLE_VUE_DEVTOOLS === 'true' && vueDevTools(),
27
+ enableVueDevTools && vueDevTools(),
26
28
 
27
29
  // 自动导入 Vue / Vue Router / Pinia / VueUse / Vant 的 API,省去手动 import
28
30
  AutoImport({