This commit is contained in:
u2nyakim 2025-08-27 17:41:16 +08:00
parent e60702340b
commit 9c2e5a7b0b
10 changed files with 534 additions and 14 deletions

View File

@ -58,7 +58,7 @@ class MenuController extends BaseController
'path' => $data['path'], 'path' => $data['path'],
'component' => $data['component'], 'component' => $data['component'],
'authority' => $data['authority'], 'authority' => $data['authority'],
'sortNumber' => $data['sortNumber'], 'sort_number' => $data['sortNumber'],
'hide' => $data['hide'], 'hide' => $data['hide'],
'meta' => $data['meta'], 'meta' => $data['meta'],
]); ]);

View File

@ -5,7 +5,6 @@ namespace app\controller\admin\system;
use app\BaseController; use app\BaseController;
use app\entity\SysRequestRecord; use app\entity\SysRequestRecord;
use app\service\CurdService; use app\service\CurdService;
use think\db\exception\DbException;
use think\response\Json; use think\response\Json;
class RequestRecordController extends BaseController class RequestRecordController extends BaseController
@ -13,19 +12,32 @@ class RequestRecordController extends BaseController
/** /**
* 分页查询请求日志记录 * 分页查询请求日志记录
* @return Json * @return Json
* @throws DbException
*/ */
public function page(): Json public function page(): Json
{ {
$model = SysRequestRecord::withSearch(['createTime'], [ ini_set('memory_limit', '512M');
'createTime' => [ try {
$this->request->get('createTimeStart/s', ''), /*
$this->request->get('createTimeEnd/s', '') * 这里有两个字段数据太大,提到详情里面做查询
], */
]); $model = SysRequestRecord::withoutField('request_headers,response_data')->withSearch(['createTime'], [
'createTime' => [
$paginate = CurdService::getPaginate($this->request, $model); $this->request->get('createTimeStart/s', ''),
$this->request->get('createTimeEnd/s', '')
],
]);
$paginate = CurdService::getPaginate($this->request, $model);
} catch (\Exception $e) {
return $this->writeError($e->getMessage());
}
return $this->writeSuccess('ok', $paginate); return $this->writeSuccess('ok', $paginate);
} }
public function info(): Json
{
ini_set('memory_limit', '512M');
$id = $this->request->get('id');
$data = SysRequestRecord::find($id);
return $this->writeSuccess('ok', $data);
}
} }

View File

@ -25,6 +25,10 @@ class ContextMiddleware extends middleware
return $response; return $response;
} }
protected $noHttpLog = [
'GET@/adminapi/system/request-record'
];
/** /**
* 结束 * 结束
* @param Response $response * @param Response $response
@ -36,8 +40,8 @@ class ContextMiddleware extends middleware
$log = (array)$this->app->config->get('admin.httpLog'); $log = (array)$this->app->config->get('admin.httpLog');
$logOpen = $log['open'] ?? false; $logOpen = $log['open'] ?? false;
$logOpen = false;
if (!$logOpen) { if (!$logOpen || in_array($request->method() . '@' . $request->pathinfo(),$this->noHttpLog)) {
return; return;
} }
$request_start_time = $request->time(true); $request_start_time = $request->time(true);

View File

@ -55,6 +55,7 @@ Route::group("adminapi", function () {
Route::get('login-record/page', [LoginRecordController::class, "page"])->name("system.pageLoginRecords"); Route::get('login-record/page', [LoginRecordController::class, "page"])->name("system.pageLoginRecords");
Route::get('operate-record/page', [OperateRecordController::class, "page"])->name("system.pageOperateRecords"); Route::get('operate-record/page', [OperateRecordController::class, "page"])->name("system.pageOperateRecords");
Route::get('request-record/page', [RequestRecordController::class, "page"])->name("system.pageRequestRecords"); Route::get('request-record/page', [RequestRecordController::class, "page"])->name("system.pageRequestRecords");
Route::get('request-record/info', [RequestRecordController::class, "info"])->name("system.pageRequestInfo");
/* /*
* 缓存管理 * 缓存管理
*/ */

View File

@ -0,0 +1,30 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { RequestRecord, RequestRecordParam } from './model';
/**
*
*/
export async function pageRequestRecords(params: RequestRecordParam) {
const res = await request.get<ApiResult<PageResult<RequestRecord>>>(
'/system/request-record/page',
{ params }
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
*
*/
export async function getRequestRecordInfo(id: number) {
const res = await request.get<ApiResult<RequestRecord>>(
'/system/request-record/info?id=' + id
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}

View File

@ -0,0 +1,57 @@
import { PageParam } from '@/api';
/**
*
*/
export interface RequestRecord {
/** 操作日志id */
id?: number;
/** 用户id */
userId?: number;
/** 操作模块 */
module: string;
/** 操作功能 */
description: string;
/** 请求地址 */
url: 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;
/** 用户昵称 */
nickname: string;
/** 用户账号 */
username: string;
}
/**
*
*/
export interface RequestRecordParam extends PageParam {
/** contextId */
contextId?: string;
/** 开始时间 */
createTimeStart?: string;
/** 截至时间 */
createTimeEnd?: string;
}

View File

@ -219,3 +219,34 @@ export function doWithTransition(
}; };
}); });
} }
/**
* GB, MB, KB, B
* @param bytes
* @param precision 2
* @returns '1.23GB', '500B'
*/
export function formatBytes(bytes: number, precision: number = 2): string {
// 定义单位常量
const UNITS = ['B', 'KB', 'MB', 'GB'];
const UNIT_SCALE = 1024;
// 处理无效输入
if (isNaN(bytes) || !isFinite(bytes)) return '0B';
if (bytes === 0) return '0B';
// 计算单位级别
const level = Math.floor(Math.log(bytes) / Math.log(UNIT_SCALE));
// 获取转换后的值和对应单位
const unit = UNITS[Math.min(level, UNITS.length - 1)];
const value = bytes / Math.pow(UNIT_SCALE, level);
// 智能处理小数部分:
// 1. 当结果为整数时省略小数部分
// 2. 保留指定精度但去除末尾多余的0
const formattedValue = value.toFixed(precision).replace(/(\.0+|0+)$/, '');
return `${formattedValue}${unit}`;
}

View File

@ -0,0 +1,121 @@
<!-- 详情弹窗 -->
<template>
<ele-modal title="请求详情" :width="960" v-model="visible">
<el-descriptions
v-if="data"
:border="true"
:column="mobile ? 1 : 2"
class="detail-table"
>
<el-descriptions-item label="ContextId">
<div>{{ data.contextId }}</div>
</el-descriptions-item>
<el-descriptions-item label="IP地址">
<div>{{ data.requestIp }}</div>
</el-descriptions-item>
<el-descriptions-item label="请求时间">
<div>{{ data.requestTime }}<span style="color:#9e9e9e;"> - {{ data.requestEndTime }}</span></div>
</el-descriptions-item>
<el-descriptions-item label="接口耗时">
<div v-if="!isNaN(data.runningTime)">{{ data.runningTime / 1000 }}s</div>
</el-descriptions-item>
<el-descriptions-item label="请求方式">
<div>{{ data.requestMethod }}</div>
</el-descriptions-item>
<el-descriptions-item label="状态码">
<el-tag
size="small"
:type="data.responseCode === 200 ? 'success' : 'danger'"
:disable-transitions="true"
>
{{ data.responseCode }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="请求地址" :span="2">
<div style="word-break: break-all">{{ data.requestDomain }}{{ data.requestPath }}</div>
</el-descriptions-item>
<el-descriptions-item label="规则名称" :span="2">
<div style="word-break: break-all">{{ data.ruleName }}</div>
</el-descriptions-item>
<el-descriptions-item label="路由地址" :span="2">
<div style="word-break: break-all">{{ data.ruleRoute }}</div>
</el-descriptions-item>
<el-descriptions-item label="请求头信息" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.requestHeaders }}
</ele-ellipsis>
</el-descriptions-item>
<el-descriptions-item label="请求参数" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.requestBody }}
</ele-ellipsis>
</el-descriptions-item>
<el-descriptions-item label="返回类型" :span="1">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.responseType }}
</ele-ellipsis>
</el-descriptions-item>
<el-descriptions-item label="运行消耗" :span="1">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ formatBytes(data.runningMemoryUsage) }} / {{ data.runningMemoryLimit }}
</ele-ellipsis>
</el-descriptions-item>
<el-descriptions-item label="返回结果" :span="2">
<ele-ellipsis :max-line="4" :tooltip="ellipsisTooltipProps">
{{ data.responseData }}
</ele-ellipsis>
</el-descriptions-item>
</el-descriptions>
</ele-modal>
</template>
<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 { useMobile } from '@/utils/use-mobile';
import {formatBytes} from "@/utils/common";
defineProps<{
/** 修改回显的数据 */
data: OperationRecord;
}>();
/** 弹窗是否打开 */
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();
</script>
<style lang="scss" scoped>
.detail-table :deep(td.el-descriptions__label.el-descriptions__cell) {
width: 88px;
text-align: right;
font-weight: normal;
}
.detail-table :deep(.el-descriptions__content > div) {
max-height: 100%;
}
</style>

View File

@ -0,0 +1,65 @@
<!-- 搜索表单 -->
<template>
<ele-card :body-style="{ paddingBottom: '2px' }">
<el-form label-width="72px" @keyup.enter="search" @submit.prevent="">
<el-row :gutter="8">
<el-col :lg="6" :md="18" :sm="17" :xs="24">
<el-form-item label="上下文ID">
<el-input v-model="form.contextId" maxlength="32" placeholder=""></el-input>
</el-form-item>
<el-form-item label="请求时间">
<el-date-picker
unlink-panels
type="datetimerange"
v-model="dateRange"
range-separator="-"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
class="ele-fluid"
/>
</el-form-item>
</el-col>
<el-col :lg="4" :md="6" :sm="7" :xs="24">
<el-form-item label-width="6px">
<div style="white-space: nowrap">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
</ele-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useFormData } from '@/utils/use-form-data';
import type { RequestRecordParam } from '@/api/system/request-record/model';
const emit = defineEmits<{
(e: 'search', where?: RequestRecordParam): void;
}>();
/** 表单数据 */
const [form, resetFields] = useFormData<RequestRecordParam>({
contextId: ""
});
/** 日期范围 */
const dateRange = ref<[string, string]>(['', '']);
/** 搜索 */
const search = () => {
const [createTimeStart, createTimeEnd] = dateRange.value || [];
emit('search', { ...form, createTimeStart, createTimeEnd });
};
/** 重置 */
const reset = () => {
resetFields();
dateRange.value = ['', ''];
search();
};
</script>

View File

@ -0,0 +1,199 @@
<template>
<ele-page>
<request-record-search @search="reload" />
<ele-card :body-style="{ paddingTop: '8px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:show-overflow-tooltip="true"
:highlight-current-row="true"
:export-config="{ fileName: '请求日志数据' }"
cache-key="systemOperationRecordTable"
>
<template #requestPath="{ row }">
<el-tag size="small">{{ row.requestMethod }}</el-tag>
<span style="margin-left: 10px;">{{ row.requestPath }}</span>
</template>
<template #responseCode="{ row }">
<el-tag
size="small"
:type="row.responseCode === 200 ? 'success' : 'danger'"
:disable-transitions="true"
>
{{ row.responseCode }}
</el-tag>
</template>
<template #action="{ row }">
<el-link type="primary" underline="never" @click="openDetail(row)">
详情
</el-link>
</template>
</ele-pro-table>
</ele-card>
<!-- 详情弹窗 -->
<request-record-detail v-model="showInfo" :data="current" />
</ele-page>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import type { EleProTable } from 'ele-admin-plus';
import type {
DatasourceFunction,
Columns
} from 'ele-admin-plus/es/ele-pro-table/types';
import RequestRecordSearch from './components/request-record-search.vue';
import RequestRecordDetail from './components/request-record-detail.vue';
import {
getRequestRecordInfo,
pageRequestRecords
} from '@/api/system/request-record';
import type {
RequestRecord,
RequestRecordParam
} from '@/api/system/request-record/model';
import {formatBytes} from "@/utils/common";
defineOptions({ name: 'SystemRequestRecord' });
/** 表格实例 */
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
/** 表格列配置 */
const columns = ref<Columns>([
{
type: 'index',
columnKey: 'index',
width: 50,
align: 'center' /* ,
fixed: 'left' */
},
{
prop: 'contextId',
label: 'ContextId',
width: 280
},
{
prop: 'requestPath',
label: 'Request',
slot: 'requestPath',
minWidth: 220
},
{
prop: 'ruleName',
label: '规则名称',
minWidth: 110
},
{
prop: 'ruleRoute',
label: '路由地址',
minWidth: 210
},
{
prop: 'responseCode',
label: '状态码',
width: 100,
align: 'center',
slot: 'responseCode',
filters: [
{
text: '正常',
value: '1'
},
{
text: '异常',
value: '2'
}
],
filterMultiple: false,
formatter: (row) => (row.responseCode == 200 ? '正常' : '异常')
},
{
prop: 'runningTime',
label: '耗时',
sortable: 'custom',
width: 100,
formatter: (row) => `${row.runningTime / 1000}s`,
align: 'center'
},
{
prop: 'runningMemoryUsage',
label: '内存消耗',
width: 110,
sortable: 'custom',
align: 'center',
formatter: (row) => formatBytes(row.runningMemoryUsage),
},
{
prop: 'requestTime',
label: '请求时间',
align: 'center',
width: 180
},
{
columnKey: 'action',
label: '操作',
width: 90,
align: 'center',
slot: 'action' /* ,
fixed: 'right' */,
hideInPrint: true,
hideInExport: true
}
]);
/** 当前选中数据 */
const current = ref<RequestRecord>({
module: '',
description: '',
url: '',
requestMethod: '',
method: '',
params: '',
result: '',
error: '',
spendTime: 0,
os: '',
device: '',
browser: '',
ip: '',
status: 0,
createTime: '',
nickname: '',
username: ''
});
/** 是否显示查看弹窗 */
const showInfo = ref(false);
/** 表格数据源 */
const datasource: DatasourceFunction = ({
pages,
where,
orders,
filters
}) => {
return pageRequestRecords({
...where,
...orders,
...filters,
...pages
});
};
/** 刷新表格 */
const reload = (where?: RequestRecordParam) => {
tableRef.value?.reload?.({ page: 1, where });
};
/** 详情 */
const openDetail = async (row: RequestRecord) => {
current.value = await getRequestRecordInfo(row.id);
showInfo.value = true;
};
</script>