minitest2.0 0.0.5 → 0.0.7

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.
@@ -0,0 +1,390 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ buildForeignForecastDetailPayload,
4
+ queryForeignForecastDetail,
5
+ type ForeignForecastDetailRecord,
6
+ } from '@/api/foreign-position'
7
+ import {
8
+ formatForeignForecastAmountText,
9
+ formatForeignForecastAmountYuanText,
10
+ formatForeignForecastDate,
11
+ resolveForeignBusinessType,
12
+ resolveForeignCurrency,
13
+ resolveForeignStatus,
14
+ } from '@/utils/foreign-forecast'
15
+ import { showAppToast } from '@/utils/toast'
16
+
17
+ defineOptions({ name: 'ForeignPositionDetailPage' })
18
+
19
+ type DetailField = {
20
+ label: string
21
+ value: string
22
+ }
23
+
24
+ type DetailSection = {
25
+ title: string
26
+ fields: DetailField[]
27
+ }
28
+
29
+ const route = useRoute()
30
+ const router = useRouter()
31
+
32
+ const predRefNo = computed(() => String(route.params.predRefNo || ''))
33
+ const detail = ref<ForeignForecastDetailRecord>()
34
+ const loading = ref(false)
35
+ const loadError = ref(false)
36
+ const currencyValue = computed(() => getRecordCurrency(detail.value))
37
+ const currencyOption = computed(() => resolveForeignCurrency(currencyValue.value))
38
+ const statusOption = computed(() => resolveForeignStatus(detail.value?.predStatus))
39
+ const heroAmount = computed(() =>
40
+ formatForeignForecastAmountText(detail.value?.predAmt, currencyValue.value),
41
+ )
42
+ const heroAmountYuan = computed(() => formatForeignForecastAmountYuanText(detail.value?.predAmt))
43
+ const heroDate = computed(() => formatForeignForecastDate(detail.value?.tranDate))
44
+
45
+ const detailSections = computed<DetailSection[]>(() => {
46
+ const item = detail.value
47
+
48
+ if (!item) return []
49
+
50
+ return [
51
+ {
52
+ title: '基础信息',
53
+ fields: [
54
+ { label: '预报流水号', value: fieldText(item.predRefNo) },
55
+ { label: '交易日期', value: formatForeignForecastDate(item.tranDate) },
56
+ { label: '预报日期', value: formatForeignForecastDate(item.predDate) },
57
+ { label: '预报状态', value: statusOption.value.label },
58
+ { label: '核销状态', value: fieldText(item.verifyStatus) },
59
+ { label: '额度标志', value: fieldText(item.eduFlag) },
60
+ ],
61
+ },
62
+ {
63
+ title: '机构与人员',
64
+ fields: [
65
+ { label: '预报机构', value: fieldText(item.predBranchName || item.predBranch) },
66
+ { label: '预报机构代码', value: fieldText(item.predBranch) },
67
+ { label: '预报人员编号', value: fieldText(item.predCode) },
68
+ { label: '预报人员姓名', value: fieldText(item.predName) },
69
+ { label: '落地机构名称', value: fieldText(item.groundBranch) },
70
+ { label: '落地机构代码', value: fieldText(item.groundBranchCode) },
71
+ ],
72
+ },
73
+ {
74
+ title: '业务与币种',
75
+ fields: [
76
+ { label: '币种', value: currencyOption.value.label },
77
+ { label: '业务类型', value: resolveForeignBusinessType(item.busiType) },
78
+ { label: '客户名称', value: fieldText(item.clientName) },
79
+ { label: '交易对手名称', value: fieldText(item.competName) },
80
+ { label: '交易对手账号', value: fieldText(item.competAcct) },
81
+ {
82
+ label: '清算行 SWIFT',
83
+ value: fieldText(item.bankSwift || item.swiftCode || item.bicCode),
84
+ },
85
+ { label: '标准名称', value: fieldText(item.standardName) },
86
+ { label: '用途', value: fieldText(item.purpose) },
87
+ { label: '备注', value: fieldText(item.remark) },
88
+ ],
89
+ },
90
+ {
91
+ title: '支付与金额',
92
+ fields: [
93
+ {
94
+ label: '预报金额',
95
+ value: formatForeignForecastAmountText(item.predAmt, currencyValue.value),
96
+ },
97
+ {
98
+ label: '已核销金额',
99
+ value: formatForeignForecastAmountText(item.verifyAmt, currencyValue.value),
100
+ },
101
+ { label: '支付渠道', value: fieldText(item.payChannel) },
102
+ { label: '收支方向', value: fieldText(item.payDirec) },
103
+ ],
104
+ },
105
+ {
106
+ title: '票据与联系',
107
+ fields: [
108
+ { label: '出票日', value: formatForeignForecastDate(item.billDate) },
109
+ { label: '票号', value: fieldText(item.billNo) },
110
+ { label: '联系人', value: fieldText(item.linkMan) },
111
+ { label: '联系电话', value: fieldText(item.phoneNo) },
112
+ ],
113
+ },
114
+ ]
115
+ })
116
+
117
+ onMounted(() => {
118
+ loadDetail()
119
+ })
120
+
121
+ async function loadDetail() {
122
+ if (!predRefNo.value) {
123
+ loadError.value = true
124
+ return
125
+ }
126
+
127
+ loading.value = true
128
+ loadError.value = false
129
+
130
+ const payload = buildForeignForecastDetailPayload(predRefNo.value)
131
+ console.log('[外币预报详情入参]', payload)
132
+
133
+ try {
134
+ const response = await queryForeignForecastDetail(payload)
135
+ detail.value = response.body
136
+ } catch (error) {
137
+ console.error('[外币预报详情加载失败]', error)
138
+ loadError.value = true
139
+ showAppToast('详情加载失败,请重试')
140
+ } finally {
141
+ loading.value = false
142
+ }
143
+ }
144
+
145
+ function getRecordCurrency(record?: ForeignForecastDetailRecord) {
146
+ return (
147
+ record?.ccy ||
148
+ record?.currency ||
149
+ record?.currencyCode ||
150
+ record?.currCode ||
151
+ record?.curType ||
152
+ record?.tranCcy ||
153
+ ''
154
+ )
155
+ .trim()
156
+ .toUpperCase()
157
+ }
158
+
159
+ function fieldText(value?: string) {
160
+ const text = value?.trim()
161
+ return text || '-'
162
+ }
163
+ </script>
164
+
165
+ <template>
166
+ <main class="detail-page">
167
+ <section v-if="loading" class="state-section">
168
+ <van-loading vertical>加载中...</van-loading>
169
+ </section>
170
+
171
+ <template v-else-if="detail">
172
+ <section class="hero-card" aria-label="外币预报详情概要">
173
+ <div class="hero-topline">
174
+ <span class="status-tag" :class="statusOption.className">{{ statusOption.label }}</span>
175
+ <span class="hero-date">{{ heroDate }}</span>
176
+ </div>
177
+ <strong>{{ heroAmount }}</strong>
178
+ <span v-if="heroAmountYuan !== '-'" class="hero-amount-yuan">{{ heroAmountYuan }}</span>
179
+ <p>{{ detail.predRefNo || predRefNo }}</p>
180
+ </section>
181
+
182
+ <section
183
+ v-for="section in detailSections"
184
+ :key="section.title"
185
+ class="detail-card"
186
+ :aria-label="section.title"
187
+ >
188
+ <h2>{{ section.title }}</h2>
189
+ <dl class="detail-list">
190
+ <div v-for="field in section.fields" :key="`${section.title}-${field.label}`">
191
+ <dt>{{ field.label }}</dt>
192
+ <dd>{{ field.value }}</dd>
193
+ </div>
194
+ </dl>
195
+ </section>
196
+ </template>
197
+
198
+ <van-empty v-else image="error" :description="loadError ? '详情加载失败' : '详情不存在'">
199
+ <van-button v-if="loadError" round type="danger" @click="loadDetail">重试</van-button>
200
+ <van-button v-else round type="danger" @click="router.replace('/foreign-position')">
201
+ 返回列表
202
+ </van-button>
203
+ </van-empty>
204
+ </main>
205
+ </template>
206
+
207
+ <style scoped>
208
+ .detail-page {
209
+ width: 100%;
210
+ max-width: 750px;
211
+ min-height: var(--app-page-min-height, 100vh);
212
+ margin: 0 auto;
213
+ padding: 13px 13px calc(env(safe-area-inset-bottom, 0px) + 24px);
214
+ color: #080d1f;
215
+ background:
216
+ radial-gradient(circle at 50% 0%, rgba(255, 242, 243, 0.92), transparent 118px),
217
+ linear-gradient(180deg, #ffffff 0%, #fbfcfd 54%, #ffffff 100%);
218
+ box-sizing: border-box;
219
+ }
220
+
221
+ button {
222
+ border: 0;
223
+ padding: 0;
224
+ background: transparent;
225
+ appearance: none;
226
+ }
227
+
228
+ .state-section {
229
+ display: flex;
230
+ justify-content: center;
231
+ padding-top: 120px;
232
+ }
233
+
234
+ .hero-card {
235
+ margin-top: 15px;
236
+ border-radius: 13px;
237
+ padding: 16px;
238
+ color: #ffffff;
239
+ background: linear-gradient(135deg, #f22f3d 0%, #ff5660 100%);
240
+ box-shadow: 0 13px 28px rgba(244, 29, 48, 0.18);
241
+ }
242
+
243
+ .hero-topline {
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: space-between;
247
+ gap: 10px;
248
+ }
249
+
250
+ .hero-card strong {
251
+ display: block;
252
+ margin-top: 18px;
253
+ font-size: 27px;
254
+ font-weight: 800;
255
+ line-height: 1.1;
256
+ }
257
+
258
+ .hero-amount-yuan {
259
+ display: block;
260
+ margin-top: 7px;
261
+ overflow-wrap: anywhere;
262
+ color: rgba(255, 255, 255, 0.74);
263
+ font-size: 12px;
264
+ font-weight: 500;
265
+ line-height: 1.35;
266
+ }
267
+
268
+ .hero-card p {
269
+ overflow: hidden;
270
+ margin: 10px 0 0;
271
+ color: rgba(255, 255, 255, 0.82);
272
+ font-size: 12px;
273
+ line-height: 1.3;
274
+ text-overflow: ellipsis;
275
+ white-space: nowrap;
276
+ }
277
+
278
+ .hero-date {
279
+ color: rgba(255, 255, 255, 0.88);
280
+ font-size: 13px;
281
+ font-weight: 650;
282
+ }
283
+
284
+ .detail-card {
285
+ margin-top: 10px;
286
+ border-radius: 13px;
287
+ padding: 15px;
288
+ background: rgba(255, 255, 255, 0.96);
289
+ box-shadow: 0 7px 22px rgba(21, 28, 45, 0.055);
290
+ }
291
+
292
+ .detail-card h2 {
293
+ margin: 0;
294
+ color: #111728;
295
+ font-size: 15px;
296
+ font-weight: 760;
297
+ line-height: 1.2;
298
+ }
299
+
300
+ .detail-list {
301
+ margin: 12px 0 0;
302
+ }
303
+
304
+ .detail-list div {
305
+ display: grid;
306
+ grid-template-columns: 96px minmax(0, 1fr);
307
+ gap: 12px;
308
+ padding: 10px 0;
309
+ border-top: 1px solid #eef0f4;
310
+ }
311
+
312
+ .detail-list div:first-child {
313
+ border-top: 0;
314
+ }
315
+
316
+ .detail-list dt,
317
+ .detail-list dd {
318
+ margin: 0;
319
+ font-size: 13px;
320
+ line-height: 1.42;
321
+ }
322
+
323
+ .detail-list dt {
324
+ color: #7f8798;
325
+ }
326
+
327
+ .detail-list dd {
328
+ overflow-wrap: anywhere;
329
+ color: #151827;
330
+ font-weight: 650;
331
+ text-align: right;
332
+ }
333
+
334
+ .status-tag {
335
+ display: inline-flex;
336
+ align-items: center;
337
+ justify-content: center;
338
+ min-height: 20px;
339
+ border: 1px solid transparent;
340
+ border-radius: 4px;
341
+ padding: 2px 7px;
342
+ font-size: 11px;
343
+ font-weight: 650;
344
+ line-height: 1.25;
345
+ white-space: nowrap;
346
+ }
347
+
348
+ .status-tag-confirmed {
349
+ border-color: #d8e1ee;
350
+ color: #687083;
351
+ background: #ffffff;
352
+ }
353
+
354
+ .status-tag-pending {
355
+ border-color: #ffd99a;
356
+ color: #a05a00;
357
+ background: #fff4d8;
358
+ }
359
+
360
+ .status-tag-cancelled {
361
+ border-color: #d6dbe3;
362
+ color: #687083;
363
+ background: #f3f5f8;
364
+ }
365
+
366
+ .status-tag-verified {
367
+ border-color: #b7ebc9;
368
+ color: #0b8f3a;
369
+ background: #e4f8eb;
370
+ }
371
+
372
+ .status-tag-rejected {
373
+ border-color: #ffc0cb;
374
+ color: #bf2549;
375
+ background: #ffe8ed;
376
+ }
377
+
378
+ .status-tag-unknown {
379
+ border-color: #d6dbe3;
380
+ color: #687083;
381
+ background: #f3f5f8;
382
+ }
383
+
384
+ @media (max-width: 340px) {
385
+ .detail-list div {
386
+ grid-template-columns: 84px minmax(0, 1fr);
387
+ gap: 8px;
388
+ }
389
+ }
390
+ </style>
@@ -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
  }