verce-vue-test 0.0.31 → 0.0.32

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.
@@ -10,19 +10,23 @@
10
10
  // ============================================================
11
11
 
12
12
  /** 原生 AJAX 请求参数 */
13
- interface MXAjaxParams {
13
+ export interface MXAjaxParams {
14
14
  type: 'GET' | 'POST' | 'PUT' | 'DELETE'
15
15
  url: string
16
- data?: unknown
16
+ dataType?: string
17
17
  async?: boolean
18
- success?: (data: string, status: number, xhr: unknown) => void
19
- error?: (data: unknown, status: number, xhr: unknown) => void
18
+ data?: unknown
19
+ headers?: Record<string, string>
20
+ complete?: () => void
21
+ success?: (data: unknown, status?: number, xhr?: unknown) => void
22
+ error?: (data: unknown, status?: number, xhr?: unknown) => void
20
23
  }
21
24
 
22
25
  /** 原生 AJAX 响应 */
23
26
  export interface MXAjaxResponse<T = unknown> {
24
27
  data: T
25
- status: number
28
+ status?: number
29
+ xhr?: unknown
26
30
  }
27
31
 
28
32
  /** 用户信息 */
@@ -47,7 +51,7 @@ interface MXApiCallbacks {
47
51
  }
48
52
 
49
53
  /** 原生接口命名空间 */
50
- type MXNamespace = 'MXCommon' | 'MXWebui' | 'MXContacts'
54
+ type MXNamespace = 'MXCommon' | 'NXCommon' | 'MXWebui' | 'MXContacts'
51
55
 
52
56
  // ============================================================
53
57
  // 声明全局原生接口
@@ -60,6 +64,9 @@ declare global {
60
64
  getEncryptString: (callback: (secret: string) => void) => void
61
65
  ajax: (params: MXAjaxParams) => void
62
66
  }
67
+ NXCommon?: {
68
+ ajax: (params: MXAjaxParams) => void
69
+ }
63
70
  MXWebui?: {
64
71
  hideWebViewTitle: () => void
65
72
  showOptionMenu: () => void
@@ -144,6 +151,7 @@ const makeApi =
144
151
  applyApi(namespace, api, args)
145
152
 
146
153
  const MXCommon = makeApi('MXCommon')
154
+ const NXCommon = makeApi('NXCommon')
147
155
  const MXWebui = makeApi('MXWebui')
148
156
  const MXContacts = makeApi('MXContacts')
149
157
 
@@ -217,58 +225,111 @@ export const MXSelectUsers = (
217
225
  * 原生 AJAX 请求
218
226
  *
219
227
  * 使用原生应用的 HTTP 客户端发起请求,绕过浏览器同源策略限制。
220
- * ajax 本身通过 success/error 回调,保留 Promise 包装;回调未触发时 15s 超时 reject
228
+ * NXCommon.ajax 只支持回调,不返回 Promise。
221
229
  */
222
- export const ajax = <T = unknown>(params: Omit<MXAjaxParams, 'success' | 'error'>): Promise<MXAjaxResponse<T>> => {
223
- const request = new Promise<MXAjaxResponse<T>>((resolve, reject) => {
224
- const requestParams: MXAjaxParams = {
225
- ...params,
226
- url: `${import.meta.env.VITE_API_BASE_URL || ''}${params.url}`,
227
- async: true,
228
- success(data, status) {
229
- resolve({ data: JSON.parse(data) as T, status })
230
- },
231
- error(data, status) {
232
- reject({ data, status })
233
- },
234
- }
235
-
236
- MXCommon('ajax', requestParams)
237
- })
238
-
239
- const timeout = new Promise<MXAjaxResponse<T>>((_, reject) =>
240
- setTimeout(() => reject(new Error(`[MX] ajax ${params.type} ${params.url} 超时`)), 15000),
241
- )
230
+ export const ajax = (params: MXAjaxParams): void => {
231
+ const requestParams: MXAjaxParams = {
232
+ ...params,
233
+ url: normalizeAjaxUrl(params.url),
234
+ async: params.async ?? true,
235
+ dataType: params.dataType ?? 'text',
236
+ complete() {
237
+ params.complete?.()
238
+ },
239
+ success(data, status, xhr) {
240
+ params.success?.(data, status, xhr)
241
+ },
242
+ error(data, status, xhr) {
243
+ params.error?.(data, status, xhr)
244
+ },
245
+ }
242
246
 
243
- return Promise.race([request, timeout])
247
+ callNativeAjax(requestParams)
244
248
  }
245
249
 
246
250
  /** 原生 GET 请求 */
247
- export const ajaxGet = <T = unknown>(url: string, query?: Record<string, unknown>): Promise<MXAjaxResponse<T>> => {
248
- const queryString = query
249
- ? '?' + Object.entries(query)
250
- .filter(([, value]) => value !== undefined && value !== null)
251
- .map(([key, value]) => `${key}=${value}`)
252
- .join('&')
253
- : ''
254
-
255
- return ajax<T>({
251
+ export const ajaxGet = (
252
+ url: string,
253
+ query?: Record<string, unknown>,
254
+ options?: Pick<MXAjaxParams, 'headers' | 'complete' | 'dataType' | 'success' | 'error'>,
255
+ ): void =>
256
+ ajax({
257
+ ...options,
256
258
  type: 'GET',
257
- url: `${url}${queryString}`,
259
+ url: appendQuery(url, query),
258
260
  })
259
- }
260
261
 
261
262
  /** 原生 POST 请求 */
262
- export const ajaxPost = <T = unknown>(url: string, data?: unknown): Promise<MXAjaxResponse<T>> =>
263
- ajax<T>({ type: 'POST', url, data })
263
+ export const ajaxPost = (
264
+ url: string,
265
+ data?: unknown,
266
+ options?: Pick<MXAjaxParams, 'headers' | 'complete' | 'dataType' | 'success' | 'error'>,
267
+ ): void =>
268
+ ajax({ ...options, type: 'POST', url, data })
264
269
 
265
270
  /** 原生 PUT 请求 */
266
- export const ajaxPut = <T = unknown>(url: string, data?: unknown): Promise<MXAjaxResponse<T>> =>
267
- ajax<T>({ type: 'PUT', url, data })
271
+ export const ajaxPut = (
272
+ url: string,
273
+ data?: unknown,
274
+ options?: Pick<MXAjaxParams, 'headers' | 'complete' | 'dataType' | 'success' | 'error'>,
275
+ ): void =>
276
+ ajax({ ...options, type: 'PUT', url, data })
268
277
 
269
278
  /** 原生 DELETE 请求 */
270
- export const ajaxDelete = <T = unknown>(url: string, id: string): Promise<MXAjaxResponse<T>> =>
271
- ajax<T>({ type: 'DELETE', url: `${url}/${id}` })
279
+ export const ajaxDelete = (
280
+ url: string,
281
+ data?: unknown,
282
+ options?: Pick<MXAjaxParams, 'headers' | 'complete' | 'dataType' | 'success' | 'error'>,
283
+ ): void =>
284
+ ajax({
285
+ ...options,
286
+ type: 'DELETE',
287
+ url: typeof data === 'string' || typeof data === 'number'
288
+ ? `${url.replace(/\/+$/, '')}/${encodeURIComponent(String(data))}`
289
+ : url,
290
+ data: typeof data === 'string' || typeof data === 'number' ? undefined : data,
291
+ })
292
+
293
+ function callNativeAjax(params: MXAjaxParams): void {
294
+ if (window.NXCommon?.ajax) {
295
+ NXCommon('ajax', params)
296
+ return
297
+ }
298
+
299
+ MXCommon('ajax', params)
300
+ }
301
+
302
+ function normalizeAjaxUrl(url: string): string {
303
+ if (/^[a-z][a-z\d+\-.]*:\/\//i.test(url)) return url
304
+
305
+ const baseURL = import.meta.env.VITE_API_BASE_URL || ''
306
+ if (!baseURL) return url
307
+
308
+ return `${baseURL.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
309
+ }
310
+
311
+ function appendQuery(url: string, query?: Record<string, unknown>): string {
312
+ if (!query) return url
313
+
314
+ const searchParams = new URLSearchParams()
315
+ Object.entries(query).forEach(([key, value]) => {
316
+ if (value === undefined || value === null) return
317
+ if (Array.isArray(value)) {
318
+ value.forEach((item) => {
319
+ if (item !== undefined && item !== null) {
320
+ searchParams.append(key, String(item))
321
+ }
322
+ })
323
+ return
324
+ }
325
+ searchParams.append(key, String(value))
326
+ })
327
+
328
+ const queryString = searchParams.toString()
329
+ if (!queryString) return url
330
+
331
+ return `${url}${url.includes('?') ? '&' : '?'}${queryString}`
332
+ }
272
333
 
273
334
  let nativeReady = false
274
335
  let resolveNativeReady: (() => void) | null = null
@@ -299,4 +360,4 @@ export const whenNativeReady = (): Promise<boolean> => {
299
360
 
300
361
  /** 检测是否在原生环境(需在 deviceready 之后调用才准确) */
301
362
  export const isNativeApp = (): boolean =>
302
- typeof window.MXCommon !== 'undefined'
363
+ typeof window.MXCommon !== 'undefined' || typeof window.NXCommon !== 'undefined'
@@ -5,10 +5,13 @@
5
5
  * 根据环境自动选择 axios 或原生 AJAX
6
6
  */
7
7
 
8
- import { isNativeApp, ajaxGet, ajaxPost, ajaxPut, ajaxDelete } from '@/core/mxApi'
9
- import type { MXAjaxResponse } from '@/core/mxApi'
8
+ import { isNativeApp, ajax } from '@/core/mxApi'
9
+ import type { MXAjaxParams, MXAjaxResponse } from '@/core/mxApi'
10
10
  import { request as axiosRequest } from '@/utils/request'
11
11
  import type { ApiResponse } from '@/types/api'
12
+ import { showToast } from 'vant'
13
+ import NProgress from 'nprogress'
14
+ import router from '@/router'
12
15
 
13
16
  // ============================================================
14
17
  // Token 管理
@@ -79,6 +82,10 @@ export async function request<R = unknown, T = unknown>(
79
82
  ...customHeaders,
80
83
  }
81
84
 
85
+ if (auth && !headers.Authorization) {
86
+ headers.Authorization = `Basic ${window.btoa(`${auth.username}:${auth.password}`)}`
87
+ }
88
+
82
89
 
83
90
  if (isNativeApp()) {
84
91
  return await nativeRequest<R, T>({ url, method, data, params, headers })
@@ -98,32 +105,239 @@ export async function request<R = unknown, T = unknown>(
98
105
  async function nativeRequest<R, T>(
99
106
  config: RequestConfig<T>
100
107
  ): Promise<ApiResponse<R>> {
101
- const { url, method, data, params } = config
102
-
103
- let response: MXAjaxResponse<unknown>
104
-
105
- switch (method) {
106
- case 'GET':
107
- response = await ajaxGet<R>(url, params)
108
- break
109
- case 'POST':
110
- response = await ajaxPost<R>(url, data)
111
- break
112
- case 'PUT':
113
- response = await ajaxPut<R>(url, data)
114
- break
115
- case 'DELETE':
116
- const id = params?.id as string
117
- response = await ajaxDelete<R>(url, id)
118
- break
119
- default:
120
- throw new Error(`不支持的请求方法: ${method}`)
108
+ const { url, method, data, params, headers } = config
109
+
110
+ NProgress.start()
111
+
112
+ try {
113
+ const options = { headers, dataType: 'text' }
114
+ let response: MXAjaxResponse<ApiResponse<R>>
115
+
116
+ switch (method) {
117
+ case 'GET':
118
+ response = await nativeAjax<ApiResponse<R>>({
119
+ ...options,
120
+ type: 'GET',
121
+ url: appendQuery(url, params),
122
+ })
123
+ break
124
+ case 'POST':
125
+ response = await nativeAjax<ApiResponse<R>>({
126
+ ...options,
127
+ type: 'POST',
128
+ url,
129
+ data,
130
+ })
131
+ break
132
+ case 'PUT':
133
+ response = await nativeAjax<ApiResponse<R>>({
134
+ ...options,
135
+ type: 'PUT',
136
+ url,
137
+ data,
138
+ })
139
+ break
140
+ case 'DELETE':
141
+ response = await nativeAjax<ApiResponse<R>>({
142
+ ...options,
143
+ type: 'DELETE',
144
+ url: appendDeleteParams(url, params),
145
+ data,
146
+ })
147
+ break
148
+ default:
149
+ throw new Error(`不支持的请求方法: ${method}`)
150
+ }
151
+
152
+ const result = normalizeNativeResponse<R>(response)
153
+
154
+ if (result.code !== 0) {
155
+ showToast(result.message || '请求失败')
156
+
157
+ if (result.code === 401) {
158
+ clearToken()
159
+ router.push('/login')
160
+ }
161
+
162
+ const error = new Error(result.message || '请求失败') as Error & { handled?: boolean }
163
+ error.handled = true
164
+ throw error
165
+ }
166
+
167
+ return result
168
+ } catch (error) {
169
+ const nativeError = error as Error & { status?: number; data?: unknown; handled?: boolean }
170
+ if (nativeError.handled) {
171
+ throw error
172
+ }
173
+
174
+ const status = nativeError.status
175
+ const statusCodeMap: Record<number, string> = {
176
+ 400: '请求参数错误',
177
+ 401: '未授权,请重新登录',
178
+ 403: '拒绝访问',
179
+ 404: '请求资源不存在',
180
+ 500: '服务器内部错误',
181
+ }
182
+
183
+ showToast(status ? statusCodeMap[status] || `连接错误 ${status}` : nativeError.message || '网络连接异常')
184
+
185
+ if (status === 401) {
186
+ clearToken()
187
+ router.push('/login')
188
+ }
189
+
190
+ throw error
191
+ } finally {
192
+ NProgress.done()
193
+ }
194
+ }
195
+
196
+ function normalizeNativeResponse<R>(response: MXAjaxResponse<unknown>): ApiResponse<R> {
197
+ const data = response.data
198
+
199
+ if (isApiResponse<R>(data)) {
200
+ return data
121
201
  }
122
202
 
123
- // 包装成与 axios 拦截器一致的响应格式
124
203
  return {
125
204
  code: 0,
126
205
  message: 'success',
127
- data: response.data as R,
128
- } as ApiResponse<R>
206
+ data: data as R,
207
+ }
208
+ }
209
+
210
+ function isApiResponse<R>(value: unknown): value is ApiResponse<R> {
211
+ return typeof value === 'object'
212
+ && value !== null
213
+ && typeof (value as ApiResponse<R>).code === 'number'
214
+ && 'data' in value
215
+ }
216
+
217
+ function appendDeleteParams(url: string, params?: Record<string, unknown>): string {
218
+ if (!params) return url
219
+
220
+ const { id, ...rest } = params
221
+ const targetUrl = id === undefined || id === null
222
+ ? url
223
+ : `${url.replace(/\/+$/, '')}/${encodeURIComponent(String(id))}`
224
+
225
+ const query = new URLSearchParams()
226
+ Object.entries(rest).forEach(([key, value]) => {
227
+ if (value === undefined || value === null) return
228
+ query.append(key, String(value))
229
+ })
230
+
231
+ const queryString = query.toString()
232
+ if (!queryString) return targetUrl
233
+
234
+ return `${targetUrl}${targetUrl.includes('?') ? '&' : '?'}${queryString}`
235
+ }
236
+
237
+ function nativeAjax<T>(params: MXAjaxParams & { timeout?: number }): Promise<MXAjaxResponse<T>> {
238
+ const { timeout = 15000, ...ajaxParams } = params
239
+
240
+ return new Promise((resolve, reject) => {
241
+ let settled = false
242
+ const timer = window.setTimeout(() => {
243
+ if (settled) return
244
+ settled = true
245
+ reject(new Error(`[MX] ajax ${ajaxParams.type} ${ajaxParams.url} 超时(${timeout}ms)`))
246
+ }, timeout)
247
+
248
+ const settle = (callback: () => void) => {
249
+ if (settled) return
250
+ settled = true
251
+ window.clearTimeout(timer)
252
+ callback()
253
+ }
254
+
255
+ ajax({
256
+ ...ajaxParams,
257
+ success(data, status, xhr) {
258
+ settle(() => {
259
+ ajaxParams.success?.(data, status, xhr)
260
+ resolve({
261
+ data: parseAjaxData<T>(data, ajaxParams.dataType),
262
+ status,
263
+ xhr,
264
+ })
265
+ })
266
+ },
267
+ error(data, status, xhr) {
268
+ settle(() => {
269
+ ajaxParams.error?.(data, status, xhr)
270
+ reject(createNativeAjaxError(data, status, xhr, ajaxParams))
271
+ })
272
+ },
273
+ complete() {
274
+ ajaxParams.complete?.()
275
+ },
276
+ })
277
+ })
278
+ }
279
+
280
+ function appendQuery(url: string, query?: Record<string, unknown>): string {
281
+ if (!query) return url
282
+
283
+ const searchParams = new URLSearchParams()
284
+ Object.entries(query).forEach(([key, value]) => {
285
+ if (value === undefined || value === null) return
286
+ if (Array.isArray(value)) {
287
+ value.forEach((item) => {
288
+ if (item !== undefined && item !== null) {
289
+ searchParams.append(key, String(item))
290
+ }
291
+ })
292
+ return
293
+ }
294
+ searchParams.append(key, String(value))
295
+ })
296
+
297
+ const queryString = searchParams.toString()
298
+ if (!queryString) return url
299
+
300
+ return `${url}${url.includes('?') ? '&' : '?'}${queryString}`
301
+ }
302
+
303
+ function parseAjaxData<T>(data: unknown, dataType?: string): T {
304
+ if (dataType === 'text') {
305
+ return parseJsonString(data) as T
306
+ }
307
+
308
+ if (typeof data !== 'string') return data as T
309
+
310
+ return parseJsonString(data) as T
311
+ }
312
+
313
+ function parseJsonString(data: unknown): unknown {
314
+ if (typeof data !== 'string') return data
315
+
316
+ const trimmed = data.trim()
317
+ if (!trimmed) return undefined
318
+
319
+ try {
320
+ return JSON.parse(trimmed)
321
+ } catch {
322
+ return data
323
+ }
324
+ }
325
+
326
+ function createNativeAjaxError(
327
+ data: unknown,
328
+ status: number | undefined,
329
+ xhr: unknown,
330
+ params: Omit<MXAjaxParams, 'success' | 'error'>,
331
+ ): Error & { data: unknown; status?: number; xhr?: unknown } {
332
+ const parsedData = parseAjaxData(data, params.dataType)
333
+ const message = typeof parsedData === 'object' && parsedData && 'message' in parsedData
334
+ ? String((parsedData as { message?: unknown }).message || '请求失败')
335
+ : typeof parsedData === 'string'
336
+ ? parsedData
337
+ : '请求失败'
338
+ const error = new Error(message) as Error & { data: unknown; status?: number; xhr?: unknown }
339
+ error.data = parsedData
340
+ error.status = status
341
+ error.xhr = xhr
342
+ return error
129
343
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verce-vue-test",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -264,68 +264,73 @@ function createCashflowOption(): EChartsOption {
264
264
  }
265
265
  }
266
266
 
267
- function createLiquidityRingOption(card: GaugeCard): EChartsOption {
268
- const progress = Math.min(Math.max(card.needle, 0), card.max)
269
- const rest = Math.max(card.max - progress, 0)
270
- const ringGradient = new echarts.graphic.LinearGradient(0, 0, 1, 1, [
271
- { offset: 0, color: '#1278ff' },
272
- { offset: 0.5, color: '#33f0ff' },
273
- { offset: 1, color: '#58ff86' },
274
- ])
275
-
267
+ function createStageGaugeOption(card: GaugeCard): EChartsOption {
276
268
  return {
277
269
  animationDuration: 900,
278
- graphic: [
279
- {
280
- type: 'text',
281
- left: 'center',
282
- top: '44%',
283
- style: {
284
- text: card.value,
285
- fill: '#34eaff',
286
- fontSize: 30,
287
- fontWeight: 800,
288
- textAlign: 'center',
289
- textShadowBlur: 14,
290
- textShadowColor: 'rgba(47, 233, 255, .55)',
291
- },
292
- },
293
- ],
294
270
  series: [
295
271
  {
296
- type: 'pie',
297
- radius: ['62%', '78%'],
298
- center: ['50%', '52%'],
299
- startAngle: 90,
300
- clockwise: true,
301
- silent: true,
302
- label: { show: false },
303
- labelLine: { show: false },
304
- itemStyle: {
305
- borderRadius: 10,
306
- borderColor: 'rgba(2, 14, 33, .5)',
307
- borderWidth: 3,
272
+ type: 'gauge',
273
+ min: 0,
274
+ max: card.max,
275
+ radius: '93%',
276
+ center: ['50%', '58%'],
277
+ splitNumber: 5,
278
+ axisLine: {
279
+ lineStyle: {
280
+ width: 16,
281
+ color: [
282
+ [0.3, '#67e0e3'],
283
+ [0.7, '#37a2da'],
284
+ [1, '#fd666d'],
285
+ ],
286
+ },
308
287
  },
309
- data: [
310
- { value: progress, itemStyle: { color: ringGradient } },
311
- { value: rest, itemStyle: { color: 'rgba(33, 79, 124, .28)' } },
312
- ],
313
- },
314
- {
315
- type: 'pie',
316
- radius: ['46%', '47%'],
317
- center: ['50%', '52%'],
318
- silent: true,
319
- label: { show: false },
320
- labelLine: { show: false },
321
- data: [
322
- {
323
- value: 1,
324
- itemStyle: {
325
- color: 'rgba(58, 216, 255, .18)',
326
- },
288
+ pointer: {
289
+ length: '58%',
290
+ width: 5,
291
+ itemStyle: { color: 'auto' },
292
+ },
293
+ axisTick: {
294
+ distance: -16,
295
+ length: 7,
296
+ lineStyle: {
297
+ color: '#ffffff',
298
+ width: 2,
327
299
  },
328
- ],
300
+ },
301
+ splitLine: {
302
+ distance: -16,
303
+ length: 16,
304
+ lineStyle: {
305
+ color: '#ffffff',
306
+ width: 3,
307
+ },
308
+ },
309
+ axisLabel: {
310
+ color: 'inherit',
311
+ distance: 22,
312
+ fontSize: 12,
313
+ },
314
+ anchor: {
315
+ show: true,
316
+ showAbove: true,
317
+ size: 9,
318
+ itemStyle: {
319
+ borderWidth: 2,
320
+ borderColor: '#7ef7ff',
321
+ color: '#001a34',
322
+ },
323
+ },
324
+ title: { show: false },
325
+ detail: {
326
+ valueAnimation: true,
327
+ offsetCenter: [0, '45%'],
328
+ color: '#30dcff',
329
+ fontSize: 26,
330
+ fontWeight: 800,
331
+ formatter: () => card.value,
332
+ },
333
+ data: [{ value: card.needle }],
329
334
  },
330
335
  ],
331
336
  }
@@ -414,9 +419,9 @@ onMounted(async () => {
414
419
  updateCockpitScale()
415
420
  await nextTick()
416
421
  createChart(cashflowChart.value, createCashflowOption())
417
- createChart(liquidityGauge.value, createLiquidityRingOption(gaugeCards[0]!))
418
- createChart(coverageGauge.value, createLiquidityRingOption(gaugeCards[1]!))
419
- createChart(stableGauge.value, createLiquidityRingOption(gaugeCards[2]!))
422
+ createChart(liquidityGauge.value, createStageGaugeOption(gaugeCards[0]!))
423
+ createChart(coverageGauge.value, createStageGaugeOption(gaugeCards[1]!))
424
+ createChart(stableGauge.value, createStageGaugeOption(gaugeCards[2]!))
420
425
  createChart(maturityChart.value, createMaturityOption())
421
426
  window.addEventListener('resize', handleViewportChange)
422
427
  window.visualViewport?.addEventListener('resize', handleViewportChange)
@@ -1032,17 +1037,17 @@ onUnmounted(() => {
1032
1037
 
1033
1038
  .gauge-chart {
1034
1039
  position: absolute;
1035
- left: -2px;
1036
- right: -2px;
1037
- top: 23px;
1038
- height: 150px;
1040
+ left: -8px;
1041
+ right: -8px;
1042
+ top: 24px;
1043
+ height: 156px;
1039
1044
  }
1040
1045
 
1041
1046
  .gauge-card p {
1042
1047
  position: absolute;
1043
1048
  left: 0;
1044
1049
  right: 0;
1045
- bottom: 8px;
1050
+ bottom: 0;
1046
1051
  color: #d7e6f6;
1047
1052
  text-align: center;
1048
1053
  font-size: 14px;