vue3-element-plus-app-chu 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/view/dcps/digital-rmb-authorization/index.vue +1 -1
- package/src/view/dcps/digital-rmb-forecast/api.ts +220 -0
- package/src/view/dcps/digital-rmb-forecast/components/ForecastDetailView.vue +145 -0
- package/src/view/dcps/digital-rmb-forecast/components/ForecastEditorView.vue +320 -0
- package/src/view/dcps/digital-rmb-forecast/components/ForecastListView.vue +322 -0
- package/src/view/dcps/digital-rmb-forecast/components/ForecastPageShell.vue +56 -0
- package/src/view/dcps/digital-rmb-forecast/index.vue +66 -0
- package/src/view/dcps/digital-rmb-forecast/runtime.ts +8 -0
- package/src/view/dcps/digital-rmb-forecast/typs.ts +50 -0
- package/src/view/dcps/layout/index.vue +46 -43
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
# Vue 3 + TypeScript + Element Plus
|
|
2
2
|
|
|
3
3
|
基于 Vite 初始化的 Vue 3 项目,使用 TypeScript,并已全局注册 Element Plus。
|
|
4
|
+
|
|
5
|
+
## 数字人民币头寸预报
|
|
6
|
+
|
|
7
|
+
默认入口为数字人民币头寸预报,侧边菜单可切换至原有数字人民币授权页面;切换时保留各自列表状态。
|
|
8
|
+
|
|
9
|
+
- 列表页只保留筛选卡片和表格卡片;筛选条件压在一行,窗口过窄时才换行。
|
|
10
|
+
- 新增、修改、详情不使用弹窗,在当前路由内切换为独立页面,页面顶部提供“返回列表”按钮;返回后列表的筛选和分页状态不变。
|
|
11
|
+
- 支持交易日期、客户、交易对手、机构、金额范围及业务代码筛选,分页、详情、新增、修改和批量删除。
|
|
12
|
+
- 金额、账号及流水号使用字符串处理。业务状态与类型代码保留接口原值,待服务端提供字典后再接入中文选项。
|
|
13
|
+
- 导入、导出按钮保留在列表工具栏,文件由后端接口生成和解析,页面不再在前端拼装 CSV。
|
|
14
|
+
- 模块目录与授权页面保持一致:`api.ts` 接口与日期、金额、表单校验等共用工具,`typs.ts` 类型,`runtime.ts` 默认交易日期,`index.vue` 为入口,`components/` 放页面外壳和列表、新增修改、详情页面。
|
|
15
|
+
- 新增、修改、详情页面每行四列展示,窗口变窄后依次降为三列、两列、一列。
|
|
16
|
+
|
|
17
|
+
本地后端的预报接口当前使用通用模拟响应,写入成功响应不代表真实持久化。
|
package/package.json
CHANGED
|
@@ -333,7 +333,7 @@ const submitDecision = async (decision: Decision) => {
|
|
|
333
333
|
rmk: remarkText, // 审批备注,拒绝时必须提供原因。
|
|
334
334
|
apprUserId: approverId, // 公共审批用户标识,与审批人工号一致。
|
|
335
335
|
userId: approverId, // 公共操作用户标识,与审批人工号一致。
|
|
336
|
-
})
|
|
336
|
+
}) as any
|
|
337
337
|
if (disposed) return
|
|
338
338
|
// retCode 为 00000 即表示请求成功。
|
|
339
339
|
if (head?.retCode !== '00000') {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import sharedForecastApi from '@/api/modules/digital-rmb/forecast-management'
|
|
2
|
+
import type { ApiResponse } from '@/utils/request'
|
|
3
|
+
import type {
|
|
4
|
+
ForecastCreateBody,
|
|
5
|
+
ForecastDetail,
|
|
6
|
+
ForecastDetailParams,
|
|
7
|
+
ForecastEditBody,
|
|
8
|
+
ForecastFormValues,
|
|
9
|
+
ForecastListBody,
|
|
10
|
+
ForecastQueryParams,
|
|
11
|
+
ForecastRow,
|
|
12
|
+
} from './typs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 数字人民币头寸预报页面接口与共用工具。
|
|
16
|
+
* 接口地址:新增 120501001、修改 120501002、删除 120501003、查询 140501001、详情 140501002;
|
|
17
|
+
* 请求报文沿用 @/api 中的数币接口定义。
|
|
18
|
+
*/
|
|
19
|
+
const API = {
|
|
20
|
+
/**
|
|
21
|
+
* 分页查询头寸预报。
|
|
22
|
+
* @param params 查询参数:opType 固定为 query;tranDate 为必查交易日期,格式为 YYYYMMDD。
|
|
23
|
+
* 其余筛选条件为空字符串表示不限制;金额范围使用 startTransAmt、endTransAmt。
|
|
24
|
+
*/
|
|
25
|
+
queryListApi: (params: ForecastQueryParams) => sharedForecastApi.queryListApi(params),
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 新增一条头寸预报。
|
|
29
|
+
* @param body 新增业务字段;金额字段由调用方标准化为十进制文本。
|
|
30
|
+
*/
|
|
31
|
+
addApi: (body: ForecastCreateBody) => sharedForecastApi.addApi({ body }),
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 修改一条头寸预报。
|
|
35
|
+
* @param body 修改业务字段;predRefSeq 为待修改记录的预报流水号。
|
|
36
|
+
*/
|
|
37
|
+
editApi: (body: ForecastEditBody) => sharedForecastApi.editApi({ body }),
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 按预报流水号删除头寸预报。
|
|
41
|
+
* @param predRefSeqs 待删除记录的预报流水号列表。
|
|
42
|
+
*/
|
|
43
|
+
deleteApi: (predRefSeqs: string[]) => sharedForecastApi.deleteApi({ predRefSeqs }),
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 读取单条预报详情。
|
|
47
|
+
* @param params 详情参数:predRefNo 为预报流水号。
|
|
48
|
+
*/
|
|
49
|
+
getDetailApi: (params: ForecastDetailParams) => sharedForecastApi.getDetailApi(params),
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 读取详情并与列表记录合并:详情没有的字段由同一条列表记录补充,
|
|
53
|
+
* 不为接口未返回的字段填入空值。
|
|
54
|
+
* @param row 列表中的预报记录。
|
|
55
|
+
*/
|
|
56
|
+
async fetchDetail(row: ForecastRow): Promise<ForecastDetail> {
|
|
57
|
+
if (!row.predRefNo?.trim()) throw new Error('预报流水号缺失,无法读取详情')
|
|
58
|
+
const body = ensureSuccess(await API.getDetailApi({ predRefNo: row.predRefNo }), '读取预报详情失败')
|
|
59
|
+
if (!body || !Object.keys(body).length) throw new Error('服务端未返回预报详情')
|
|
60
|
+
if (body.predRefNo && body.predRefNo !== row.predRefNo) throw new Error('返回的预报流水号与当前记录不一致')
|
|
61
|
+
const definedBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined && value !== null))
|
|
62
|
+
return { ...row, ...definedBody } as ForecastDetail
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 校验列表响应并取出当前页数据;列表缺失或总条数无效时抛出可提示的错误。
|
|
67
|
+
* @param body 列表接口返回的业务数据。
|
|
68
|
+
*/
|
|
69
|
+
readPage(body: ForecastListBody | null): ForecastPage {
|
|
70
|
+
if (!body || !Array.isArray(body.resultList)) throw new Error('查询结果缺少预报列表,请联系服务端确认返回数据')
|
|
71
|
+
const totalText = String(body.totalCount ?? body.resultList.length)
|
|
72
|
+
const total = Number(totalText)
|
|
73
|
+
if (!/^\d+$/.test(totalText) || !Number.isSafeInteger(total)) throw new Error('查询返回的总条数无效')
|
|
74
|
+
return { data: body.resultList, total }
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 列表查询结果:当前页记录和符合条件的总条数。 */
|
|
79
|
+
export interface ForecastPage {
|
|
80
|
+
data: ForecastRow[] // 当前页的预报记录。
|
|
81
|
+
total: number // 符合查询条件的总记录数。
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 校验公共响应头;retCode 不是 00000 时按服务端提示或 fallback 抛错。 */
|
|
85
|
+
export function ensureSuccess<T>(response: ApiResponse<T>, fallback: string): T | null {
|
|
86
|
+
if (response?.head?.retCode !== '00000') {
|
|
87
|
+
throw new Error(response?.head?.retMsg?.trim() || fallback)
|
|
88
|
+
}
|
|
89
|
+
return response.body
|
|
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
|
+
/** 新增和导入使用的表单初始值;日期默认取当天。 */
|
|
178
|
+
export function createFormValues(): ForecastFormValues {
|
|
179
|
+
const today = getToday()
|
|
180
|
+
return {
|
|
181
|
+
billDate: '', billNo: '', busiType: '', clientName: '', clientType: '',
|
|
182
|
+
competAcct: '', competName: '', custInfoSeq: '', groundBranch: '', groundBranchName: '',
|
|
183
|
+
linkMan: '', payChannel: '', payDirec: '', phoneNo: '', predAmt: '', predDate: today,
|
|
184
|
+
predName: '', predOrgName: '', purpose: '', remark: '', tranDate: today,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 校验表单;新增校验全部必填项,修改只校验已填写的字段。
|
|
190
|
+
* @param values 表单值。
|
|
191
|
+
* @param mode create 为新增,edit 为修改。
|
|
192
|
+
* @returns 错误提示文本,空字符串表示校验通过。
|
|
193
|
+
*/
|
|
194
|
+
export function validateForm(values: ForecastFormValues, mode: 'create' | 'edit' = 'create'): string {
|
|
195
|
+
const requiredFields = [
|
|
196
|
+
['clientName', '客户名称'], ['payDirec', '收支方向'], ['busiType', '业务类型'],
|
|
197
|
+
['payChannel', '支付渠道'], ['groundBranch', '落地机构'],
|
|
198
|
+
] as const
|
|
199
|
+
if (mode === 'create') {
|
|
200
|
+
for (const [key, label] of requiredFields) {
|
|
201
|
+
if (!values[key].trim()) return `请填写${label}`
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const [key, label] of [['tranDate', '交易日期'], ['predDate', '预报日期']] as const) {
|
|
205
|
+
const date = values[key].trim()
|
|
206
|
+
if ((date || mode === 'create') && !isValidDate(date)) return `${label}须为有效日期,格式为 YYYYMMDD`
|
|
207
|
+
}
|
|
208
|
+
if (values.billDate.trim() && !isValidDate(values.billDate.trim())) {
|
|
209
|
+
return '出票日须为有效日期,格式为 YYYYMMDD'
|
|
210
|
+
}
|
|
211
|
+
if (values.predAmt.trim() || mode === 'create') {
|
|
212
|
+
const amount = normalizeAmount(values.predAmt)
|
|
213
|
+
if (!amount || amount.startsWith('-') || (amount.split('.')[1]?.length || 0) > 2) {
|
|
214
|
+
return '预报金额须为非负金额,最多保留两位小数'
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return ''
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default API
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
3
|
+
import ForecastPageShell from './ForecastPageShell.vue'
|
|
4
|
+
import API, { formatAmount, formatDate, getErrorMessage } from '../api'
|
|
5
|
+
import type { ForecastDetail, ForecastRow } from '../typs'
|
|
6
|
+
|
|
7
|
+
defineOptions({ name: 'ForecastDetailView' })
|
|
8
|
+
|
|
9
|
+
/** 预报详情页;按预报流水号读取,接口未返回的字段由列表记录补充。 */
|
|
10
|
+
const props = defineProps<{ row: ForecastRow }>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{ back: []; edit: [row: ForecastRow] }>()
|
|
13
|
+
|
|
14
|
+
type DetailField = { key: keyof ForecastDetail; label: string; kind?: 'date' | 'amount' | 'status' }
|
|
15
|
+
|
|
16
|
+
const detailGroups: { title: string; fields: DetailField[] }[] = [
|
|
17
|
+
{
|
|
18
|
+
title: '预报信息',
|
|
19
|
+
fields: [
|
|
20
|
+
{ key: 'predRefNo', label: '预报流水号' },
|
|
21
|
+
{ key: 'predDate', label: '预报日期', kind: 'date' },
|
|
22
|
+
{ key: 'tranDate', label: '交易日期', kind: 'date' },
|
|
23
|
+
{ key: 'predAmt', label: '预报金额(元)', kind: 'amount' },
|
|
24
|
+
{ key: 'predCode', label: '预报人员编号' },
|
|
25
|
+
{ key: 'standardName', label: '标准名称' },
|
|
26
|
+
{ key: 'predStatus', label: '预报状态代码', kind: 'status' },
|
|
27
|
+
{ key: 'verifyStatus', label: '核销状态代码', kind: 'status' },
|
|
28
|
+
{ key: 'verifyAmt', label: '已核销金额(元)', kind: 'amount' },
|
|
29
|
+
{ key: 'edusFlag', label: '释放状态代码', kind: 'status' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: '业务与客户',
|
|
34
|
+
fields: [
|
|
35
|
+
{ key: 'busiType', label: '业务类型代码' },
|
|
36
|
+
{ key: 'payDirec', label: '收支方向代码' },
|
|
37
|
+
{ key: 'payChannel', label: '支付渠道代码' },
|
|
38
|
+
{ key: 'clientName', label: '客户名称' },
|
|
39
|
+
{ key: 'clientType', label: '客户类型代码' },
|
|
40
|
+
{ key: 'competName', label: '交易对手名称' },
|
|
41
|
+
{ key: 'competAcct', label: '交易对手账号' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: '机构与票据',
|
|
46
|
+
fields: [
|
|
47
|
+
{ key: 'groundBranch', label: '落地机构' },
|
|
48
|
+
{ key: 'groundBranchCode', label: '机构代码' },
|
|
49
|
+
{ key: 'groundBranchName', label: '落地机构名称' },
|
|
50
|
+
{ key: 'billNo', label: '票号' },
|
|
51
|
+
{ key: 'billSignDate', label: '出票日期', kind: 'date' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const detail = ref<ForecastDetail | null>(null)
|
|
57
|
+
const loading = ref(false)
|
|
58
|
+
const error = ref('')
|
|
59
|
+
const predRefNo = computed(() => props.row.predRefNo?.trim() || '')
|
|
60
|
+
const title = computed(() => predRefNo.value ? `预报详情 · ${predRefNo.value}` : '数字人民币头寸预报详情')
|
|
61
|
+
|
|
62
|
+
const displayValue = (field: DetailField) => {
|
|
63
|
+
const value = detail.value?.[field.key]
|
|
64
|
+
if (value === undefined || value === null || value === '') return '—'
|
|
65
|
+
if (field.kind === 'amount') return formatAmount(value)
|
|
66
|
+
if (field.kind === 'date') return formatDate(value)
|
|
67
|
+
return String(value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let disposed = false
|
|
71
|
+
const loadDetail = async () => {
|
|
72
|
+
if (loading.value || !predRefNo.value) return
|
|
73
|
+
loading.value = true
|
|
74
|
+
detail.value = null
|
|
75
|
+
error.value = ''
|
|
76
|
+
try {
|
|
77
|
+
const result = await API.fetchDetail(props.row)
|
|
78
|
+
if (!disposed) detail.value = result
|
|
79
|
+
} catch (requestError) {
|
|
80
|
+
if (!disposed) error.value = getErrorMessage(requestError)
|
|
81
|
+
} finally {
|
|
82
|
+
if (!disposed) loading.value = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onMounted(loadDetail)
|
|
87
|
+
onBeforeUnmount(() => { disposed = true })
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<ForecastPageShell :title="title" :back-disabled="loading" @back="emit('back')">
|
|
92
|
+
<template #heading-extra>
|
|
93
|
+
<el-button v-if="detail" type="primary" :disabled="loading" @click="emit('edit', row)">修改预报</el-button>
|
|
94
|
+
</template>
|
|
95
|
+
<el-card class="detail-card" shadow="never" :body-style="{ padding: '12px 16px' }">
|
|
96
|
+
<div v-loading="loading" class="detail-scroll">
|
|
97
|
+
<div v-if="error" class="detail-error" role="alert">
|
|
98
|
+
<el-alert :title="error" type="error" :closable="false" show-icon />
|
|
99
|
+
<el-button size="small" :disabled="loading" @click="loadDetail">重新加载</el-button>
|
|
100
|
+
</div>
|
|
101
|
+
<div v-else-if="detail" class="detail-content">
|
|
102
|
+
<!-- group 为当前详情分组,fields 中的每项对应一个字段。 -->
|
|
103
|
+
<section v-for="group in detailGroups" :key="group.title" class="detail-group" :aria-label="group.title">
|
|
104
|
+
<h3>{{ group.title }}</h3>
|
|
105
|
+
<dl class="detail-fields">
|
|
106
|
+
<div v-for="field in group.fields" :key="field.key" class="detail-field">
|
|
107
|
+
<dt>{{ field.label }}</dt>
|
|
108
|
+
<dd :class="{ 'detail-amount': field.kind === 'amount', 'detail-status': field.kind === 'status' }">{{ displayValue(field) }}</dd>
|
|
109
|
+
</div>
|
|
110
|
+
</dl>
|
|
111
|
+
</section>
|
|
112
|
+
</div>
|
|
113
|
+
<el-empty v-else-if="!loading" description="暂无预报详情" :image-size="64" />
|
|
114
|
+
</div>
|
|
115
|
+
</el-card>
|
|
116
|
+
</ForecastPageShell>
|
|
117
|
+
</template>
|
|
118
|
+
|
|
119
|
+
<style scoped>
|
|
120
|
+
.detail-card { flex: 1; min-height: 0; min-width: 0; }
|
|
121
|
+
.detail-scroll { height: 100%; padding-right: 2px; overflow-y: auto; }
|
|
122
|
+
/* 与授权详情一致:分组标题在左,字段按四列排布,窗口变窄后逐级减少列数。 */
|
|
123
|
+
.detail-content { display: grid; gap: 8px; font-size: 13px; line-height: 1.4; }
|
|
124
|
+
.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; }
|
|
125
|
+
.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; }
|
|
126
|
+
.detail-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 0; }
|
|
127
|
+
.detail-field { min-width: 0; padding: 8px 12px; border-radius: 6px; background: var(--el-fill-color-lighter); }
|
|
128
|
+
.detail-field dt { color: var(--el-text-color-secondary); font-size: 12px; }
|
|
129
|
+
.detail-field dd { margin: 2px 0 0; color: var(--el-text-color-primary); font-weight: 600; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
130
|
+
.detail-amount { font-variant-numeric: tabular-nums; }
|
|
131
|
+
.detail-status { color: #000; }
|
|
132
|
+
.detail-error { display: grid; justify-items: start; gap: 12px; }
|
|
133
|
+
|
|
134
|
+
@media (max-width: 1400px) {
|
|
135
|
+
.detail-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
136
|
+
}
|
|
137
|
+
@media (max-width: 1024px) {
|
|
138
|
+
.detail-group { grid-template-columns: minmax(0, 1fr); }
|
|
139
|
+
.detail-group h3 { margin-top: 0; }
|
|
140
|
+
.detail-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
141
|
+
}
|
|
142
|
+
@media (max-width: 640px) {
|
|
143
|
+
.detail-fields { grid-template-columns: minmax(0, 1fr); }
|
|
144
|
+
}
|
|
145
|
+
</style>
|