mirror of
https://gitee.com/bootx/dax-pay-ui
synced 2026-08-11 23:25:33 +08:00
fix(log): 日志详情抽屉化 + JSON 友好渲染 + JsonViewer 兼容性修复
- 三个日志详情 (支付接口/操作/登录) 由 Modal 改为 Drawer, 适配长内容滚动 - 支付接口/操作日志的 reqParam/resBody 用 JsonViewer 渲染, 支持折叠/复制/语法高亮 - a-descriptions 加 label-style + table-layout:fixed, 修复长 token 字段挤压 label 换行 - 修复 errorCode (unipay) / browser (operate) 单字段行 span 不匹配 column 的警告 - 修复 vue-json-viewer UMD 默认导出兼容 (Vite 预构建后 default 是命名空间对象) - 修复 json-bigint 解析返回 Object.create(null) 对象与 vue-json-viewer 内部 hasOwnProperty 冲突
This commit is contained in:
@@ -27,17 +27,23 @@
|
||||
|
||||
<template>
|
||||
<!-- 国际化:登录日志详情 -->
|
||||
<a-modal
|
||||
<a-drawer
|
||||
v-bind="$attrs"
|
||||
:loading="confirmLoading"
|
||||
:width="700"
|
||||
:title="$t('system.log.login-log.detailTitle')"
|
||||
:size="1100"
|
||||
:open="visible"
|
||||
:footer="null"
|
||||
@cancel="visible = false"
|
||||
:destroy-on-hidden="true"
|
||||
@close="visible = false"
|
||||
>
|
||||
<a-spin :spinning="confirmLoading">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<!-- label 固定宽度且不换行, 防止内容撑宽列导致 label 被挤压 -->
|
||||
<a-descriptions
|
||||
class="login-log-desc"
|
||||
:column="2"
|
||||
size="small"
|
||||
bordered
|
||||
:label-style="{ width: '110px', whiteSpace: 'nowrap' }"
|
||||
>
|
||||
<!-- 用户账号 -->
|
||||
<a-descriptions-item :label="$t('system.log.login-log.account')">
|
||||
{{ data.account }}
|
||||
@@ -58,7 +64,7 @@
|
||||
</a-descriptions-item>
|
||||
<!-- 登录IP -->
|
||||
<a-descriptions-item :label="$t('system.log.login-log.ip')">
|
||||
{{ data.ip }}
|
||||
<span class="break-all">{{ data.ip }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 登录地点 -->
|
||||
<a-descriptions-item :label="$t('system.log.login-log.loginLocation')">
|
||||
@@ -74,7 +80,7 @@
|
||||
</a-descriptions-item>
|
||||
<!-- 提示消息 -->
|
||||
<a-descriptions-item :label="$t('system.log.login-log.msg')">
|
||||
{{ data.msg }}
|
||||
<span class="break-all">{{ data.msg }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 登录时间 -->
|
||||
<a-descriptions-item :label="$t('system.log.login-log.loginTime')">
|
||||
@@ -82,5 +88,12 @@
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 固定列宽, 避免长 token 内容反向挤压 label 列导致换行 */
|
||||
.login-log-desc :deep(.ant-descriptions-view > table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { JsonViewer } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
@@ -22,22 +23,56 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为合法 JSON (仅作渲染路由判断, 不替代 JsonViewer 内部的 json-bigint 解析)
|
||||
*/
|
||||
function isJsonString(str?: null | string): boolean {
|
||||
if (!str) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// 仅对 {} 或 [] 开头的字符串尝试解析, 避免对纯文本/HTML/XML 误判
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 操作参数是否可按 JSON 渲染
|
||||
const operateParamIsJson = computed(() => isJsonString(data.value.operateParam));
|
||||
// 返回结果是否可按 JSON 渲染
|
||||
const operateReturnIsJson = computed(() => isJsonString(data.value.operateReturn));
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 国际化:操作日志详情 -->
|
||||
<a-modal
|
||||
<a-drawer
|
||||
v-bind="$attrs"
|
||||
:loading="confirmLoading"
|
||||
:width="900"
|
||||
:title="$t('system.log.operate-log.detailTitle')"
|
||||
:size="1100"
|
||||
:open="visible"
|
||||
:footer="null"
|
||||
@cancel="visible = false"
|
||||
:destroy-on-hidden="true"
|
||||
@close="visible = false"
|
||||
>
|
||||
<a-spin :spinning="confirmLoading">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<!-- label 固定宽度且不换行, 内容长 token 用 break-all 防止撑宽列导致 label 被挤压 -->
|
||||
<a-descriptions
|
||||
class="operate-log-desc"
|
||||
:column="2"
|
||||
size="small"
|
||||
bordered
|
||||
:label-style="{ width: '110px', whiteSpace: 'nowrap' }"
|
||||
>
|
||||
<!-- 操作账号 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.account')">
|
||||
{{ data.account }}
|
||||
@@ -62,15 +97,15 @@
|
||||
</a-descriptions-item>
|
||||
<!-- 方法名称 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.method')">
|
||||
{{ data.method }}
|
||||
<span class="break-all">{{ data.method }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 请求地址 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.operateUrl')" :span="2">
|
||||
{{ data.operateUrl }}
|
||||
<span class="break-all">{{ data.operateUrl }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 操作IP -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.operateIp')">
|
||||
{{ data.operateIp }}
|
||||
<span class="break-all">{{ data.operateIp }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 操作地点 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.operateLocation')">
|
||||
@@ -84,21 +119,37 @@
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.os')">
|
||||
{{ data.os }}
|
||||
</a-descriptions-item>
|
||||
<!-- 浏览器 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.browser')">
|
||||
<!-- 浏览器 (独占一行, 后续 errorMsg span=2 需从新行开始, 否则 column 2 不匹配) -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.browser')" :span="2">
|
||||
{{ data.browser }}
|
||||
</a-descriptions-item>
|
||||
<!-- 错误信息 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.errorMsg')" :span="2">
|
||||
{{ data.errorMsg }}
|
||||
<span class="break-all">{{ data.errorMsg }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 操作参数 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.operateParam')" :span="2">
|
||||
<pre class="whitespace-pre-wrap break-all text-xs">{{ data.operateParam }}</pre>
|
||||
<JsonViewer
|
||||
v-if="operateParamIsJson"
|
||||
class="json-viewer-box"
|
||||
:value="data.operateParam"
|
||||
:expand-depth="2"
|
||||
copyable
|
||||
/>
|
||||
<!-- 非 JSON 内容兜底: 原文展示 -->
|
||||
<pre v-else class="whitespace-pre-wrap break-all text-xs">{{ data.operateParam }}</pre>
|
||||
</a-descriptions-item>
|
||||
<!-- 返回结果 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.operateReturn')" :span="2">
|
||||
<pre class="whitespace-pre-wrap break-all text-xs">{{ data.operateReturn }}</pre>
|
||||
<JsonViewer
|
||||
v-if="operateReturnIsJson"
|
||||
class="json-viewer-box"
|
||||
:value="data.operateReturn"
|
||||
:expand-depth="2"
|
||||
copyable
|
||||
/>
|
||||
<!-- 非 JSON 内容兜底: 原文展示 -->
|
||||
<pre v-else class="whitespace-pre-wrap break-all text-xs">{{ data.operateReturn }}</pre>
|
||||
</a-descriptions-item>
|
||||
<!-- 操作时间 -->
|
||||
<a-descriptions-item :label="$t('system.log.operate-log.operateTime')" :span="2">
|
||||
@@ -106,5 +157,20 @@
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 固定列宽, 避免长 token 内容反向挤压 label 列导致换行 */
|
||||
.operate-log-desc :deep(.ant-descriptions-view > table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* JsonViewer 在 descriptions 单元格内的轻样式 (不使用 boxed 避免与外层 bordered 双层边框冲突) */
|
||||
.operate-log-desc :deep(.json-viewer-box) {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--ant-color-fill-quaternary, #fafafa);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { JsonViewer } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import { formatDateTime } from '@vben/utils';
|
||||
|
||||
@@ -22,29 +23,63 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为合法 JSON (仅作渲染路由判断, 不替代 JsonViewer 内部的 json-bigint 解析)
|
||||
*/
|
||||
function isJsonString(str?: null | string): boolean {
|
||||
if (!str) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// 仅对 {} 或 [] 开头的字符串尝试解析, 避免对纯文本/HTML/XML 误判
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 请求参数是否可按 JSON 渲染
|
||||
const reqParamIsJson = computed(() => isJsonString(data.value.reqParam));
|
||||
// 响应体是否可按 JSON 渲染
|
||||
const resBodyIsJson = computed(() => isJsonString(data.value.resBody));
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 国际化:支付接口日志详情 -->
|
||||
<a-modal
|
||||
<a-drawer
|
||||
v-bind="$attrs"
|
||||
:loading="confirmLoading"
|
||||
:width="960"
|
||||
:title="$t('system.log.unipay-api-log.detailTitle')"
|
||||
:size="1100"
|
||||
:open="visible"
|
||||
:footer="null"
|
||||
@cancel="visible = false"
|
||||
:destroy-on-hidden="true"
|
||||
@close="visible = false"
|
||||
>
|
||||
<a-spin :spinning="confirmLoading">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<!-- label 固定宽度且不换行, 内容长 token 用 break-all 防止撑宽列导致 label 被挤压 -->
|
||||
<a-descriptions
|
||||
class="unipay-api-log-desc"
|
||||
:column="2"
|
||||
size="small"
|
||||
bordered
|
||||
:label-style="{ width: '110px', whiteSpace: 'nowrap' }"
|
||||
>
|
||||
<!-- 商户号 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.mchNo')">
|
||||
{{ data.mchNo }}
|
||||
<span class="break-all">{{ data.mchNo }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 请求ID -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.reqId')">
|
||||
{{ data.reqId }}
|
||||
<span class="break-all">{{ data.reqId }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 状态 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.status')">
|
||||
@@ -66,11 +101,11 @@
|
||||
</a-descriptions-item>
|
||||
<!-- 接口路径 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.apiPath')" :span="2">
|
||||
{{ data.apiPath }}
|
||||
<span class="break-all">{{ data.apiPath }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 真实接入 IP -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.requestIp')">
|
||||
{{ data.requestIp }}
|
||||
<span class="break-all">{{ data.requestIp }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 归属地 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.requestLocation')">
|
||||
@@ -78,27 +113,31 @@
|
||||
</a-descriptions-item>
|
||||
<!-- 商户声明 IP -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.clientIp')">
|
||||
{{ data.clientIp }}
|
||||
<span class="break-all">{{ data.clientIp }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 追踪 ID -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.traceId')">
|
||||
{{ data.traceId }}
|
||||
<span class="break-all">{{ data.traceId }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 错误码 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.errorCode')">
|
||||
<!-- 错误码 (独占一行, 后续 errorMsg span=2 需从新行开始, 否则 column 2 不匹配) -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.errorCode')" :span="2">
|
||||
{{ data.errorCode }}
|
||||
</a-descriptions-item>
|
||||
<!-- 错误信息 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.errorMsg')" :span="2">
|
||||
{{ data.errorMsg }}
|
||||
<span class="break-all">{{ data.errorMsg }}</span>
|
||||
</a-descriptions-item>
|
||||
<!-- 请求参数 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.reqParam')" :span="2">
|
||||
<pre class="whitespace-pre-wrap break-all text-xs">{{ data.reqParam }}</pre>
|
||||
<JsonViewer v-if="reqParamIsJson" class="json-viewer-box" :value="data.reqParam" :expand-depth="2" copyable />
|
||||
<!-- 非 JSON 内容兜底: 原文展示 -->
|
||||
<pre v-else class="whitespace-pre-wrap break-all text-xs">{{ data.reqParam }}</pre>
|
||||
</a-descriptions-item>
|
||||
<!-- 响应体 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.resBody')" :span="2">
|
||||
<pre class="whitespace-pre-wrap break-all text-xs">{{ data.resBody }}</pre>
|
||||
<JsonViewer v-if="resBodyIsJson" class="json-viewer-box" :value="data.resBody" :expand-depth="2" copyable />
|
||||
<!-- 非 JSON 内容兜底: 原文展示 -->
|
||||
<pre v-else class="whitespace-pre-wrap break-all text-xs">{{ data.resBody }}</pre>
|
||||
</a-descriptions-item>
|
||||
<!-- 时间 -->
|
||||
<a-descriptions-item :label="$t('system.log.unipay-api-log.operateTime')" :span="2">
|
||||
@@ -106,5 +145,20 @@
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 固定列宽, 避免长 token 内容反向挤压 label 列导致换行 */
|
||||
.unipay-api-log-desc :deep(.ant-descriptions-view > table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* JsonViewer 在 descriptions 单元格内的轻样式 (不使用 boxed 避免与外层 bordered 双层边框冲突) */
|
||||
.unipay-api-log-desc :deep(.json-viewer-box) {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--ant-color-fill-quaternary, #fafafa);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
|
||||
import { computed, useAttrs } from 'vue';
|
||||
// @ts-expect-error - vue-json-viewer does not expose compatible typings for this import path
|
||||
import VueJsonViewer from 'vue-json-viewer';
|
||||
import VueJsonViewerRaw from 'vue-json-viewer';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
@@ -42,6 +42,11 @@ const emit = defineEmits<{
|
||||
valueClick: [value: JsonViewerValue];
|
||||
}>();
|
||||
|
||||
// vue-json-viewer@3.0.4 是 webpack4 UMD 老包, Vite/esbuild 预构建后 default 导出是
|
||||
// { default: Component, __esModule: true } 命名空间对象, 真组件挂在 .default 上
|
||||
// 直接渲染整个命名空间对象会触发 "Component is missing template or render function"
|
||||
const VueJsonViewer = (VueJsonViewerRaw as any).default || VueJsonViewerRaw;
|
||||
|
||||
const attrs: SetupContext['attrs'] = useAttrs();
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
@@ -66,14 +71,35 @@ function handleClick(event: MouseEvent) {
|
||||
emit('click', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归将对象转换为带 Object.prototype 原型的普通对象 {}
|
||||
* 修复: json-bigint 解析返回 Object.create(null) 对象 (无原型链),
|
||||
* 与 vue-json-viewer 内部 this.ordered.hasOwnProperty(o) 直接调用冲突,
|
||||
* 报 "obj.hasOwnProperty is not a function"
|
||||
*/
|
||||
function deepToPlain<T>(value: T): T {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepToPlain(item)) as unknown as T;
|
||||
}
|
||||
const plain: Record<string, any> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
plain[key] = deepToPlain((value as Record<string, any>)[key]);
|
||||
}
|
||||
return plain as unknown as T;
|
||||
}
|
||||
|
||||
// 支持显示 bigint 数据,如较长的订单号
|
||||
const jsonData = computed<Record<string, any>>(() => {
|
||||
if (typeof props.value !== 'string') {
|
||||
return props.value || {};
|
||||
return props.value ? deepToPlain(props.value) : {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JsonBigint({ storeAsString: true }).parse(props.value);
|
||||
const parsed = JsonBigint({ storeAsString: true }).parse(props.value);
|
||||
return deepToPlain(parsed);
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
return {};
|
||||
|
||||
Reference in New Issue
Block a user