vue3-element-plus-app-chu 0.0.13 → 0.0.14

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.
Files changed (30) hide show
  1. package/README.md +4 -4
  2. package/package.json +3 -2
  3. package/src/layout/index.vue +21 -36
  4. package/src/router/index.ts +110 -0
  5. package/src/router/menus.ts +16 -0
  6. package/src/view/dcps/README.md +59 -0
  7. package/src/view/dcps/auth/components/AuthorizationDetailDialog.vue +2 -0
  8. package/src/view/dcps/auth/index.vue +4 -6
  9. package/src/view/dcps/balance/index.vue +4 -6
  10. package/src/view/dcps/common.css +7 -0
  11. package/src/view/dcps/estimation/api.ts +17 -142
  12. package/src/view/dcps/estimation/components/EstimationDetailDialog.vue +4 -1
  13. package/src/view/dcps/estimation/index.vue +7 -8
  14. package/src/view/dcps/estimation/utils.ts +113 -0
  15. package/src/view/dcps/forecast/api.ts +9 -201
  16. package/src/view/dcps/forecast/components/ForecastDetailView.vue +19 -9
  17. package/src/view/dcps/forecast/components/ForecastEditorView.vue +25 -14
  18. package/src/view/dcps/forecast/components/ForecastListView.vue +8 -11
  19. package/src/view/dcps/forecast/components/ForecastPageShell.vue +3 -7
  20. package/src/view/dcps/forecast/index.vue +22 -50
  21. package/src/view/dcps/forecast/list-refresh.ts +11 -0
  22. package/src/view/dcps/forecast/row-cache.ts +18 -0
  23. package/src/view/dcps/forecast/utils.ts +169 -0
  24. package/src/view/dcps/injection/components/InjectionDetailDialog.vue +2 -0
  25. package/src/view/dcps/injection/index.vue +4 -6
  26. package/src/view/dcps/write-off/api.ts +9 -164
  27. package/src/view/dcps/write-off/components/FlowWriteOffView.vue +4 -3
  28. package/src/view/dcps/write-off/components/ForecastWriteOffView.vue +4 -3
  29. package/src/view/dcps/write-off/index.vue +6 -7
  30. package/src/view/dcps/write-off/utils.ts +131 -0
@@ -1,66 +1,38 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue'
2
+ import { ref, watch } from 'vue'
3
+ import { useRouter } from 'vue-router'
3
4
  import ForecastListView from './components/ForecastListView.vue'
4
- import ForecastEditorView from './components/ForecastEditorView.vue'
5
- import ForecastDetailView from './components/ForecastDetailView.vue'
6
- import type { ForecastEditorMode, ForecastRow } from './typs'
5
+ import { forecastListRefreshToken } from './list-refresh'
6
+ import { rememberForecastRow } from './row-cache'
7
+ import type { ForecastRow } from './typs'
7
8
 
8
- defineOptions({ name: 'DigitalRmbForecastPage' })
9
+ defineOptions({ name: 'DcpsForecastPage' })
9
10
 
10
- /**
11
- * 页面外壳:列表、新增、修改和详情都在当前路由内切换,
12
- * 项目未引入路由库,切换方式与侧边菜单一致。
13
- */
14
- const view = ref<'list' | 'editor' | 'detail'>('list')
15
- const editorMode = ref<ForecastEditorMode>('create')
16
- const activeRow = ref<ForecastRow | null>(null) // 修改和详情使用的列表记录。
11
+ /** 预报列表页入口;新增、修改和详情分别对应独立路由。 */
12
+ const router = useRouter()
17
13
  const listRef = ref<InstanceType<typeof ForecastListView> | null>(null)
18
14
 
19
15
  const openCreate = () => {
20
- editorMode.value = 'create'
21
- activeRow.value = null
22
- view.value = 'editor'
16
+ const tranDate = listRef.value?.getQueryDate()
17
+ void router.push({ name: 'forecast-create', query: tranDate ? { tranDate } : {} })
23
18
  }
24
19
  const openEdit = (row: ForecastRow) => {
25
- editorMode.value = 'edit'
26
- activeRow.value = row
27
- view.value = 'editor'
20
+ const predRefNo = row.predRefNo?.trim()
21
+ if (!predRefNo) return
22
+ rememberForecastRow(row)
23
+ void router.push({ name: 'forecast-edit', params: { predRefNo } })
28
24
  }
29
25
  const openDetail = (row: ForecastRow) => {
30
- activeRow.value = row
31
- view.value = 'detail'
32
- }
33
- // 返回列表时保留列表页状态;保存成功后回到列表并重新查询。
34
- const backToList = () => { view.value = 'list' }
35
- const onSaved = () => {
36
- backToList()
37
- void listRef.value?.refresh()
26
+ const predRefNo = row.predRefNo?.trim()
27
+ if (!predRefNo) return
28
+ rememberForecastRow(row)
29
+ void router.push({ name: 'forecast-detail', params: { predRefNo } })
38
30
  }
31
+
32
+ // 保存成功返回列表时重新查询;筛选条件和分页由缓存中的列表页保留。
33
+ watch(forecastListRefreshToken, () => void listRef.value?.refresh())
39
34
  </script>
40
35
 
41
36
  <template>
42
- <!-- 列表页保持挂载,返回后筛选条件、分页和滚动位置不变。 -->
43
- <ForecastListView
44
- v-show="view === 'list'"
45
- ref="listRef"
46
- @create="openCreate"
47
- @edit="openEdit"
48
- @detail="openDetail"
49
- />
50
- <ForecastEditorView
51
- v-if="view === 'editor'"
52
- :key="`${editorMode}-${activeRow?.predRefNo ?? 'new'}`"
53
- :mode="editorMode"
54
- :row="activeRow"
55
- :tran-date="listRef?.getQueryDate()"
56
- @back="backToList"
57
- @saved="onSaved"
58
- />
59
- <ForecastDetailView
60
- v-else-if="view === 'detail'"
61
- :key="activeRow?.predRefNo"
62
- :row="activeRow!"
63
- @back="backToList"
64
- @edit="openEdit"
65
- />
37
+ <ForecastListView ref="listRef" @create="openCreate" @edit="openEdit" @detail="openDetail" />
66
38
  </template>
@@ -0,0 +1,11 @@
1
+ import { ref } from 'vue'
2
+
3
+ /**
4
+ * 预报列表刷新信号。
5
+ * 列表页由 KeepAlive 缓存,新增或修改成功返回后需要显式通知它重新查询。
6
+ */
7
+ export const forecastListRefreshToken = ref(0)
8
+
9
+ export const requestForecastListRefresh = () => {
10
+ forecastListRefreshToken.value += 1
11
+ }
@@ -0,0 +1,18 @@
1
+ import type { ForecastRow } from './typs'
2
+
3
+ /**
4
+ * 列表记录缓存。
5
+ * 详情和修改接口没有返回全部字段,路由跳转前暂存列表记录,用于补齐页面展示和表单回填。
6
+ * 直接打开或刷新详情地址时没有缓存,页面会按预报流水号单独请求详情。
7
+ */
8
+ const rows = new Map<string, ForecastRow>()
9
+
10
+ export const rememberForecastRow = (row: ForecastRow) => {
11
+ const predRefNo = row.predRefNo?.trim()
12
+ if (predRefNo) rows.set(predRefNo, row)
13
+ }
14
+
15
+ export const recallForecastRow = (predRefNo?: string): ForecastRow | null => {
16
+ const key = predRefNo?.trim()
17
+ return key ? rows.get(key) ?? null : null
18
+ }
@@ -0,0 +1,169 @@
1
+ import API from './api'
2
+ import type { ApiResponse } from '@/utils/request'
3
+ import type {
4
+ ForecastDetail,
5
+ ForecastFormValues,
6
+ ForecastListBody,
7
+ ForecastRow,
8
+ } from './typs'
9
+
10
+ /** 列表查询结果:当前页记录和符合条件的总条数。 */
11
+ export interface ForecastPage {
12
+ data: ForecastRow[] // 当前页的预报记录。
13
+ total: number // 符合查询条件的总记录数。
14
+ }
15
+
16
+ /** 校验公共响应头;retCode 不是 00000 时按服务端提示或 fallback 抛错。 */
17
+ export function ensureSuccess<T>(response: ApiResponse<T>, fallback: string): T | null {
18
+ if (response?.head?.retCode !== '00000') {
19
+ throw new Error(response?.head?.retMsg?.trim() || fallback)
20
+ }
21
+ return response.body
22
+ }
23
+
24
+ /** 提取可展示的错误信息;无法识别时给出统一提示。 */
25
+ export function getErrorMessage(error: unknown): string {
26
+ if (error instanceof Error && error.message.trim()) return error.message
27
+ if (typeof error === 'string' && error.trim()) return error
28
+ return '操作失败,请稍后重试'
29
+ }
30
+
31
+ /** 业务日期始终按上海时区生成,不依赖浏览器所在时区,格式为 YYYYMMDD。 */
32
+ export function getToday(): string {
33
+ const parts = new Intl.DateTimeFormat('en-CA', {
34
+ timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
35
+ }).formatToParts(new Date())
36
+ const read = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value || ''
37
+ return read('year') + read('month') + read('day')
38
+ }
39
+
40
+ /** 判断是否为合法的 YYYYMMDD 日期。 */
41
+ export function isValidDate(value: string): boolean {
42
+ if (!/^\d{8}$/.test(value)) return false
43
+ const year = Number(value.slice(0, 4))
44
+ const month = Number(value.slice(4, 6))
45
+ const day = Number(value.slice(6, 8))
46
+ if (year < 1 || month < 1 || month > 12 || day < 1) return false
47
+ const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0)
48
+ const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
49
+ return day <= (monthDays[month - 1] || 0)
50
+ }
51
+
52
+ /** 把 YYYYMMDD 格式化为 YYYY-MM-DD 展示;空值显示横线,其他格式保留原文。 */
53
+ export function formatDate(value?: string): string {
54
+ const text = value?.trim() || ''
55
+ if (!text) return '—'
56
+ return isValidDate(text) ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}` : text
57
+ }
58
+
59
+ /** 只接受十进制文本和合法千分位;保留全部小数位,避免改变输入精度。 */
60
+ export function normalizeAmount(value: string): string {
61
+ let text = value.trim()
62
+ if (text.includes(',')) {
63
+ if (!/^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/.test(text)) return ''
64
+ text = text.replace(/,/g, '')
65
+ }
66
+ const match = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(text)
67
+ if (!match || (!match[2] && !match[3])) return ''
68
+ const integer = (match[2] || '0').replace(/^0+(?=\d)/, '')
69
+ const fraction = match[3] || ''
70
+ const negative = match[1] === '-' && /[1-9]/.test(integer + fraction)
71
+ return `${negative ? '-' : ''}${integer}${fraction ? `.${fraction}` : ''}`
72
+ }
73
+
74
+ /** 千分位展示至少两位小数,超过两位的小数原样保留,不做浮点数转换或舍入。 */
75
+ export function formatAmount(value?: string): string {
76
+ const original = value?.trim() || ''
77
+ if (!original) return '—'
78
+ const normalized = normalizeAmount(original)
79
+ if (!normalized) return original
80
+ const negative = normalized.startsWith('-')
81
+ const [integer = '0', fraction = ''] = (negative ? normalized.slice(1) : normalized).split('.')
82
+ const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
83
+ return `${negative ? '-' : ''}${grouped}.${fraction.padEnd(2, '0')}`
84
+ }
85
+
86
+ /** 按统一小数位数转为 BigInt,任意长度的十进制金额都可精确比较。 */
87
+ export function compareAmounts(left: string, right: string): number {
88
+ const leftText = normalizeAmount(left)
89
+ const rightText = normalizeAmount(right)
90
+ if (!leftText || !rightText) {
91
+ if (!leftText && !rightText) return 0
92
+ return leftText ? -1 : 1
93
+ }
94
+ const parts = (text: string) => {
95
+ const negative = text.startsWith('-')
96
+ const [integer = '0', fraction = ''] = (negative ? text.slice(1) : text).split('.')
97
+ return { negative, integer, fraction }
98
+ }
99
+ const a = parts(leftText)
100
+ const b = parts(rightText)
101
+ const precision = Math.max(a.fraction.length, b.fraction.length)
102
+ const toInteger = (amount: ReturnType<typeof parts>) =>
103
+ BigInt(amount.integer + amount.fraction.padEnd(precision, '0')) * (amount.negative ? -1n : 1n)
104
+ const aValue = toInteger(a)
105
+ const bValue = toInteger(b)
106
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
107
+ }
108
+
109
+ /** 新增和导入使用的表单初始值;日期默认取当天。 */
110
+ export function createFormValues(): ForecastFormValues {
111
+ const today = getToday()
112
+ return {
113
+ billDate: '', billNo: '', busiType: '', clientName: '', clientType: '',
114
+ competAcct: '', competName: '', custInfoSeq: '', groundBranch: '', groundBranchName: '',
115
+ linkMan: '', payChannel: '', payDirec: '', phoneNo: '', predAmt: '', predDate: today,
116
+ predName: '', predOrgName: '', purpose: '', remark: '', tranDate: today,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 校验表单;新增校验全部必填项,修改只校验已填写的字段。
122
+ * @param values 表单值。
123
+ * @param mode create 为新增,edit 为修改。
124
+ * @returns 错误提示文本,空字符串表示校验通过。
125
+ */
126
+ export function validateForm(values: ForecastFormValues, mode: 'create' | 'edit' = 'create'): string {
127
+ const requiredFields = [
128
+ ['clientName', '客户名称'], ['payDirec', '收支方向'], ['busiType', '业务类型'],
129
+ ['payChannel', '支付渠道'], ['groundBranch', '落地机构'],
130
+ ] as const
131
+ if (mode === 'create') {
132
+ for (const [key, label] of requiredFields) {
133
+ if (!values[key].trim()) return `请填写${label}`
134
+ }
135
+ }
136
+ for (const [key, label] of [['tranDate', '交易日期'], ['predDate', '预报日期']] as const) {
137
+ const date = values[key].trim()
138
+ if ((date || mode === 'create') && !isValidDate(date)) return `${label}须为有效日期,格式为 YYYYMMDD`
139
+ }
140
+ if (values.billDate.trim() && !isValidDate(values.billDate.trim())) {
141
+ return '出票日须为有效日期,格式为 YYYYMMDD'
142
+ }
143
+ if (values.predAmt.trim() || mode === 'create') {
144
+ const amount = normalizeAmount(values.predAmt)
145
+ if (!amount || amount.startsWith('-') || (amount.split('.')[1]?.length || 0) > 2) {
146
+ return '预报金额须为非负金额,最多保留两位小数'
147
+ }
148
+ }
149
+ return ''
150
+ }
151
+
152
+ /** 读取详情并与列表记录合并:详情没有的字段由同一条列表记录补充。 */
153
+ export async function fetchDetail(row: ForecastRow): Promise<ForecastDetail> {
154
+ if (!row.predRefNo?.trim()) throw new Error('预报流水号缺失,无法读取详情')
155
+ const body = ensureSuccess(await API.getDetailApi({ predRefNo: row.predRefNo }), '读取预报详情失败')
156
+ if (!body || !Object.keys(body).length) throw new Error('服务端未返回预报详情')
157
+ if (body.predRefNo && body.predRefNo !== row.predRefNo) throw new Error('返回的预报流水号与当前记录不一致')
158
+ const definedBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined && value !== null))
159
+ return { ...row, ...definedBody } as ForecastDetail
160
+ }
161
+
162
+ /** 校验列表响应并取出当前页数据;列表缺失或总条数无效时抛出可提示的错误。 */
163
+ export function readPage(body: ForecastListBody | null): ForecastPage {
164
+ if (!body || !Array.isArray(body.resultList)) throw new Error('查询结果缺少预报列表,请联系服务端确认返回数据')
165
+ const totalText = String(body.totalCount ?? body.resultList.length)
166
+ const total = Number(totalText)
167
+ if (!/^\d+$/.test(totalText) || !Number.isSafeInteger(total)) throw new Error('查询返回的总条数无效')
168
+ return { data: body.resultList, total }
169
+ }
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ defineOptions({ name: 'DcpsInjectionDetailDialog' })
3
+
2
4
  // 父页面传入的弹窗状态、注资明细原始数据和格式化后的展示数据。
3
5
  const props = defineProps<{
4
6
  // 是否显示详情弹窗。
@@ -6,6 +6,8 @@ import InjectionDetailDialog from './components/InjectionDetailDialog.vue'
6
6
  import API from './api'
7
7
  import type { InjectionFilters, InjectionQueryParams, InjectionRecord } from './typs'
8
8
 
9
+ defineOptions({ name: 'DcpsInjectionPage' })
10
+
9
11
  type AmountSortOrder = 'ascending' | 'descending' | null
10
12
 
11
13
  // 初始查询条件;空字符串表示不限制该条件,重置时恢复此配置。
@@ -327,16 +329,12 @@ onBeforeUnmount(() => {
327
329
  <style scoped>
328
330
  /* 页面先扣除宿主占用高度,再使用 flex 分配剩余高度,让列表内部滚动并保留分页。 */
329
331
  .dcps-page {
330
- /* 手动修改这里:顶部导航/页签 + 底部状态栏 + 宿主上下留白的合计高度。
331
- 独立页面保持 0px;例如顶部 90px、底部 24px、上下留白 6px,填写 120px。
332
- 不包含浏览器标签栏、地址栏,也不包含本页面自身的 padding。 */
333
- --host-layout-height: 0px;
334
332
  box-sizing: border-box;
335
333
  display: flex;
336
334
  flex-direction: column;
337
335
  gap: 5px;
338
- height: calc(100vh - var(--host-layout-height));
339
- height: calc(100dvh - var(--host-layout-height));
336
+ height: calc(100vh - var(--dpcs-host-layout-height));
337
+ height: calc(100dvh - var(--dpcs-host-layout-height));
340
338
  min-height: 0;
341
339
  min-width: 0;
342
340
  padding: 5px;
@@ -1,5 +1,4 @@
1
1
  import { postDigitalRmb, type DigitalRmbParams } from '@/utils/digital-rmb-request'
2
- import type { ApiResponse } from '@/utils/request'
3
2
  import type {
4
3
  Tam120502001InBody,
5
4
  Tam120502002InBody,
@@ -11,182 +10,28 @@ import type {
11
10
  } from './typs'
12
11
 
13
12
  /**
14
- * 数字人民币头寸核销的五个接口,复用本模块的类型定义。
13
+ * 数字人民币头寸核销接口。
14
+ * 修改 120502001、核销 120502002、撤销核销 120502003、预报查询 140502001、流水查询 140502002。
15
15
  * 支持平铺参数和 { appHead, head, body } 报文;业务 tranDate 始终保留在 body。
16
- * 金额、日期、账号及流水号使用字符串,响应保留 TAM 的 head、body 结构。
17
16
  */
18
- const API = {
19
- /**
20
- * 头寸核销-修改(120502001)。
21
- * @param params predRefNo 为预报流水号,predAmt 为预报金额,
22
- * beforeVerifyAmt 为修改前核销金额,verifyAmt 为已核销金额。
23
- */
17
+ export default {
18
+ /** 头寸核销-修改。 */
24
19
  editApi: (params: DigitalRmbParams<Tam120502001InBody>) =>
25
20
  postDigitalRmb<Tam120502001InBody, Record<string, unknown>>('/TAM/0001/120502001', params),
26
21
 
27
- /**
28
- * 头寸核销-核销(120502002)。
29
- * @param params predList 为必填预报集合,每项包含 predRefNo、predAmt、verifyAmt、verifyStatus;
30
- * tranList 为可选流水集合,传入时每项必须包含 refNo、tranAmt、verifyStatus。
31
- */
22
+ /** 头寸核销-核销;predList 必填,tranList 可选。 */
32
23
  writeOffApi: (params: DigitalRmbParams<Tam120502002InBody>) =>
33
24
  postDigitalRmb<Tam120502002InBody, Record<string, unknown>>('/TAM/0001/120502002', params),
34
25
 
35
- /**
36
- * 头寸核销-撤销核销(120502003)。
37
- * @param params predList 为预报集合,tranList 为流水集合;Swagger 未声明集合必填。
38
- * 流水项必须包含 refNo、tranAmt、verifyStatus;预报项字段的必填约束与核销接口不同。
39
- */
26
+ /** 头寸核销-撤销核销。 */
40
27
  cancelWriteOffApi: (params: DigitalRmbParams<Tam120502003InBody>) =>
41
28
  postDigitalRmb<Tam120502003InBody, Record<string, unknown>>('/TAM/0001/120502003', params),
42
29
 
43
- /**
44
- * 头寸核销-预报查询(140502001)。
45
- * @param params tranDate 为必填交易日期;支持账号、机构、客户、交易对手、金额范围和核销状态列表筛选。
46
- * busiType:201 兑回、202 兑出、203 付现;payChannel:8 数字人民币;payDirec:I 来账、O 往账。
47
- * 分页使用公共头 pageIndex、pageSize;返回 body.resultList、body.totalCount(字符串)。
48
- */
30
+ /** 头寸核销-预报查询;tranDate 必填,分页使用 pageIndex、pageSize。 */
49
31
  queryForecastApi: (params: DigitalRmbParams<Tam140502001InBody>) =>
50
32
  postDigitalRmb<Tam140502001InBody, Tam140502001OutBody>('/TAM/0001/140502001', params),
51
33
 
52
- /**
53
- * 头寸核销-流水查询(140502002)。
54
- * @param params tranDate 为必填交易日期;支持 orgName、payerName、payeeName、payDirec、
55
- * startTransAmt、endTransAmt、verifyStatus(1 未核销、2 已核销)筛选。
56
- * 返回 body.resultList、body.totalCount 和可选 fileName;流水的渠道说明为 5 数字人民币。
57
- */
34
+ /** 头寸核销-流水查询;tranDate 必填,分页使用 pageIndex、pageSize。 */
58
35
  queryFlowApi: (params: DigitalRmbParams<Tam140502002InBody>) =>
59
- postDigitalRmb<Tam140502002InBody, Tam140502002OutBody>('/TAM/0001/140502002', params),
36
+ postDigitalRmb<Tam140502002InBody, Tam140502002OutBody>('/TAM/0001/140502002', params)
60
37
  }
61
-
62
- /** 列表查询结果:当前页记录和符合条件的总条数。 */
63
- export interface WriteOffPage<T> {
64
- data: T[] // 当前页的记录列表。
65
- total: number // 符合查询条件的总记录数。
66
- }
67
-
68
- /** 校验公共响应头;retCode 不是 00000 时按服务端提示或 fallback 抛错。 */
69
- export function ensureSuccess<T>(response: ApiResponse<T>, fallback: string): T | null {
70
- if (response?.head?.retCode !== '00000') {
71
- throw new Error(response?.head?.retMsg?.trim() || fallback)
72
- }
73
- return response.body
74
- }
75
-
76
- /**
77
- * 校验查询响应并取出当前页数据;两个查询接口共享同一种 body 结构。
78
- * @param body 查询接口返回的业务数据。
79
- * @param fallback body 缺少 resultList 时的错误提示文本。
80
- */
81
- export function readPage<R extends { resultList?: unknown[]; totalCount?: unknown }>(
82
- body: R | null,
83
- fallback: string,
84
- ): WriteOffPage<NonNullable<R['resultList']>[number]> {
85
- if (!body || !Array.isArray(body.resultList)) throw new Error(fallback)
86
- const totalText = String(body.totalCount ?? body.resultList.length)
87
- const total = Number(totalText)
88
- if (!/^\d+$/.test(totalText) || !Number.isSafeInteger(total)) throw new Error('查询返回的总条数无效')
89
- return { data: body.resultList, total }
90
- }
91
-
92
- /** 提取可展示的错误信息;无法识别时给出统一提示。 */
93
- export function getErrorMessage(error: unknown): string {
94
- if (error instanceof Error && error.message.trim()) return error.message
95
- if (typeof error === 'string' && error.trim()) return error
96
- return '操作失败,请稍后重试'
97
- }
98
-
99
- /** 业务日期始终按上海时区生成,不依赖浏览器所在时区,格式为 YYYYMMDD。 */
100
- export function getToday(): string {
101
- const parts = new Intl.DateTimeFormat('en-CA', {
102
- timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
103
- }).formatToParts(new Date())
104
- const read = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value || ''
105
- return read('year') + read('month') + read('day')
106
- }
107
-
108
- /** 判断是否为合法的 YYYYMMDD 日期。 */
109
- export function isValidDate(value: string): boolean {
110
- if (!/^\d{8}$/.test(value)) return false
111
- const year = Number(value.slice(0, 4))
112
- const month = Number(value.slice(4, 6))
113
- const day = Number(value.slice(6, 8))
114
- if (year < 1 || month < 1 || month > 12 || day < 1) return false
115
- const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0)
116
- const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
117
- return day <= (monthDays[month - 1] || 0)
118
- }
119
-
120
- /** 把 YYYYMMDD 格式化为 YYYY-MM-DD 展示;空值显示横线,其他格式保留原文。 */
121
- export function formatDate(value?: string): string {
122
- const text = value?.trim() || ''
123
- if (!text) return '—'
124
- return isValidDate(text) ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}` : text
125
- }
126
-
127
- /** 只接受十进制文本和合法千分位;保留全部小数位,避免改变输入精度。 */
128
- export function normalizeAmount(value: string): string {
129
- let text = value.trim()
130
- if (text.includes(',')) {
131
- if (!/^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/.test(text)) return ''
132
- text = text.replace(/,/g, '')
133
- }
134
- const match = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(text)
135
- if (!match || (!match[2] && !match[3])) return ''
136
- const integer = (match[2] || '0').replace(/^0+(?=\d)/, '')
137
- const fraction = match[3] || ''
138
- const negative = match[1] === '-' && /[1-9]/.test(integer + fraction)
139
- return `${negative ? '-' : ''}${integer}${fraction ? `.${fraction}` : ''}`
140
- }
141
-
142
- /** 千分位展示至少两位小数,超过两位的小数原样保留,不做浮点数转换或舍入。 */
143
- export function formatAmount(value?: string): string {
144
- const original = value?.trim() || ''
145
- if (!original) return '—'
146
- const normalized = normalizeAmount(original)
147
- if (!normalized) return original
148
- const negative = normalized.startsWith('-')
149
- const [integer = '0', fraction = ''] = (negative ? normalized.slice(1) : normalized).split('.')
150
- const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
151
- return `${negative ? '-' : ''}${grouped}.${fraction.padEnd(2, '0')}`
152
- }
153
-
154
- /** 按统一小数位数转为 BigInt,任意长度的十进制金额都可精确比较。 */
155
- export function compareAmounts(left: string, right: string): number {
156
- const leftText = normalizeAmount(left)
157
- const rightText = normalizeAmount(right)
158
- if (!leftText || !rightText) {
159
- if (!leftText && !rightText) return 0
160
- return leftText ? -1 : 1
161
- }
162
- const parts = (text: string) => {
163
- const negative = text.startsWith('-')
164
- const [integer = '0', fraction = ''] = (negative ? text.slice(1) : text).split('.')
165
- return { negative, integer, fraction }
166
- }
167
- const a = parts(leftText)
168
- const b = parts(rightText)
169
- const precision = Math.max(a.fraction.length, b.fraction.length)
170
- const toInteger = (amount: ReturnType<typeof parts>) =>
171
- BigInt(amount.integer + amount.fraction.padEnd(precision, '0')) * (amount.negative ? -1n : 1n)
172
- const aValue = toInteger(a)
173
- const bValue = toInteger(b)
174
- return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
175
- }
176
-
177
- /** 业务类型字典:201 兑回、202 兑出、203 付现;接口附件已声明,超出字典的值保留原文。 */
178
- export const busiTypeLabels: Record<string, string> = { '201': '兑回', '202': '兑出', '203': '付现' }
179
- /** 收支方向字典:I 来账、O 往账。 */
180
- export const payDirecLabels: Record<string, string> = { I: '来账', O: '往账' }
181
- /** 支付渠道:核销预报为 8、流水为 5,均表示数字人民币;其余渠道保留原文。 */
182
- export const payChannelLabels: Record<string, string> = { '5': '数字人民币', '8': '数字人民币' }
183
- /** 核销状态字典:流水的 1 未核销、2 已核销已在附件声明,预报按同一枚举展示。 */
184
- export const verifyStatusLabels: Record<string, string> = { '1': '未核销', '2': '已核销' }
185
-
186
- /** 读取字典文案;空值显示横线,未登记的代码保留原文展示。 */
187
- export function dictLabel(labels: Record<string, string>, value?: string): string {
188
- const text = value?.trim() || ''
189
- return text ? labels[text] ?? text : '—'
190
- }
191
-
192
- export default API
@@ -1,14 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
3
3
  import { ElMessage } from 'element-plus'
4
- import API, {
4
+ import API from '../api'
5
+ import {
5
6
  busiTypeLabels, compareAmounts, dictLabel, ensureSuccess, formatAmount, formatDate,
6
7
  getErrorMessage, getToday, normalizeAmount, payDirecLabels, readPage, verifyStatusLabels,
7
- } from '../api'
8
+ } from '../utils'
8
9
  import { ArtDataTable, type ColumnOption, type PaginationOptions, type TableSettings } from '@/components/table'
9
10
  import type { FlowRow } from '../typs'
10
11
 
11
- defineOptions({ name: 'FlowWriteOffView' })
12
+ defineOptions({ name: 'DcpsFlowWriteOff' })
12
13
 
13
14
  // 流水信息表格只负责查询和勾选;核销、撤销核销由父页面统一提交。
14
15
 
@@ -1,15 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
3
3
  import { ElMessage } from 'element-plus'
4
- import API, {
4
+ import API from '../api'
5
+ import {
5
6
  busiTypeLabels, compareAmounts, dictLabel, ensureSuccess, formatAmount, formatDate,
6
7
  getErrorMessage, getToday, normalizeAmount, payChannelLabels, payDirecLabels, readPage,
7
8
  verifyStatusLabels,
8
- } from '../api'
9
+ } from '../utils'
9
10
  import { ArtDataTable, type ColumnOption, type PaginationOptions, type TableSettings } from '@/components/table'
10
11
  import type { EditVerifyFormValues, ForecastRow } from '../typs'
11
12
 
12
- defineOptions({ name: 'ForecastWriteOffView' })
13
+ defineOptions({ name: 'DcpsForecastWriteOff' })
13
14
 
14
15
  /** 预报信息表格负责查询、勾选核销预报和逐条修改已核销金额;
15
16
  * 核销、撤销核销按钮在本表格工具栏展示,由父页面读取两个表格的勾选统一提交。 */
@@ -1,13 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
3
  import { ElMessage, ElMessageBox } from 'element-plus'
4
- import API, { ensureSuccess, formatAmount, getErrorMessage, normalizeAmount } from './api'
4
+ import API from './api'
5
+ import { ensureSuccess, formatAmount, getErrorMessage, normalizeAmount } from './utils'
5
6
  import ForecastWriteOffView from './components/ForecastWriteOffView.vue'
6
7
  import FlowWriteOffView from './components/FlowWriteOffView.vue'
7
8
  import type {
8
9
  CancelWriteOffPredItem, FlowRow, ForecastRow, WriteOffFlowInput, WriteOffForecastItem, WriteOffItemInput,
9
10
  } from './typs'
10
11
 
12
+ defineOptions({ name: 'DcpsWriteOffPage' })
13
+
11
14
  // 数字人民币核销页面:上方预报信息表格,下方流水信息表格。
12
15
  // 核销、撤销核销由两个表格的勾选行合并提交,按钮统一放在预报信息表格的工具栏。
13
16
 
@@ -233,16 +236,12 @@ const refreshBoth = async () => {
233
236
  <style scoped>
234
237
  /* 页面先扣除宿主占用高度,再使用 flex 分配剩余高度,让两个表格各占一半内部滚动。 */
235
238
  .dcps-page {
236
- /* 手动修改这里:顶部导航/页签 + 底部状态栏 + 宿主上下留白的合计高度。
237
- 独立页面保持 0px;例如顶部 90px、底部 24px、上下留白 6px,填写 120px。
238
- 不包含浏览器标签栏、地址栏,也不包含本页面自身的 padding。 */
239
- --host-layout-height: 0px;
240
239
  box-sizing: border-box;
241
240
  display: flex;
242
241
  flex-direction: column;
243
242
  gap: 5px;
244
- height: calc(100vh - var(--host-layout-height));
245
- height: calc(100dvh - var(--host-layout-height));
243
+ height: calc(100vh - var(--dpcs-host-layout-height));
244
+ height: calc(100dvh - var(--dpcs-host-layout-height));
246
245
  min-width: 0;
247
246
  min-height: 0;
248
247
  padding: 5px;