This commit is contained in:
扶桑花间 2025-08-27 19:48:56 +08:00
parent e2d79ce1ef
commit d281084e1d
7 changed files with 159 additions and 380 deletions

View File

@ -7,7 +7,7 @@ import type { OperationRecord, OperationRecordParam } from './model';
*/ */
export async function pageOperationRecords(params: OperationRecordParam) { export async function pageOperationRecords(params: OperationRecordParam) {
const res = await request.get<ApiResult<PageResult<OperationRecord>>>( const res = await request.get<ApiResult<PageResult<OperationRecord>>>(
'/system/request-record/page', '/system/operate-record/page',
{ params } { params }
); );
if (res.data.code === 0) { if (res.data.code === 0) {
@ -15,17 +15,3 @@ export async function pageOperationRecords(params: OperationRecordParam) {
} }
return Promise.reject(new Error(res.data.message)); return Promise.reject(new Error(res.data.message));
} }
/**
*
*/
export async function listOperationRecords(params?: OperationRecordParam) {
const res = await request.get<ApiResult<OperationRecord[]>>(
'/system/request-record',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}

View File

@ -8,38 +8,16 @@ export interface OperationRecord {
id?: number; id?: number;
/** 用户id */ /** 用户id */
userId?: number; userId?: number;
/** 操作模块 */ /** 操作数据 */
module: string; data: string;
/** 操作功能 */ /** 操作功能 */
description: string; description: string;
/** 请求地址 */ /** ContextId */
url: string; contextId: string;
/** 请求方式 */
requestMethod: string;
/** 调用方法 */
method: string;
/** 请求参数 */
params: string;
/** 返回结果 */
result: string;
/** 异常信息 */
error: string;
/** 消耗时间, 单位毫秒 */
spendTime: number;
/** 操作系统 */
os: string;
/** 设备名称 */
device: string;
/** 浏览器类型 */
browser: string;
/** ip地址 */
ip: string;
/** 状态, 0成功, 1异常 */
status: number;
/** 操作时间 */ /** 操作时间 */
createTime: string; createTime: string;
/** 用户昵称 */ /** 地址 */
nickname: string; location: string;
/** 用户账号 */ /** 用户账号 */
username: string; username: string;
} }
@ -51,11 +29,9 @@ export interface OperationRecordParam extends PageParam {
/** 用户昵称 */ /** 用户昵称 */
username?: string; username?: string;
/** 操作模块 */ /** 操作模块 */
module?: string; location?: string;
/** 开始时间 */ /** 开始时间 */
createTimeStart?: string; createTimeStart?: string;
/** 截至时间 */ /** 截至时间 */
createTimeEnd?: string; createTimeEnd?: string;
/** 状态 */
status?: number;
} }

View File

@ -220,7 +220,6 @@ export function doWithTransition(
}); });
} }
/** /**
* GB, MB, KB, B * GB, MB, KB, B
* @param bytes * @param bytes
@ -250,3 +249,78 @@ export function formatBytes(bytes: number, precision: number = 2): string {
return `${formattedValue}${unit}`; return `${formattedValue}${unit}`;
} }
/**
* JSON字符串
* @param {string} jsonString - JSON字符串
* @param {number} indentSpaces - 2
* @returns {string} JSON字符串
* @throws {Error} JSON时抛出错误
*/
export function formatJson(jsonString, indentSpaces = 2) {
// 验证输入是否为字符串
// 解析JSON字符串为JavaScript对象
let parsedObj;
if (typeof jsonString === 'string') {
// throw new Error('输入必须是字符串');
try {
parsedObj = JSON.parse(jsonString);
} catch (e: any) {
throw new Error('无效的JSON格式: ' + e.message);
}
} else {
parsedObj = jsonString;
}
// 递归函数来处理格式化
function format(obj, depth) {
// 处理基本类型
if (obj === null) {
return 'null';
}
if (typeof obj === 'boolean' || typeof obj === 'number') {
return obj.toString();
}
if (typeof obj === 'string') {
return '"' + obj + '"';
}
// 处理数组
if (Array.isArray(obj)) {
if (obj.length === 0) {
return '[]';
}
const indent = ' '.repeat(depth * indentSpaces);
const innerIndent = ' '.repeat((depth + 1) * indentSpaces);
const items: any = [];
for (let i = 0; i < obj.length; i++) {
items.push(innerIndent + format(obj[i], depth + 1));
}
return '[\n' + items.join(',\n') + '\n' + indent + ']';
}
// 处理对象
const indent = ' '.repeat(depth * indentSpaces);
const innerIndent = ' '.repeat((depth + 1) * indentSpaces);
const keys = Object.keys(obj);
if (keys.length === 0) {
return '{}';
}
const items: any = [];
for (const key of keys) {
items.push(innerIndent + '"' + key + '": ' + format(obj[key], depth + 1));
}
return '{\n' + items.join(',\n') + '\n' + indent + '}';
}
return format(parsedObj, 0);
}

View File

@ -8,102 +8,49 @@
class="detail-table" class="detail-table"
> >
<el-descriptions-item label="操作人"> <el-descriptions-item label="操作人">
<div>{{ data.nickname }}({{ data.username }})</div> <div>{{ data.contextId }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="IP地址"> <el-descriptions-item label="地址">
<div>{{ data.ip }}</div> <div>{{ data.location }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="操作模块"> <el-descriptions-item label="操作描述">
<div>{{ data.module }}</div>
</el-descriptions-item>
<el-descriptions-item label="操作功能">
<div>{{ data.description }}</div> <div>{{ data.description }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="操作时间"> <el-descriptions-item label="操作时间">
<div>{{ data.createTime }}</div> <div>{{ data.createTime }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="请求耗时"> <el-descriptions-item label="事件" :span="2">
<div v-if="!isNaN(data.spendTime)">{{ data.spendTime / 1000 }}s</div> <div style="word-break: break-all">{{ data.event }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="请求方式"> <el-descriptions-item label="数据" :span="2">
<div>{{ data.requestMethod }}</div> <monaco-editor
</el-descriptions-item> v-model="previewData"
<el-descriptions-item label="请求状态"> language="json"
<el-tag style="height: 460px"
v-if="data.status === 0" />
size="small"
type="success"
:disable-transitions="true"
>
正常
</el-tag>
<el-tag
v-else-if="data.status === 1"
size="small"
type="danger"
:disable-transitions="true"
>
异常
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="请求地址" :span="2">
<div style="word-break: break-all">{{ data.url }}</div>
</el-descriptions-item>
<el-descriptions-item label="调用方法" :span="2">
<div style="word-break: break-all">{{ data.method }}</div>
</el-descriptions-item>
<el-descriptions-item label="请求参数" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.params }}
</ele-ellipsis>
</el-descriptions-item>
<el-descriptions-item v-if="data.status === 0" label="返回结果" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.result }}
</ele-ellipsis>
</el-descriptions-item>
<el-descriptions-item v-else label="异常信息" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.error }}
</ele-ellipsis>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</ele-modal> </ele-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive } from 'vue';
import type { EleTooltipProps } from 'ele-admin-plus/es/ele-app/plus';
import type { OperationRecord } from '@/api/system/operation-record/model'; import type { OperationRecord } from '@/api/system/operation-record/model';
import { useMobile } from '@/utils/use-mobile'; import { useMobile } from '@/utils/use-mobile';
import MonacoEditor from '@/components/MonacoEditor/index.vue';
import { formatJson } from '@/utils/common';
import { computed } from 'vue';
defineProps<{ const props = defineProps<{
/** 修改回显的数据 */ /** 修改回显的数据 */
data: OperationRecord; data: OperationRecord;
}>(); }>();
const previewData = computed(() => {
return formatJson(props.data);
});
/** 弹窗是否打开 */ /** 弹窗是否打开 */
const visible = defineModel({ type: Boolean }); const visible = defineModel({ type: Boolean });
/** 文字省略组件的提示组件的属性 */
const ellipsisTooltipProps = reactive<EleTooltipProps>({
popperStyle: {
width: '580px',
maxWidth: '90%',
wordBreak: 'break-all'
},
bodyStyle: {
maxWidth: 'calc(100vw - 32px)',
maxHeight: '252px',
overflowY: 'auto',
'--ele-scrollbar-color': '#5e5e5e',
'--ele-scrollbar-hover-color': '#707070',
'--ele-scrollbar-size': '8px'
},
offset: 4,
placement: 'top'
});
/** 是否是移动端 */ /** 是否是移动端 */
const { mobile } = useMobile(); const { mobile } = useMobile();
</script> </script>

View File

@ -13,7 +13,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :lg="6" :md="12" :sm="12" :xs="24"> <el-col :lg="6" :md="12" :sm="12" :xs="24">
<el-form-item label="操作模块"> <el-form-item label="地址">
<el-input <el-input
clearable clearable
v-model.trim="form.module" v-model.trim="form.module"

View File

@ -12,34 +12,6 @@
:export-config="{ fileName: '操作日志数据' }" :export-config="{ fileName: '操作日志数据' }"
cache-key="systemOperationRecordTable" cache-key="systemOperationRecordTable"
> >
<template #toolbar>
<el-button
type="primary"
class="ele-btn-icon"
:icon="DownloadOutlined"
@click="exportData"
>
导出
</el-button>
</template>
<template #status="{ row }">
<el-tag
v-if="row.status === 0"
size="small"
type="success"
:disable-transitions="true"
>
正常
</el-tag>
<el-tag
v-else-if="row.status === 1"
size="small"
type="danger"
:disable-transitions="true"
>
异常
</el-tag>
</template>
<template #action="{ row }"> <template #action="{ row }">
<el-link type="primary" underline="never" @click="openDetail(row)"> <el-link type="primary" underline="never" @click="openDetail(row)">
详情 详情
@ -54,21 +26,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { EleMessage } from 'ele-admin-plus';
import type { EleProTable } from 'ele-admin-plus'; import type { EleProTable } from 'ele-admin-plus';
import type { import type {
DatasourceFunction, DatasourceFunction,
Columns Columns
} from 'ele-admin-plus/es/ele-pro-table/types'; } from 'ele-admin-plus/es/ele-pro-table/types';
import ExcelJS from 'exceljs';
import { download } from '@/utils/common';
import { DownloadOutlined } from '@/components/icons';
import OperationRecordSearch from './components/operation-record-search.vue'; import OperationRecordSearch from './components/operation-record-search.vue';
import OperationRecordDetail from './components/operation-record-detail.vue'; import OperationRecordDetail from './components/operation-record-detail.vue';
import { import { pageOperationRecords } from '@/api/system/operation-record';
pageOperationRecords,
listOperationRecords
} from '@/api/system/operation-record';
import type { import type {
OperationRecord, OperationRecord,
OperationRecordParam OperationRecordParam
@ -85,73 +50,32 @@
type: 'index', type: 'index',
columnKey: 'index', columnKey: 'index',
width: 50, width: 50,
align: 'center' /* , align: 'center'
fixed: 'left' */
}, },
{ {
prop: 'username', prop: 'contextId',
label: '账号', label: 'ContextId',
sortable: 'custom', width: 280
minWidth: 110
}, },
{ {
prop: 'nickname', prop: 'operateUserId',
label: '用户名', label: '操作人',
sortable: 'custom', width: 110
minWidth: 110
}, },
{ {
prop: 'module', prop: 'location',
label: '操作模块', label: '地址',
sortable: 'custom', minWidth: 160
minWidth: 110 },
{
prop: 'event',
label: '事件',
width: 120
}, },
{ {
prop: 'description', prop: 'description',
label: '操作功能', label: '操作描述',
sortable: 'custom', minWidth: 210
minWidth: 110
},
{
prop: 'url',
label: '请求地址',
sortable: 'custom',
minWidth: 110
},
{
prop: 'requestMethod',
label: '请求方式',
sortable: 'custom',
width: 110,
align: 'center'
},
{
prop: 'status',
label: '状态',
sortable: 'custom',
width: 100,
align: 'center',
slot: 'status',
filters: [
{
text: '正常',
value: '0'
},
{
text: '异常',
value: '1'
}
],
filterMultiple: false,
formatter: (row) => (row.status == 0 ? '正常' : '异常')
},
{
prop: 'spendTime',
label: '耗时',
sortable: 'custom',
width: 100,
formatter: (row) => `${row.spendTime / 1000}s`,
align: 'center'
}, },
{ {
prop: 'createTime', prop: 'createTime',
@ -221,74 +145,4 @@
current.value = row; current.value = row;
showInfo.value = true; showInfo.value = true;
}; };
/** 导出数据 */
const exportData = () => {
// ()
const loading = EleMessage.loading({
message: '请求中..',
plain: true
});
tableRef.value?.fetch?.(({ where, orders, filters }) => {
listOperationRecords({ ...where, ...orders, ...filters })
.then((data) => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.addRow([
'账号',
'用户名',
'操作模块',
'操作功能',
'请求地址',
'请求方式',
'状态',
'耗时',
'操作时间'
]);
data.forEach((d) => {
sheet.addRow([
d.username,
d.nickname,
d.module,
d.description,
d.url,
d.requestMethod,
['正常', '异常'][d.status],
d.spendTime / 1000 + 's',
d.createTime
]);
});
//
[16, 16, 18, 20, 28, 14, 14, 14, 24].forEach((width, index) => {
sheet.getColumn(index + 1).width = width;
});
//
sheet.eachRow({ includeEmpty: true }, (row, rowIndex) => {
row.height = 20;
row.eachCell({ includeEmpty: true }, (cell) => {
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
bottom: { style: 'thin' },
right: { style: 'thin' }
};
cell.alignment = {
vertical: 'middle',
horizontal: 'center'
};
cell.font = { size: 12, bold: rowIndex === 1 };
});
});
//
workbook.xlsx.writeBuffer().then((data) => {
download(data, '操作日志.xlsx');
loading.close();
});
})
.catch((e) => {
loading.close();
EleMessage.error({ message: e.message, plain: true });
});
});
};
</script> </script>

View File

@ -14,11 +14,18 @@
<div>{{ data.requestIp }}</div> <div>{{ data.requestIp }}</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="请求时间"> <el-descriptions-item label="请求时间">
<div>{{ data.requestTime }}<span style="color:#9e9e9e;"> - {{ data.requestEndTime }}</span></div> <div
>{{ data.requestTime
}}<span style="color: #9e9e9e">
- {{ data.requestEndTime }}</span
></div
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="接口耗时"> <el-descriptions-item label="接口耗时">
<div v-if="!isNaN(data.runningTime)">{{ data.runningTime / 1000 }}s</div> <div v-if="!isNaN(data.runningTime)"
>{{ data.runningTime / 1000 }}s</div
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="请求方式"> <el-descriptions-item label="请求方式">
<div>{{ data.requestMethod }}</div> <div>{{ data.requestMethod }}</div>
@ -33,7 +40,9 @@
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="请求地址" :span="2"> <el-descriptions-item label="请求地址" :span="2">
<div style="word-break: break-all">{{ data.requestDomain }}{{ data.requestPath }}</div> <div style="word-break: break-all"
>{{ data.requestDomain }}{{ data.requestPath }}</div
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="规则名称" :span="2"> <el-descriptions-item label="规则名称" :span="2">
<div style="word-break: break-all">{{ data.ruleName }}</div> <div style="word-break: break-all">{{ data.ruleName }}</div>
@ -45,13 +54,21 @@
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps"> <ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.requestHeaders }} {{ data.requestHeaders }}
</ele-ellipsis> </ele-ellipsis>
<el-button size="small" @click="preview('请求头数据', 'json', data.requestHeaders)">预览</el-button> <el-button
size="small"
@click="preview('请求头数据', 'json', data.requestHeaders)"
>预览</el-button
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="请求参数" :span="2"> <el-descriptions-item label="请求参数" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps"> <ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.requestBody }} {{ data.requestBody }}
</ele-ellipsis> </ele-ellipsis>
<el-button size="small" @click="preview('请求参数', data.responseType, data.requestBody)">预览</el-button> <el-button
size="small"
@click="preview('请求参数', data.responseType, data.requestBody)"
>预览</el-button
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="返回类型" :span="1"> <el-descriptions-item label="返回类型" :span="1">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps"> <ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
@ -60,22 +77,23 @@
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="运行消耗" :span="1"> <el-descriptions-item label="运行消耗" :span="1">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps"> <ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ formatBytes(data.runningMemoryUsage) }} / {{ data.runningMemoryLimit }} {{ formatBytes(data.runningMemoryUsage) }} /
{{ data.runningMemoryLimit }}
</ele-ellipsis> </ele-ellipsis>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="返回结果" :span="2"> <el-descriptions-item label="返回结果" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps"> <ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.responseData }} {{ data.responseData }}
</ele-ellipsis> </ele-ellipsis>
<el-button size="small" @click="preview('返回结果',data.responseType, data.responseData)">预览</el-button> <el-button
size="small"
@click="preview('返回结果', data.responseType, data.responseData)"
>预览</el-button
>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-dialog <el-dialog v-model="previewVisible" :title="previewTitle" width="860">
v-model="previewVisible"
:title="previewTitle"
width="860"
>
<monaco-editor <monaco-editor
v-model="previewData" v-model="previewData"
language="json" language="json"
@ -90,8 +108,8 @@
import type { EleTooltipProps } from 'ele-admin-plus/es/ele-app/plus'; import type { EleTooltipProps } from 'ele-admin-plus/es/ele-app/plus';
import type { OperationRecord } from '@/api/system/operation-record/model'; import type { OperationRecord } from '@/api/system/operation-record/model';
import { useMobile } from '@/utils/use-mobile'; import { useMobile } from '@/utils/use-mobile';
import {formatBytes} from "@/utils/common"; import { formatBytes, formatJson } from '@/utils/common';
import MonacoEditor from "@/components/MonacoEditor/index.vue"; import MonacoEditor from '@/components/MonacoEditor/index.vue';
defineProps<{ defineProps<{
/** 修改回显的数据 */ /** 修改回显的数据 */
@ -124,93 +142,17 @@
const { mobile } = useMobile(); const { mobile } = useMobile();
const previewVisible = ref(false); const previewVisible = ref(false);
const previewData = ref(""); const previewData = ref('');
const previewTitle = ref(""); const previewTitle = ref('');
const preview = (title, type, data) => { const preview = (title, type, data) => {
previewTitle.value = title; previewTitle.value = title;
if(type == "think\\response\\Json" || type == 'json'){ if (type == 'think\\response\\Json' || type == 'json') {
previewData.value = formatJson(data); previewData.value = formatJson(data);
} else { } else {
previewData.value = data; previewData.value = data;
} }
previewVisible.value = true; previewVisible.value = true;
} };
/**
* 格式化JSON字符串
* @param {string} jsonString - 需要格式化的JSON字符串
* @param {number} indentSpaces - 缩进空格数默认为2
* @returns {string} 格式化后的JSON字符串
* @throws {Error} 当输入不是有效的JSON时抛出错误
*/
function formatJson(jsonString, indentSpaces = 2) {
//
// JSONJavaScript
let parsedObj;
if (typeof jsonString === 'string') {
// throw new Error('');
try {
parsedObj = JSON.parse(jsonString);
} catch (e) {
throw new Error('无效的JSON格式: ' + e.message);
}
}else{
parsedObj = jsonString;
}
//
function format(obj, depth) {
//
if (obj === null) {
return 'null';
}
if (typeof obj === 'boolean' || typeof obj === 'number') {
return obj.toString();
}
if (typeof obj === 'string') {
return '"' + obj + '"';
}
//
if (Array.isArray(obj)) {
if (obj.length === 0) {
return '[]';
}
let indent = ' '.repeat(depth * indentSpaces);
let innerIndent = ' '.repeat((depth + 1) * indentSpaces);
let items = [];
for (let i = 0; i < obj.length; i++) {
items.push(innerIndent + format(obj[i], depth + 1));
}
return '[\n' + items.join(',\n') + '\n' + indent + ']';
}
//
let indent = ' '.repeat(depth * indentSpaces);
let innerIndent = ' '.repeat((depth + 1) * indentSpaces);
let keys = Object.keys(obj);
if (keys.length === 0) {
return '{}';
}
let items = [];
for (let key of keys) {
items.push(innerIndent + '"' + key + '": ' + format(obj[key], depth + 1));
}
return '{\n' + items.join(',\n') + '\n' + indent + '}';
}
return format(parsedObj, 0);
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>