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:
DaxPay Dev
2026-07-20 08:21:39 +08:00
parent b30d4268c0
commit 27283c0a22
4 changed files with 206 additions and 47 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {};