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

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 (28) 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 +107 -0
  5. package/src/router/menus.ts +16 -0
  6. package/src/utils/request.ts +3 -1
  7. package/src/view/dcps/README.md +52 -0
  8. package/src/view/dcps/auth/index.vue +151 -14
  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 +18 -148
  12. package/src/view/dcps/estimation/index.vue +312 -13
  13. package/src/view/dcps/forecast/api.ts +21 -218
  14. package/src/view/dcps/forecast/index.vue +996 -50
  15. package/src/view/dcps/injection/index.vue +154 -11
  16. package/src/view/dcps/write-off/api.ts +25 -180
  17. package/src/view/dcps/write-off/index.vue +776 -39
  18. package/src/view/dcps/auth/components/.gitkeep +0 -0
  19. package/src/view/dcps/auth/components/AuthorizationDetailDialog.vue +0 -171
  20. package/src/view/dcps/estimation/components/EstimationDetailDialog.vue +0 -222
  21. package/src/view/dcps/forecast/components/ForecastDetailView.vue +0 -145
  22. package/src/view/dcps/forecast/components/ForecastEditorView.vue +0 -320
  23. package/src/view/dcps/forecast/components/ForecastListView.vue +0 -322
  24. package/src/view/dcps/forecast/components/ForecastPageShell.vue +0 -56
  25. package/src/view/dcps/injection/components/InjectionDetailDialog.vue +0 -171
  26. package/src/view/dcps/runtime.ts +0 -38
  27. package/src/view/dcps/write-off/components/FlowWriteOffView.vue +0 -299
  28. package/src/view/dcps/write-off/components/ForecastWriteOffView.vue +0 -437
@@ -1,66 +1,1012 @@
1
+ <script lang="ts">
2
+ import type { ForecastRow as ForecastRowType } from './typs'
3
+
4
+ /**
5
+ * 列表记录缓存。
6
+ * 详情和修改接口没有返回全部字段,路由跳转前暂存列表记录,用于补齐页面展示和表单回填。
7
+ * 直接打开或刷新详情地址时没有缓存,页面会按预报流水号单独请求详情。
8
+ */
9
+ const rows = new Map<string, ForecastRowType>()
10
+
11
+ const rememberForecastRow = (row: ForecastRowType) => {
12
+ const predRefNo = row.predRefNo?.trim()
13
+ if (predRefNo) rows.set(predRefNo, row)
14
+ }
15
+
16
+ const recallForecastRow = (predRefNo?: string): ForecastRowType | null => {
17
+ const key = predRefNo?.trim()
18
+ return key ? rows.get(key) ?? null : null
19
+ }
20
+ </script>
21
+
1
22
  <script setup lang="ts">
2
- import { ref } from 'vue'
3
- 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'
23
+ import { computed, onActivated, onBeforeUnmount, reactive, ref, watch } from 'vue'
24
+ import { useRouter } from 'vue-router'
25
+ import { ElMessage, ElMessageBox } from 'element-plus'
26
+ import API from './api'
27
+ import { ArtDataTable, type ColumnOption, type PaginationOptions, type TableSettings } from '@/components/table'
28
+ import type {
29
+ ApiResponse,
30
+ } from '@/utils/request'
31
+ import type {
32
+ ForecastCreateBody,
33
+ ForecastDetail,
34
+ ForecastDetailFields,
35
+ ForecastEditBody,
36
+ ForecastFilters,
37
+ ForecastFormValues,
38
+ ForecastListBody,
39
+ ForecastRow,
40
+ } from './typs'
7
41
 
8
- defineOptions({ name: 'DigitalRmbForecastPage' })
42
+ defineOptions({ name: 'DcpsForecastPage' })
9
43
 
10
44
  /**
11
- * 页面外壳:列表、新增、修改和详情都在当前路由内切换,
12
- * 项目未引入路由库,切换方式与侧边菜单一致。
45
+ * 预报页面:列表、新增/修改、详情共用一个入口文件。
46
+ * 路由通过 mode 区分页面形态:create/edit 为表单页,detail 为详情页,缺省为列表。
13
47
  */
14
- const view = ref<'list' | 'editor' | 'detail'>('list')
15
- const editorMode = ref<ForecastEditorMode>('create')
16
- const activeRow = ref<ForecastRow | null>(null) // 修改和详情使用的列表记录。
17
- const listRef = ref<InstanceType<typeof ForecastListView> | null>(null)
48
+ const props = defineProps<{
49
+ mode?: 'create' | 'edit' | 'detail' // 页面形态;缺省为列表页。
50
+ predRefNo?: string // 修改、详情模式的预报流水号,来自路由参数。
51
+ tranDate?: string // 新增模式的默认交易日期,格式为 YYYYMMDD。
52
+ }>()
18
53
 
19
- const openCreate = () => {
20
- editorMode.value = 'create'
21
- activeRow.value = null
22
- view.value = 'editor'
54
+ const router = useRouter()
55
+ const isEditor = computed(() => props.mode === 'create' || props.mode === 'edit')
56
+ const isDetail = computed(() => props.mode === 'detail')
57
+
58
+ // ---------- 页面内工具函数 ----------
59
+
60
+ /** 列表查询结果:当前页记录和符合条件的总条数。 */
61
+ interface ForecastPage {
62
+ data: ForecastRow[] // 当前页的预报记录。
63
+ total: number // 符合查询条件的总记录数。
64
+ }
65
+
66
+ /** 校验公共响应头;retCode 不是 00000 时按服务端提示或 fallback 抛错。 */
67
+ const ensureSuccess = <T,>(response: ApiResponse<T>, fallback: string): T | null => {
68
+ if (response?.head?.retCode !== '00000') {
69
+ throw new Error(response?.head?.retMsg?.trim() || fallback)
70
+ }
71
+ return response.body
72
+ }
73
+
74
+ /** 提取可展示的错误信息;无法识别时给出统一提示。 */
75
+ const getErrorMessage = (error: unknown): string => {
76
+ if (error instanceof Error && error.message.trim()) return error.message
77
+ if (typeof error === 'string' && error.trim()) return error
78
+ return '操作失败,请稍后重试'
79
+ }
80
+
81
+ /** 业务日期始终按上海时区生成,不依赖浏览器所在时区,格式为 YYYYMMDD。 */
82
+ const getToday = (): string => {
83
+ const parts = new Intl.DateTimeFormat('en-CA', {
84
+ timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
85
+ }).formatToParts(new Date())
86
+ const read = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value || ''
87
+ return read('year') + read('month') + read('day')
88
+ }
89
+
90
+ /** 判断是否为合法的 YYYYMMDD 日期。 */
91
+ const isValidDate = (value: string): boolean => {
92
+ if (!/^\d{8}$/.test(value)) return false
93
+ const year = Number(value.slice(0, 4))
94
+ const month = Number(value.slice(4, 6))
95
+ const day = Number(value.slice(6, 8))
96
+ if (year < 1 || month < 1 || month > 12 || day < 1) return false
97
+ const leap = year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0)
98
+ const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
99
+ return day <= (monthDays[month - 1] || 0)
100
+ }
101
+
102
+ /** 把 YYYYMMDD 格式化为 YYYY-MM-DD 展示;空值显示横线,其他格式保留原文。 */
103
+ const formatDate = (value?: string): string => {
104
+ const text = value?.trim() || ''
105
+ if (!text) return '—'
106
+ return isValidDate(text) ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}` : text
107
+ }
108
+
109
+ /** 只接受十进制文本和合法千分位;保留全部小数位,避免改变输入精度。 */
110
+ const normalizeAmount = (value: string): string => {
111
+ let text = value.trim()
112
+ if (text.includes(',')) {
113
+ if (!/^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/.test(text)) return ''
114
+ text = text.replace(/,/g, '')
115
+ }
116
+ const match = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(text)
117
+ if (!match || (!match[2] && !match[3])) return ''
118
+ const integer = (match[2] || '0').replace(/^0+(?=\d)/, '')
119
+ const fraction = match[3] || ''
120
+ const negative = match[1] === '-' && /[1-9]/.test(integer + fraction)
121
+ return `${negative ? '-' : ''}${integer}${fraction ? `.${fraction}` : ''}`
122
+ }
123
+
124
+ /** 千分位展示至少两位小数,超过两位的小数原样保留,不做浮点数转换或舍入。 */
125
+ const formatAmount = (value?: string): string => {
126
+ const original = value?.trim() || ''
127
+ if (!original) return '—'
128
+ const normalized = normalizeAmount(original)
129
+ if (!normalized) return original
130
+ const negative = normalized.startsWith('-')
131
+ const [integer = '0', fraction = ''] = (negative ? normalized.slice(1) : normalized).split('.')
132
+ const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
133
+ return `${negative ? '-' : ''}${grouped}.${fraction.padEnd(2, '0')}`
134
+ }
135
+
136
+ /** 按统一小数位数转为 BigInt,任意长度的十进制金额都可精确比较。 */
137
+ const compareAmounts = (left: string, right: string): number => {
138
+ const leftText = normalizeAmount(left)
139
+ const rightText = normalizeAmount(right)
140
+ if (!leftText || !rightText) {
141
+ if (!leftText && !rightText) return 0
142
+ return leftText ? -1 : 1
143
+ }
144
+ const parts = (text: string) => {
145
+ const negative = text.startsWith('-')
146
+ const [integer = '0', fraction = ''] = (negative ? text.slice(1) : text).split('.')
147
+ return { negative, integer, fraction }
148
+ }
149
+ const a = parts(leftText)
150
+ const b = parts(rightText)
151
+ const precision = Math.max(a.fraction.length, b.fraction.length)
152
+ const toInteger = (amount: ReturnType<typeof parts>) =>
153
+ BigInt(amount.integer + amount.fraction.padEnd(precision, '0')) * (amount.negative ? -1n : 1n)
154
+ const aValue = toInteger(a)
155
+ const bValue = toInteger(b)
156
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
23
157
  }
158
+
159
+ /** 新增和导入使用的表单初始值;日期默认取当天。 */
160
+ const createFormValues = (): ForecastFormValues => {
161
+ const today = getToday()
162
+ return {
163
+ billDate: '', billNo: '', busiType: '', clientName: '', clientType: '',
164
+ competAcct: '', competName: '', custInfoSeq: '', groundBranch: '', groundBranchName: '',
165
+ linkMan: '', payChannel: '', payDirec: '', phoneNo: '', predAmt: '', predDate: today,
166
+ predName: '', predOrgName: '', purpose: '', remark: '', tranDate: today,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 校验表单;新增校验全部必填项,修改只校验已填写的字段。
172
+ * @param values 表单值。
173
+ * @param mode create 为新增,edit 为修改。
174
+ * @returns 错误提示文本,空字符串表示校验通过。
175
+ */
176
+ const validateForm = (values: ForecastFormValues, mode: 'create' | 'edit' = 'create'): string => {
177
+ const requiredFields = [
178
+ ['clientName', '客户名称'], ['payDirec', '收支方向'], ['busiType', '业务类型'],
179
+ ['payChannel', '支付渠道'], ['groundBranch', '落地机构'],
180
+ ] as const
181
+ if (mode === 'create') {
182
+ for (const [key, label] of requiredFields) {
183
+ if (!values[key].trim()) return `请填写${label}`
184
+ }
185
+ }
186
+ for (const [key, label] of [['tranDate', '交易日期'], ['predDate', '预报日期']] as const) {
187
+ const date = values[key].trim()
188
+ if ((date || mode === 'create') && !isValidDate(date)) return `${label}须为有效日期,格式为 YYYYMMDD`
189
+ }
190
+ if (values.billDate.trim() && !isValidDate(values.billDate.trim())) {
191
+ return '出票日须为有效日期,格式为 YYYYMMDD'
192
+ }
193
+ if (values.predAmt.trim() || mode === 'create') {
194
+ const amount = normalizeAmount(values.predAmt)
195
+ if (!amount || amount.startsWith('-') || (amount.split('.')[1]?.length || 0) > 2) {
196
+ return '预报金额须为非负金额,最多保留两位小数'
197
+ }
198
+ }
199
+ return ''
200
+ }
201
+
202
+ /** 读取详情并与列表记录合并:详情没有的字段由同一条列表记录补充。 */
203
+ const fetchDetail = async (row: ForecastRow): Promise<ForecastDetail> => {
204
+ if (!row.predRefNo?.trim()) throw new Error('预报流水号缺失,无法读取详情')
205
+ const body = ensureSuccess<any>(await API.getDetailApi({ predRefNo: row.predRefNo }), '读取预报详情失败')
206
+ if (!body || !Object.keys(body).length) throw new Error('服务端未返回预报详情')
207
+ if (body.predRefNo && body.predRefNo !== row.predRefNo) throw new Error('返回的预报流水号与当前记录不一致')
208
+ const definedBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined && value !== null))
209
+ return { ...row, ...definedBody } as ForecastDetail
210
+ }
211
+
212
+ /** 校验列表响应并取出当前页数据;列表缺失或总条数无效时抛出可提示的错误。 */
213
+ const readPage = (body: ForecastListBody | null): ForecastPage => {
214
+ if (!body || !Array.isArray(body.resultList)) throw new Error('查询结果缺少预报列表,请联系服务端确认返回数据')
215
+ const totalText = String(body.totalCount ?? body.resultList.length)
216
+ const total = Number(totalText)
217
+ if (!/^\d+$/.test(totalText) || !Number.isSafeInteger(total)) throw new Error('查询返回的总条数无效')
218
+ return { data: body.resultList, total }
219
+ }
220
+
221
+ let disposed = false
222
+ onBeforeUnmount(() => { disposed = true })
223
+
224
+ // ---------- 列表页 ----------
225
+
226
+ type ListMutation = 'delete' | null
227
+ const createFilters = (): ForecastFilters => ({
228
+ tranDate: getToday(), clientName: '', competName: '', payDirec: '', busiType: '', payChannel: '',
229
+ groundBranch: '', predStatus: '', verifyStatus: '', startTransAmt: '', endTransAmt: '', sortRule: '',
230
+ })
231
+ const listFilters = ref(createFilters())
232
+ let listAppliedQuery: ForecastFilters = { ...listFilters.value }
233
+ // 重复输入项共用一份配置,依次为字段、名称;输入框较窄,占位文案统一使用简短提示。
234
+ const listTextFilters = [
235
+ ['clientName', '客户名称'],
236
+ ['competName', '交易对手名称'],
237
+ ['payDirec', '收支方向'],
238
+ ['busiType', '业务类型'],
239
+ ['payChannel', '支付渠道'],
240
+ ['groundBranch', '落地机构'],
241
+ ['predStatus', '预报状态'],
242
+ ['verifyStatus', '核销状态'],
243
+ ['sortRule', '排序规则'],
244
+ ] as const
245
+ const listRows = ref<ForecastRow[]>([])
246
+ const listSelectedRows = ref<ForecastRow[]>([])
247
+ const listPagination = reactive({ current: 1, size: 15, total: 0 })
248
+ const listPaginationOptions: PaginationOptions = { align: 'right', pageSizes: [15, 30, 50, 100], background: false }
249
+ const listTableSettings = ref<TableSettings>({ size: 'small', stripe: true, border: true, headerBackground: true })
250
+ const listShowSearchBar = ref(true)
251
+ const listLoading = ref(false)
252
+ const listQueryError = ref('')
253
+ const listMutation = ref<ListMutation>(null)
254
+ const listBusy = computed(() => listMutation.value !== null)
255
+ const listTableRef = ref<InstanceType<typeof ArtDataTable> | null>(null)
256
+ let listRequestId = 0
257
+
258
+ const listHasReference = (row: ForecastRow) => Boolean(row.predRefNo?.trim())
259
+ const listTextColumn = (prop: keyof ForecastRow, label: string, width: number, visible = true): ColumnOption<ForecastRow> => ({
260
+ prop, label, width, visible, showOverflowTooltip: true,
261
+ formatter: (row) => row[prop]?.trim() || '—',
262
+ })
263
+ const listColumns: ColumnOption<ForecastRow>[] = [
264
+ { type: 'selection', width: 42, fixed: 'left', selectable: listHasReference },
265
+ { type: 'globalIndex', label: '序号', width: 60, align: 'center' },
266
+ { ...listTextColumn('predRefNo', '预报流水号', 190), useSlot: true, slotName: 'reference' },
267
+ { ...listTextColumn('tranDate', '交易日期', 110), formatter: (row) => formatDate(row.tranDate) },
268
+ listTextColumn('clientName', '客户名称', 180),
269
+ listTextColumn('payDirec', '收支方向', 95),
270
+ listTextColumn('busiType', '业务类型', 110),
271
+ listTextColumn('payChannel', '支付渠道', 110),
272
+ { prop: 'predAmt', label: '预报金额(元)', width: 180, align: 'right', useSlot: true, slotName: 'amount' },
273
+ { prop: 'verifyAmt', label: '已核销金额(元)', width: 180, align: 'right', useSlot: true, slotName: 'verifiedAmount' },
274
+ listTextColumn('competName', '交易对手名称', 180),
275
+ listTextColumn('groundBranch', '落地机构', 160),
276
+ listTextColumn('predStatus', '预报状态', 100),
277
+ listTextColumn('verifyStatus', '核销状态', 100),
278
+ listTextColumn('competAcct', '交易对手账号', 190, false),
279
+ listTextColumn('clientType', '客户类型', 100, false),
280
+ listTextColumn('groundBranchCode', '落地机构代码', 150, false),
281
+ { ...listTextColumn('predDate', '预报日期', 110, false), formatter: (row) => formatDate(row.predDate) },
282
+ listTextColumn('predCode', '预报人员编号', 130, false),
283
+ listTextColumn('standardName', '标准名称', 150, false),
284
+ { key: 'actions', label: '操作', width: 165, fixed: 'right', align: 'center', useSlot: true },
285
+ ]
286
+
287
+ const clearListSelection = () => {
288
+ listSelectedRows.value = []
289
+ listTableRef.value?.getElTable()?.clearSelection()
290
+ }
291
+
292
+ // 请求序号防止旧响应覆盖新结果;页码越界时最多补查一次。
293
+ const loadList = async () => {
294
+ if (disposed) return
295
+ const currentId = ++listRequestId
296
+ const query = { ...listAppliedQuery }
297
+ const pageSize = listPagination.size
298
+ let pageIndex = listPagination.current
299
+ listLoading.value = true
300
+ listQueryError.value = ''
301
+ clearListSelection()
302
+ try {
303
+ for (let attempt = 0; attempt < 2; attempt += 1) {
304
+ const response = await API.queryListApi({ ...query, opType: 'query', pageIndex, pageSize })
305
+ if (disposed || currentId !== listRequestId) return
306
+ const page = readPage(ensureSuccess(response, '预报查询失败'))
307
+ const lastPage = Math.max(1, Math.ceil(page.total / pageSize))
308
+ if (pageIndex > lastPage && attempt === 0) {
309
+ pageIndex = lastPage
310
+ continue
311
+ }
312
+ listPagination.current = pageIndex
313
+ listPagination.total = page.total
314
+ listRows.value = page.data
315
+ return
316
+ }
317
+ } catch (error) {
318
+ if (disposed || currentId !== listRequestId) return
319
+ listRows.value = []
320
+ listPagination.total = 0
321
+ listQueryError.value = getErrorMessage(error)
322
+ } finally {
323
+ if (!disposed && currentId === listRequestId) listLoading.value = false
324
+ }
325
+ }
326
+
327
+ // 新查询从第一页开始;重置后复用同一查询流程。
328
+ const searchList = () => {
329
+ if (listBusy.value) return
330
+ const next = Object.fromEntries(Object.entries(listFilters.value).map(([key, value]) => [key, String(value ?? '').trim()])) as ForecastFilters
331
+ if (!/^\d{8}$/.test(next.tranDate)) return void ElMessage.warning('请选择交易日期')
332
+ for (const key of ['startTransAmt', 'endTransAmt'] as const) {
333
+ if (!next[key]) continue
334
+ const normalized = normalizeAmount(next[key])
335
+ if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) return void ElMessage.warning('金额范围请输入非负数,最多保留两位小数')
336
+ next[key] = normalized
337
+ }
338
+ if (next.startTransAmt && next.endTransAmt && compareAmounts(next.startTransAmt, next.endTransAmt) > 0) {
339
+ return void ElMessage.warning('起始金额不能大于截止金额')
340
+ }
341
+ listAppliedQuery = next
342
+ listPagination.current = 1
343
+ void loadList()
344
+ }
345
+ const resetListFilters = () => {
346
+ listFilters.value = createFilters()
347
+ searchList()
348
+ }
349
+ // 更新分页;current 为目标页码,size 为每页条数,切换条数时 current 传入 1。
350
+ const changeListPage = (current: number, size = listPagination.size) => {
351
+ if (listBusy.value) return
352
+ listPagination.current = current
353
+ listPagination.size = size
354
+ void loadList()
355
+ }
356
+
357
+ // 行内操作交给路由页面处理,这里只校验记录是否可用;整行记录用于补齐详情接口没有返回的字段。
24
358
  const openEdit = (row: ForecastRow) => {
25
- editorMode.value = 'edit'
26
- activeRow.value = row
27
- view.value = 'editor'
359
+ if (listBusy.value || !listHasReference(row)) return
360
+ const predRefNo = row.predRefNo?.trim()
361
+ if (!predRefNo) return
362
+ rememberForecastRow(row)
363
+ void router.push({ name: 'forecast-edit', params: { predRefNo } })
28
364
  }
29
365
  const openDetail = (row: ForecastRow) => {
30
- activeRow.value = row
31
- view.value = 'detail'
366
+ if (listBusy.value || !listHasReference(row)) return
367
+ const predRefNo = row.predRefNo?.trim()
368
+ if (!predRefNo) return
369
+ rememberForecastRow(row)
370
+ void router.push({ name: 'forecast-detail', params: { predRefNo } })
32
371
  }
33
- // 返回列表时保留列表页状态;保存成功后回到列表并重新查询。
34
- const backToList = () => { view.value = 'list' }
35
- const onSaved = () => {
36
- backToList()
37
- void listRef.value?.refresh()
372
+ const openCreate = () => {
373
+ if (listBusy.value) return
374
+ const tranDate = listAppliedQuery.tranDate
375
+ void router.push({ name: 'forecast-create', query: tranDate ? { tranDate } : {} })
38
376
  }
377
+
378
+ async function deleteRecords(records: ForecastRow[]) {
379
+ if (listBusy.value || !records.length) return
380
+ const references = [...new Set(records.map((row) => row.predRefNo?.trim()).filter((value): value is string => Boolean(value)))]
381
+ if (!references.length) return void ElMessage.warning('选中记录缺少预报流水号')
382
+ listMutation.value = 'delete'
383
+ try {
384
+ await ElMessageBox.confirm(`确认删除选中的 ${references.length} 条预报记录?删除后将重新查询列表。`, '删除预报', {
385
+ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning', distinguishCancelAndClose: true,
386
+ })
387
+ if (disposed) return
388
+ ensureSuccess(await API.deleteApi(references), '删除预报失败')
389
+ if (disposed) return
390
+ ElMessage.success('删除请求已处理')
391
+ await loadList()
392
+ } catch (error) {
393
+ if (!disposed && error !== 'cancel' && error !== 'close') ElMessage.error(getErrorMessage(error))
394
+ } finally {
395
+ if (!disposed) listMutation.value = null
396
+ }
397
+ }
398
+
399
+ // 列表页由 KeepAlive 缓存;每次回到列表时重新查询,保留筛选条件和分页。
400
+ onActivated(() => {
401
+ if (!isEditor.value && !isDetail.value) void loadList()
402
+ })
403
+
404
+ // ---------- 新增 / 修改页 ----------
405
+
406
+ type FieldKey = Exclude<keyof ForecastFormValues, 'custRefSeq'>
407
+ type EditorField = {
408
+ key: FieldKey
409
+ label: string
410
+ kind?: 'date' | 'amount' | 'textarea'
411
+ placeholder?: string
412
+ createOnly?: boolean
413
+ }
414
+
415
+ const editorMode = computed<'create' | 'edit'>(() => props.mode === 'edit' ? 'edit' : 'create')
416
+
417
+ /** 修改时允许提交的字段;与新增接口保持同一组业务字段。 */
418
+ const editableFields = [
419
+ 'billNo', 'busiType', 'clientName', 'clientType', 'competAcct', 'competName', 'groundBranch',
420
+ 'groundBranchName', 'linkMan', 'payChannel', 'payDirec', 'phoneNo', 'predAmt', 'predDate',
421
+ 'purpose', 'remark', 'tranDate',
422
+ ] as const
423
+
424
+ const fieldGroups: { title: string; fields: EditorField[] }[] = [
425
+ {
426
+ title: '基本预报',
427
+ fields: [
428
+ { key: 'tranDate', label: '交易日期', kind: 'date' },
429
+ { key: 'predDate', label: '预报日期', kind: 'date' },
430
+ { key: 'predAmt', label: '预报金额(元)', kind: 'amount', placeholder: '请输入金额,最多两位小数' },
431
+ { key: 'payDirec', label: '收支方向代码', placeholder: '请输入收支方向代码' },
432
+ { key: 'busiType', label: '业务类型代码', placeholder: '请输入业务类型代码' },
433
+ { key: 'payChannel', label: '支付渠道代码', placeholder: '请输入支付渠道代码' },
434
+ { key: 'clientName', label: '客户名称' },
435
+ { key: 'clientType', label: '客户类型代码', placeholder: '请输入客户类型代码' },
436
+ ],
437
+ },
438
+ {
439
+ title: '机构与客户',
440
+ fields: [
441
+ { key: 'groundBranch', label: '落地机构代码' },
442
+ { key: 'groundBranchName', label: '落地机构名称' },
443
+ { key: 'competName', label: '交易对手名称' },
444
+ { key: 'competAcct', label: '交易对手账号' },
445
+ { key: 'custInfoSeq', label: '客户信息流水号' },
446
+ { key: 'linkMan', label: '联系人' },
447
+ { key: 'phoneNo', label: '联系电话' },
448
+ ],
449
+ },
450
+ {
451
+ title: '其他信息',
452
+ fields: [
453
+ { key: 'predName', label: '预报人员姓名', createOnly: true },
454
+ { key: 'predOrgName', label: '预报机构名称', createOnly: true },
455
+ { key: 'billNo', label: '票号' },
456
+ { key: 'billDate', label: '出票日期', kind: 'date' },
457
+ { key: 'purpose', label: '用途', kind: 'textarea' },
458
+ { key: 'remark', label: '备注', kind: 'textarea' },
459
+ ],
460
+ },
461
+ ]
462
+
463
+ const form = reactive<ForecastFormValues>(createFormValues())
464
+ const formError = ref('')
465
+ const editorLoading = ref(false) // 读取修改前的原始数据。
466
+ const submitting = ref(false) // 提交新增或修改。
467
+ const loadError = ref('')
468
+ const initialValues = ref<Partial<ForecastFormValues>>({})
469
+ const requiredFields = new Set<FieldKey>(['clientName', 'payDirec', 'busiType', 'payChannel', 'groundBranch', 'tranDate', 'predDate', 'predAmt'])
470
+ const editorDisabled = computed(() => editorLoading.value || submitting.value)
471
+ const editorPredRefNo = computed(() => props.predRefNo?.trim() || '')
472
+ const editorTitle = computed(() => editorMode.value === 'create' ? '新增数字人民币头寸预报' : '修改数字人民币头寸预报')
473
+ const visibleGroups = computed(() => fieldGroups.map(group => ({
474
+ ...group,
475
+ fields: group.fields.filter(field => !field.createOnly || editorMode.value === 'create'),
476
+ })))
477
+
478
+ const initialFieldValue = (key: FieldKey) => {
479
+ if (key === 'custInfoSeq') return initialValues.value.custInfoSeq ?? initialValues.value.custRefSeq
480
+ return initialValues.value[key]
481
+ }
482
+
483
+ // 修改模式下接口没有返回的字段留空保存,避免用空值覆盖服务端已有内容。
484
+ const isMissingField = (key: FieldKey) => editorMode.value === 'edit' && initialFieldValue(key) === undefined
485
+
486
+ const fieldPlaceholder = (field: EditorField) => {
487
+ if (isMissingField(field.key)) return '未返回,留空保留原值'
488
+ if (field.kind === 'date') return '请选择日期'
489
+ return field.placeholder || `请输入${field.label}`
490
+ }
491
+
492
+ // 新增模式带出默认日期;修改模式只回填接口返回的字段。
493
+ const initializeForm = () => {
494
+ const nextValues = createFormValues()
495
+ if (editorMode.value === 'create') {
496
+ nextValues.tranDate = props.tranDate || getToday()
497
+ nextValues.predDate = getToday()
498
+ } else {
499
+ for (const key of Object.keys(nextValues) as FieldKey[]) {
500
+ const value = initialFieldValue(key)
501
+ if (value !== undefined) nextValues[key] = value
502
+ }
503
+ }
504
+ delete nextValues.custRefSeq
505
+ Object.assign(form, nextValues)
506
+ formError.value = ''
507
+ }
508
+
509
+ // 详情接口没有的字段由同一条列表记录补充,这里只挑选可提交的字符串字段。
510
+ const buildInitialValues = (detail: ForecastDetail): Partial<ForecastFormValues> => {
511
+ const initial: Partial<ForecastFormValues> = {}
512
+ for (const key of editableFields) {
513
+ const value = detail[key as keyof ForecastDetail]
514
+ if (typeof value === 'string') initial[key] = value
515
+ }
516
+ if (detail.groundBranchCode) initial.groundBranch = detail.groundBranchCode
517
+ if (detail.billSignDate !== undefined) initial.billDate = detail.billSignDate
518
+ const customerFields = detail as ForecastDetailFields
519
+ const customerReference = customerFields.custRefSeq ?? customerFields.custInfoSeq
520
+ if (typeof customerReference === 'string') initial.custInfoSeq = customerReference
521
+ return initial
522
+ }
523
+
524
+ // 修改模式先读取原始数据,再回填表单;读取失败时停留在页面提示原因。
525
+ const loadEditorDetail = async () => {
526
+ if (editorMode.value !== 'edit') return
527
+ if (!editorPredRefNo.value) {
528
+ loadError.value = '预报流水号缺失,无法修改'
529
+ return
530
+ }
531
+ editorLoading.value = true
532
+ loadError.value = ''
533
+ try {
534
+ const cachedRow = recallForecastRow(editorPredRefNo.value)
535
+ const detailRow: ForecastRow = { ...cachedRow, predRefNo: editorPredRefNo.value }
536
+ initialValues.value = buildInitialValues(await fetchDetail(detailRow))
537
+ if (disposed) return
538
+ initializeForm()
539
+ } catch (error) {
540
+ if (!disposed) loadError.value = getErrorMessage(error)
541
+ } finally {
542
+ if (!disposed) editorLoading.value = false
543
+ }
544
+ }
545
+
546
+ // 新增或修改页每次进入都重新初始化(页面实例由 KeepAlive 缓存)。
547
+ onActivated(() => {
548
+ if (!isEditor.value) return
549
+ if (editorMode.value === 'create') initializeForm()
550
+ else void loadEditorDetail()
551
+ })
552
+
553
+ watch(form, () => { formError.value = '' }, { deep: true })
554
+
555
+ const backToList = () => void router.replace({ name: 'forecast' })
556
+
557
+ // 日期控件清空时可能产生 null,提交前统一回到协议要求的字符串。
558
+ const submit = async () => {
559
+ if (editorDisabled.value || loadError.value) return
560
+ const values = Object.fromEntries(Object.entries(form).map(([key, value]) => [key, String(value ?? '').trim()])) as ForecastFormValues
561
+ formError.value = validateForm(values, editorMode.value)
562
+ if (!formError.value && editorMode.value === 'edit') {
563
+ const clearedField = fieldGroups.flatMap(group => group.fields).find(field =>
564
+ requiredFields.has(field.key) && initialFieldValue(field.key)?.trim() && !values[field.key],
565
+ )
566
+ if (clearedField) formError.value = `请填写${clearedField.label}`
567
+ }
568
+ if (formError.value) return
569
+
570
+ submitting.value = true
571
+ try {
572
+ if (editorMode.value === 'create') {
573
+ const body: ForecastCreateBody = {}
574
+ for (const [key, value] of Object.entries(values)) {
575
+ if (key !== 'custRefSeq' && value?.trim()) Object.assign(body, { [key]: value.trim() })
576
+ }
577
+ if (body.predAmt) body.predAmt = normalizeAmount(body.predAmt)
578
+ ensureSuccess(await API.addApi(body), '新增预报失败')
579
+ } else {
580
+ if (!editorPredRefNo.value) throw new Error('预报流水号缺失,无法修改')
581
+ const body: ForecastEditBody = { predRefSeq: editorPredRefNo.value }
582
+ for (const key of [...editableFields, 'billDate'] as const) {
583
+ const value = values[key]?.trim() || ''
584
+ if (Object.hasOwn(initialValues.value, key) || value) Object.assign(body, { [key]: value })
585
+ }
586
+ const customerReference = values.custRefSeq?.trim() || values.custInfoSeq.trim()
587
+ if (Object.hasOwn(initialValues.value, 'custInfoSeq') || Object.hasOwn(initialValues.value, 'custRefSeq') || customerReference) {
588
+ body.custRefSeq = customerReference
589
+ }
590
+ if (body.predAmt) body.predAmt = normalizeAmount(body.predAmt)
591
+ ensureSuccess(await API.editApi(body), '修改预报失败')
592
+ }
593
+ if (disposed) return
594
+ ElMessage.success(editorMode.value === 'create' ? '新增请求已处理' : '修改请求已处理')
595
+ await router.replace({ name: 'forecast' })
596
+ } catch (error) {
597
+ if (!disposed) ElMessage.error(getErrorMessage(error))
598
+ } finally {
599
+ if (!disposed) submitting.value = false
600
+ }
601
+ }
602
+
603
+ // ---------- 详情页 ----------
604
+
605
+ type DetailField = { key: keyof ForecastDetail; label: string; kind?: 'date' | 'amount' | 'status' }
606
+
607
+ const detailGroups: { title: string; fields: DetailField[] }[] = [
608
+ {
609
+ title: '预报信息',
610
+ fields: [
611
+ { key: 'predRefNo', label: '预报流水号' },
612
+ { key: 'predDate', label: '预报日期', kind: 'date' },
613
+ { key: 'tranDate', label: '交易日期', kind: 'date' },
614
+ { key: 'predAmt', label: '预报金额(元)', kind: 'amount' },
615
+ { key: 'predCode', label: '预报人员编号' },
616
+ { key: 'standardName', label: '标准名称' },
617
+ { key: 'predStatus', label: '预报状态代码', kind: 'status' },
618
+ { key: 'verifyStatus', label: '核销状态代码', kind: 'status' },
619
+ { key: 'verifyAmt', label: '已核销金额(元)', kind: 'amount' },
620
+ { key: 'edusFlag', label: '释放状态代码', kind: 'status' },
621
+ ],
622
+ },
623
+ {
624
+ title: '业务与客户',
625
+ fields: [
626
+ { key: 'busiType', label: '业务类型代码' },
627
+ { key: 'payDirec', label: '收支方向代码' },
628
+ { key: 'payChannel', label: '支付渠道代码' },
629
+ { key: 'clientName', label: '客户名称' },
630
+ { key: 'clientType', label: '客户类型代码' },
631
+ { key: 'competName', label: '交易对手名称' },
632
+ { key: 'competAcct', label: '交易对手账号' },
633
+ ],
634
+ },
635
+ {
636
+ title: '机构与票据',
637
+ fields: [
638
+ { key: 'groundBranch', label: '落地机构' },
639
+ { key: 'groundBranchCode', label: '机构代码' },
640
+ { key: 'groundBranchName', label: '落地机构名称' },
641
+ { key: 'billNo', label: '票号' },
642
+ { key: 'billSignDate', label: '出票日期', kind: 'date' },
643
+ ],
644
+ },
645
+ ]
646
+
647
+ const detail = ref<ForecastDetail | null>(null)
648
+ const detailLoading = ref(false)
649
+ const detailError = ref('')
650
+ const detailPredRefNo = computed(() => props.predRefNo?.trim() || '')
651
+ const detailTitle = computed(() => detailPredRefNo.value ? `预报详情 · ${detailPredRefNo.value}` : '数字人民币头寸预报详情')
652
+ const detailRow = computed<ForecastRow>(() => ({ ...recallForecastRow(detailPredRefNo.value), predRefNo: detailPredRefNo.value }))
653
+
654
+ const openEditFromDetail = () => {
655
+ const predRefNo = detailPredRefNo.value
656
+ if (!predRefNo) return
657
+ const row = recallForecastRow(predRefNo)
658
+ if (row) rememberForecastRow(row)
659
+ void router.push({ name: 'forecast-edit', params: { predRefNo } })
660
+ }
661
+
662
+ const displayValue = (field: DetailField) => {
663
+ const value = detail.value?.[field.key]
664
+ if (value === undefined || value === null || value === '') return '—'
665
+ if (field.kind === 'amount') return formatAmount(value)
666
+ if (field.kind === 'date') return formatDate(value)
667
+ return String(value)
668
+ }
669
+
670
+ const loadDetail = async () => {
671
+ if (detailLoading.value || !detailPredRefNo.value) return
672
+ detailLoading.value = true
673
+ detail.value = null
674
+ detailError.value = ''
675
+ try {
676
+ const result = await fetchDetail(detailRow.value)
677
+ if (!disposed) detail.value = result
678
+ } catch (requestError) {
679
+ if (!disposed) detailError.value = getErrorMessage(requestError)
680
+ } finally {
681
+ if (!disposed) detailLoading.value = false
682
+ }
683
+ }
684
+
685
+ // 详情页每次进入都按预报流水号读取最新数据(页面实例由 KeepAlive 缓存)。
686
+ onActivated(() => {
687
+ if (isDetail.value) void loadDetail()
688
+ })
39
689
  </script>
40
690
 
41
691
  <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
- />
692
+ <!-- 新增 / 修改页 -->
693
+ <main v-if="isEditor" class="page-shell">
694
+ <el-card class="shell-header" shadow="never" :body-style="{ padding: '10px' }">
695
+ <div class="shell-heading">
696
+ <el-button class="shell-back" :disabled="submitting" @click="backToList">
697
+ <svg class="shell-back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
698
+ <path d="M14 5l-7 7 7 7" />
699
+ </svg>
700
+ 返回列表
701
+ </el-button>
702
+ <h1 class="shell-title">{{ editorTitle }}</h1>
703
+ </div>
704
+ </el-card>
705
+ <el-card class="editor-card" shadow="never" :body-style="{ padding: '12px 16px' }">
706
+ <div class="editor-body">
707
+ <div v-loading="editorLoading" class="editor-scroll">
708
+ <el-alert v-if="loadError" class="editor-load-error" :title="loadError" type="error" show-icon :closable="false">
709
+ <el-button link type="primary" :disabled="editorDisabled" @click="loadEditorDetail">重新加载</el-button>
710
+ <el-button link type="primary" :disabled="editorDisabled" @click="backToList">返回列表</el-button>
711
+ </el-alert>
712
+ <template v-else>
713
+ <p class="editor-note">金额单位为元,方向、业务类型、支付渠道和客户类型按业务代码填写。</p>
714
+ <el-form :model="form" size="small" label-position="top" :disabled="editorDisabled" @submit.prevent="submit">
715
+ <section v-for="group in visibleGroups" :key="group.title" class="editor-group" :aria-label="group.title">
716
+ <h3>{{ group.title }}</h3>
717
+ <div class="editor-fields">
718
+ <el-form-item
719
+ v-for="field in group.fields"
720
+ :key="field.key"
721
+ :prop="field.key"
722
+ :required="editorMode === 'create' && requiredFields.has(field.key)"
723
+ :class="{ 'editor-field--wide': field.kind === 'textarea' }"
724
+ >
725
+ <template #label>
726
+ <span>{{ field.label }}</span>
727
+ <span v-if="isMissingField(field.key)" class="field-unavailable">未返回</span>
728
+ </template>
729
+ <el-date-picker
730
+ v-if="field.kind === 'date'"
731
+ v-model="form[field.key]"
732
+ type="date"
733
+ format="YYYY-MM-DD"
734
+ value-format="YYYYMMDD"
735
+ :placeholder="fieldPlaceholder(field)"
736
+ :editable="false"
737
+ clearable
738
+ />
739
+ <el-input
740
+ v-else-if="field.kind === 'textarea'"
741
+ v-model="form[field.key]"
742
+ type="textarea"
743
+ :rows="2"
744
+ :placeholder="fieldPlaceholder(field)"
745
+ resize="vertical"
746
+ />
747
+ <el-input
748
+ v-else
749
+ v-model="form[field.key]"
750
+ :inputmode="field.kind === 'amount' ? 'decimal' : 'text'"
751
+ :placeholder="fieldPlaceholder(field)"
752
+ clearable
753
+ autocomplete="off"
754
+ />
755
+ </el-form-item>
756
+ </div>
757
+ </section>
758
+ </el-form>
759
+ </template>
760
+ </div>
761
+
762
+ <div class="editor-footer">
763
+ <p v-if="formError" class="editor-error" role="alert" aria-live="polite">{{ formError }}</p>
764
+ <span v-else class="editor-tip">{{ editorMode === 'create' ? '带 * 的字段为新增必填项。' : '留空的字段表示不修改该字段的原值。' }}</span>
765
+ <el-space :size="8">
766
+ <el-button :disabled="submitting" @click="backToList">返回列表</el-button>
767
+ <el-button type="primary" :disabled="editorDisabled || !!loadError" :loading="submitting" @click="submit">
768
+ {{ editorMode === 'create' ? '新增预报' : '保存修改' }}
769
+ </el-button>
770
+ </el-space>
771
+ </div>
772
+ </div>
773
+ </el-card>
774
+ </main>
775
+
776
+ <!-- 详情页 -->
777
+ <main v-else-if="isDetail" class="page-shell">
778
+ <el-card class="shell-header" shadow="never" :body-style="{ padding: '10px' }">
779
+ <div class="shell-heading">
780
+ <el-button class="shell-back" :disabled="detailLoading" @click="backToList">
781
+ <svg class="shell-back-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
782
+ <path d="M14 5l-7 7 7 7" />
783
+ </svg>
784
+ 返回列表
785
+ </el-button>
786
+ <h1 class="shell-title">{{ detailTitle }}</h1>
787
+ <el-button v-if="detail" type="primary" :disabled="detailLoading" @click="openEditFromDetail">修改预报</el-button>
788
+ </div>
789
+ </el-card>
790
+ <el-card class="detail-card" shadow="never" :body-style="{ padding: '12px 16px' }">
791
+ <div v-loading="detailLoading" class="detail-scroll">
792
+ <div v-if="detailError" class="detail-error" role="alert">
793
+ <el-alert :title="detailError" type="error" :closable="false" show-icon />
794
+ <el-button size="small" :disabled="detailLoading" @click="loadDetail">重新加载</el-button>
795
+ </div>
796
+ <div v-else-if="detail" class="detail-content">
797
+ <!-- group 为当前详情分组,fields 中的每项对应一个字段。 -->
798
+ <section v-for="group in detailGroups" :key="group.title" class="detail-group" :aria-label="group.title">
799
+ <h3>{{ group.title }}</h3>
800
+ <dl class="detail-fields">
801
+ <div v-for="field in group.fields" :key="field.key" class="detail-field">
802
+ <dt>{{ field.label }}</dt>
803
+ <dd :class="{ 'detail-amount': field.kind === 'amount', 'detail-status': field.kind === 'status' }">{{ displayValue(field) }}</dd>
804
+ </div>
805
+ </dl>
806
+ </section>
807
+ </div>
808
+ <el-empty v-else-if="!detailLoading" description="暂无预报详情" :image-size="64" />
809
+ </div>
810
+ </el-card>
811
+ </main>
812
+
813
+ <!-- 列表页 -->
814
+ <main v-else class="dcps-page">
815
+ <!-- 查询条件:紧凑排列为一整行,控件尺寸跟随表格设置,卡片内边距为 10px,点击查询生效。 -->
816
+ <el-card v-show="listShowSearchBar" class="forecast-search" shadow="never" :body-style="{ padding: '10px' }">
817
+ <el-form class="filter-form" :model="listFilters" :size="listTableSettings.size" label-position="top" @submit.prevent="searchList">
818
+ <el-form-item class="filter-date" label="交易日期" required>
819
+ <el-date-picker v-model="listFilters.tranDate" type="date" format="YYYY-MM-DD" value-format="YYYYMMDD" :clearable="false" :editable="false" />
820
+ </el-form-item>
821
+ <!-- label 为查询字段名称,输入框较窄,占位文案统一为简短提示。 -->
822
+ <el-form-item v-for="[key, label] in listTextFilters" :key="key" :label="label">
823
+ <el-input v-model="listFilters[key]" placeholder="请输入" :maxlength="200" clearable />
824
+ </el-form-item>
825
+ <el-form-item class="filter-amount" label="起始金额(元)">
826
+ <el-input v-model="listFilters.startTransAmt" placeholder="不限制" inputmode="decimal" :maxlength="40" clearable />
827
+ </el-form-item>
828
+ <el-form-item class="filter-amount" label="截止金额(元)">
829
+ <el-input v-model="listFilters.endTransAmt" placeholder="不限制" inputmode="decimal" :maxlength="40" clearable />
830
+ </el-form-item>
831
+ <el-space class="filter-actions" :size="8">
832
+ <el-button :disabled="listBusy" @click="resetListFilters">重置</el-button>
833
+ <el-button type="primary" native-type="submit" :loading="listLoading" :disabled="listBusy">查询</el-button>
834
+ </el-space>
835
+ </el-form>
836
+ </el-card>
837
+
838
+ <el-alert v-if="listQueryError" :title="listQueryError" type="error" show-icon :closable="false">
839
+ <el-button link type="primary" :disabled="listBusy" @click="loadList">重新查询</el-button>
840
+ </el-alert>
841
+
842
+ <div class="forecast-table-region">
843
+ <ArtDataTable
844
+ ref="listTableRef"
845
+ v-model:settings="listTableSettings"
846
+ v-model:show-search-bar="listShowSearchBar"
847
+ class="forecast-table"
848
+ fill
849
+ title="预报记录"
850
+ toolbar-layout="search,refresh,size,fullscreen,columns,settings"
851
+ :data="listRows"
852
+ :columns="listColumns"
853
+ :loading="listLoading || listBusy"
854
+ :pagination="listPagination"
855
+ :pagination-options="listPaginationOptions"
856
+ row-key="predRefNo"
857
+ :empty-text="listQueryError ? '查询失败,请重新查询' : '暂无符合条件的预报记录'"
858
+ @selection-change="(selection: ForecastRow[]) => listSelectedRows = selection"
859
+ @pagination:current-change="changeListPage"
860
+ @pagination:size-change="(size: number) => changeListPage(1, size)"
861
+ @refresh="!listBusy && loadList()"
862
+ @row-dblclick="openDetail"
863
+ >
864
+ <template #toolbar-left>
865
+ <div class="table-actions">
866
+ <el-button type="primary" :disabled="listBusy" @click="openCreate">新增预报</el-button>
867
+ <!-- 导入、导出由后端接口完成,按钮先保留页面入口,不在前端拼装文件。 -->
868
+ <el-button :disabled="listBusy">导入</el-button>
869
+ <el-button :disabled="listBusy || listLoading || !listRows.length">导出</el-button>
870
+ <el-button type="danger" plain :disabled="listBusy || listLoading || !listSelectedRows.length" @click="deleteRecords(listSelectedRows)">
871
+ 批量删除<span v-if="listSelectedRows.length">({{ listSelectedRows.length }})</span>
872
+ </el-button>
873
+ </div>
874
+ </template>
875
+ <template #reference="{ row }">
876
+ <el-button v-if="listHasReference(row)" link type="primary" :disabled="listBusy" @click.stop="openDetail(row)" @dblclick.stop>
877
+ {{ row.predRefNo }}
878
+ </el-button>
879
+ <span v-else>—</span>
880
+ </template>
881
+ <template #amount="{ row }"><span class="amount-cell">{{ formatAmount(row.predAmt) }}</span></template>
882
+ <template #verifiedAmount="{ row }"><span class="amount-cell">{{ formatAmount(row.verifyAmt) }}</span></template>
883
+ <template #actions="{ row }">
884
+ <div class="row-actions" @dblclick.stop>
885
+ <el-button link type="primary" :disabled="listBusy || !listHasReference(row)" @click.stop="openDetail(row)">详情</el-button>
886
+ <el-button link type="primary" :disabled="listBusy || !listHasReference(row)" @click.stop="openEdit(row)">修改</el-button>
887
+ <el-button link type="danger" :disabled="listBusy || !listHasReference(row)" @click.stop="deleteRecords([row])">删除</el-button>
888
+ </div>
889
+ </template>
890
+ </ArtDataTable>
891
+ </div>
892
+ </main>
66
893
  </template>
894
+
895
+ <style scoped>
896
+ /* ---------- 列表页 ---------- */
897
+ /* 页面先扣除宿主占用高度,再使用 flex 分配剩余高度,让列表内部滚动并保留分页。 */
898
+ .dcps-page {
899
+ box-sizing: border-box;
900
+ display: flex;
901
+ flex-direction: column;
902
+ gap: 5px;
903
+ height: calc(100vh - var(--dpcs-host-layout-height));
904
+ height: calc(100dvh - var(--dpcs-host-layout-height));
905
+ min-width: 0;
906
+ min-height: 0;
907
+ padding: 5px;
908
+ overflow: hidden;
909
+ background: var(--el-bg-color-page, #f7f8fa);
910
+ }
911
+ /* 筛选项压成一行,等分剩余宽度,窗口过窄时才换行并限制高度,为列表保留空间。 */
912
+ .forecast-search {
913
+ flex: 0 0 auto;
914
+ max-height: min(42dvh, 360px);
915
+ overflow-y: auto;
916
+ }
917
+ .filter-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 6px; }
918
+ .filter-form :deep(.el-form-item) { flex: 1 1 76px; min-width: 76px; margin: 0; }
919
+ /* 日期和金额输入框内容更长,给出略大的基准宽度,剩余空间再由各列均分。 */
920
+ .filter-form :deep(.filter-date) { flex: 1.5 0 116px; }
921
+ .filter-form :deep(.filter-amount) { flex: 1.2 0 92px; }
922
+ .filter-form :deep(.el-form-item__label) { height: auto; margin-bottom: 4px; padding: 0; font-size: 12px; line-height: 16px; }
923
+ .filter-form :deep(.el-date-editor) { width: 100%; min-width: 0; }
924
+ .filter-actions { flex: 0 0 auto; }
925
+ .forecast-table-region { flex: 1; min-width: 0; min-height: 0; }
926
+ .forecast-table { padding: 10px; }
927
+ .table-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
928
+ .table-actions :deep(.el-button + .el-button), .row-actions :deep(.el-button + .el-button) { margin-left: 0; }
929
+ .row-actions { display: flex; justify-content: center; gap: 10px; }
930
+ .amount-cell { color: var(--el-text-color-primary); font-variant-numeric: tabular-nums; }
931
+ @media (max-width: 640px) {
932
+ .filter-form :deep(.el-form-item) { flex-basis: 100%; }
933
+ .table-actions { gap: 5px; }
934
+ }
935
+
936
+ /* ---------- 新增 / 修改、详情页外壳 ---------- */
937
+ /* 与列表页面一致:扣除宿主占用高度后,把剩余空间分给内容区,内容区内部滚动。 */
938
+ .page-shell {
939
+ box-sizing: border-box;
940
+ display: flex;
941
+ flex-direction: column;
942
+ gap: 5px;
943
+ height: calc(100vh - var(--dpcs-host-layout-height));
944
+ height: calc(100dvh - var(--dpcs-host-layout-height));
945
+ min-width: 0;
946
+ min-height: 0;
947
+ padding: 5px;
948
+ overflow: hidden;
949
+ background: var(--el-bg-color-page, #f7f8fa);
950
+ }
951
+ .shell-header { flex: 0 0 auto; }
952
+ .shell-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; }
953
+ .shell-back :deep(.el-button + .el-button) { margin-left: 0; }
954
+ .shell-back-icon { width: 15px; height: 15px; margin-right: 4px; }
955
+ .shell-title { margin: 0; color: var(--el-text-color-primary); font-size: 16px; font-weight: 600; line-height: 22px; }
956
+
957
+ /* ---------- 新增 / 修改页 ---------- */
958
+ .editor-card { flex: 1; min-height: 0; min-width: 0; }
959
+ .editor-body { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 10px; }
960
+ .editor-scroll { flex: 1; min-height: 0; padding-right: 2px; overflow-y: auto; }
961
+ .editor-load-error { margin-bottom: 12px; }
962
+ .editor-note { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
963
+ .editor-group + .editor-group { margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--el-border-color-lighter); }
964
+ .editor-group h3 { margin: 0 0 12px; color: var(--el-text-color-primary); font-size: 13px; font-weight: 600; line-height: 18px; }
965
+ .editor-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); column-gap: 18px; }
966
+ .editor-fields :deep(.el-form-item) { min-width: 0; margin-bottom: 14px; }
967
+ .editor-fields :deep(.el-form-item__label) { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; padding: 0; line-height: 20px; }
968
+ .editor-fields :deep(.el-date-editor.el-input) { width: 100%; }
969
+ .editor-field--wide { grid-column: 1 / -1; }
970
+ .field-unavailable { color: var(--el-text-color-secondary); font-size: 11px; font-weight: 400; }
971
+ .editor-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 12px; padding-top: 10px; border-top: 1px solid var(--el-border-color-lighter); }
972
+ .editor-tip { color: var(--el-text-color-secondary); font-size: 12px; }
973
+ .editor-error { margin: 0; padding: 6px 10px; border: 1px solid var(--el-color-danger-light-7); border-radius: 4px; background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: 12px; line-height: 1.6; }
974
+
975
+ @media (max-width: 1400px) {
976
+ .editor-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
977
+ }
978
+ @media (max-width: 1000px) {
979
+ .editor-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
980
+ }
981
+ @media (max-width: 600px) {
982
+ .editor-fields { grid-template-columns: minmax(0, 1fr); }
983
+ .editor-field--wide { grid-column: auto; }
984
+ }
985
+
986
+ /* ---------- 详情页 ---------- */
987
+ .detail-card { flex: 1; min-height: 0; min-width: 0; }
988
+ .detail-scroll { height: 100%; padding-right: 2px; overflow-y: auto; }
989
+ /* 与授权详情一致:分组标题在左,字段按四列排布,窗口变窄后逐级减少列数。 */
990
+ .detail-content { display: grid; gap: 8px; font-size: 13px; line-height: 1.4; }
991
+ .detail-group { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: start; gap: 8px; padding: 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; }
992
+ .detail-group h3 { margin: 4px 0 0; padding-left: 8px; border-left: 3px solid var(--el-color-primary); color: var(--el-text-color-primary); font-size: 14px; line-height: 20px; }
993
+ .detail-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 0; }
994
+ .detail-field { min-width: 0; padding: 8px 12px; border-radius: 6px; background: var(--el-fill-color-lighter); }
995
+ .detail-field dt { color: var(--el-text-color-secondary); font-size: 12px; }
996
+ .detail-field dd { margin: 2px 0 0; color: var(--el-text-color-primary); font-weight: 600; overflow-wrap: anywhere; white-space: pre-wrap; }
997
+ .detail-amount { font-variant-numeric: tabular-nums; }
998
+ .detail-status { color: #000; }
999
+ .detail-error { display: grid; justify-items: start; gap: 12px; }
1000
+
1001
+ @media (max-width: 1400px) {
1002
+ .detail-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
1003
+ }
1004
+ @media (max-width: 1024px) {
1005
+ .detail-group { grid-template-columns: minmax(0, 1fr); }
1006
+ .detail-group h3 { margin-top: 0; }
1007
+ .detail-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1008
+ }
1009
+ @media (max-width: 640px) {
1010
+ .detail-fields { grid-template-columns: minmax(0, 1fr); }
1011
+ }
1012
+ </style>