feat: 添加操作日志

This commit is contained in:
2025-02-12 00:15:35 +08:00
parent a89bc1febc
commit 35b94cc875
8 changed files with 798 additions and 2 deletions

View File

@@ -38,6 +38,7 @@ buttons:ResetPassword: Reset Password
buttons:RoleAllocation: Role Allocation
buttons:PermissionDetails: Permission Details
buttons:ForceToExit: Force Exit
buttons:Details: Details
search:Total: Total
search:History: History
search:Collect: Collect

View File

@@ -38,6 +38,7 @@ buttons:ResetPassword: 重置密码
buttons:RoleAllocation: 角色分配
buttons:PermissionDetails: 权限详情
buttons:ForceToExit: 强制退出
buttons:Details: 详情
search:Total:
search:History: 搜索历史
search:Collect: 收藏

View File

@@ -56,8 +56,8 @@
"@zxcvbn-ts/core": "^3.0.4",
"animate.css": "^4.1.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"cropperjs": "^1.6.2",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"element-plus": "^2.9.0",
"js-cookie": "^3.0.5",
@@ -74,6 +74,7 @@
"typeit": "^8.8.7",
"vue": "^3.5.13",
"vue-i18n": "^10.0.5",
"vue-json-pretty": "^2.4.0",
"vue-router": "^4.5.0",
"vue-tippy": "^6.5.0",
"vue-types": "^5.1.3"

13
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
vue-i18n:
specifier: ^10.0.5
version: 10.0.5(vue@3.5.13(typescript@5.6.3))
vue-json-pretty:
specifier: ^2.4.0
version: 2.4.0(vue@3.5.13(typescript@5.6.3))
vue-router:
specifier: ^4.5.0
version: 4.5.0(vue@3.5.13(typescript@5.6.3))
@@ -3603,6 +3606,12 @@ packages:
peerDependencies:
vue: ^3.0.0
vue-json-pretty@2.4.0:
resolution: {integrity: sha512-e9bP41DYYIc2tWaB6KuwqFJq5odZ8/GkE6vHQuGcbPn37kGk4a3n1RNw3ZYeDrl66NWXgTlOfS+M6NKkowmkWw==, tarball: https://registry.npmmirror.com/vue-json-pretty/-/vue-json-pretty-2.4.0.tgz}
engines: {node: '>= 10.0.0', npm: '>= 5.0.0'}
peerDependencies:
vue: '>=3.0.0'
vue-router@4.5.0:
resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==}
peerDependencies:
@@ -7258,6 +7267,10 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.6.3)
vue-json-pretty@2.4.0(vue@3.5.13(typescript@5.6.3)):
dependencies:
vue: 3.5.13(typescript@5.6.3)
vue-router@4.5.0(vue@3.5.13(typescript@5.6.3)):
dependencies:
'@vue/devtools-api': 6.6.4

View File

@@ -1,5 +1,5 @@
import { http } from "@/utils/http";
import type { UserLoginLogInfo } from "types/monitor";
import type { OperationLogInfo, UserLoginLogInfo } from "types/monitor";
import { filterEmptyObject } from "./utils";
// --------------------------登录日志相关--------------------------------------
@@ -28,3 +28,21 @@ export const getUserLoginLogAPI = (params: {
export const deleteUserOnlineAPI = (id: string) => {
return http.request<null>("delete", `/api/log/logout/${id}`);
};
// ------------------------操作日志相关----------------------------------------
/**
* 获取用户操作日志
*/
export const getUserOperationsAPI = (params: {
page: number;
pageSize: number;
}) => {
return http.request<QueryListResult<OperationLogInfo>>(
"GET",
"/api/log/operation",
{
params
}
);
};

View File

@@ -0,0 +1,134 @@
<template>
<div class="p-4">
<!-- 操作日志基本信息 -->
<el-card class="mb-4">
<el-descriptions title="操作日志详情" border>
<el-descriptions-item label="操作名称">{{
log.operation_name
}}</el-descriptions-item>
<el-descriptions-item label="操作类型">{{
log.operation_type
}}</el-descriptions-item>
<el-descriptions-item label="请求路径">{{
log.request_path
}}</el-descriptions-item>
<el-descriptions-item label="请求方法">{{
log.request_method
}}</el-descriptions-item>
<el-descriptions-item label="操作人ID">{{
log.operator_id
}}</el-descriptions-item>
<el-descriptions-item label="操作人姓名">{{
log.operator_name
}}</el-descriptions-item>
<el-descriptions-item label="操作人昵称">{{
log.operator_nickname
}}</el-descriptions-item>
<el-descriptions-item label="部门ID">{{
log.department_id
}}</el-descriptions-item>
<el-descriptions-item label="部门名称">{{
log.department_name
}}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{
log.operation_time
}}</el-descriptions-item>
<el-descriptions-item label="耗时"
>{{ log.cost_time }} </el-descriptions-item
>
<el-descriptions-item label="IP地址">{{
log.host
}}</el-descriptions-item>
<el-descriptions-item label="地理位置">{{
log.location
}}</el-descriptions-item>
<el-descriptions-item label="浏览器">{{
log.browser
}}</el-descriptions-item>
<el-descriptions-item label="操作系统">{{
log.os
}}</el-descriptions-item>
<el-descriptions-item label="User-Agent">{{
log.user_agent
}}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 折叠面板 -->
<el-card>
<el-collapse v-model="activeCollapse">
<!-- 请求参数 -->
<el-collapse-item title="请求参数" name="requestParams">
<vue-json-pretty :data="parsedRequestParams" />
</el-collapse-item>
<!-- 响应结果 -->
<el-collapse-item title="响应结果" name="responseResult">
<vue-json-pretty :data="parsedResponseResult" />
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import VueJsonPretty from "vue-json-pretty";
import "vue-json-pretty/lib/styles.css";
// 操作日志数据
const log = ref({
id: "9d40633c-677b-4adc-9a2e-7676fc6924df",
operation_name: "获取用户列表",
operation_type: 1,
request_path: "/user/list",
request_method: "GET",
request_params: "{}",
response_result:
'{"code": 200, "msg": "操作成功", "success": true, "time": "2025-01-29T00:26:23.979927", "data": {"result": [{"id": "8242939b-01af-4e4f-be6a-045ff95b51b6", "create_time": "2025-01-20T21:43:45.800419+08:00", "update_time": "2025-01-27T02:05:34.436735+08:00", "username": "admin", "email": "zhangsan@example.com", "phone": "13888888888", "nickname": "张三", "gender": 0, "status": 1, "department_id": "74aae292-f318-4e07-93b8-3772d9792351"}], "total": 1, "page": 1}}',
host: "127.0.0.1",
location: "",
browser: "Edge 132",
os: "Windows 10",
user_agent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0",
operator_id: "8242939b-01af-4e4f-be6a-045ff95b51b6",
operator_name: "admin",
operator_nickname: "张三",
department_id: "74aae292-f318-4e07-93b8-3772d9792351",
department_name: "系统管理",
status: 1,
operation_time: "2025-01-29T00:26:23.982990+08:00",
cost_time: 0.7223844528198242
});
// 解析请求参数
const parsedRequestParams = computed(() => {
try {
return JSON.parse(log.value.request_params);
} catch (e) {
return {};
}
});
// 解析响应结果
const parsedResponseResult = computed(() => {
try {
return JSON.parse(log.value.response_result);
} catch (e) {
return {};
}
});
// 折叠面板的激活项
const activeCollapse = ref(["requestParams", "responseResult"]);
</script>
<style scoped lang="scss">
.el-descriptions {
margin-bottom: 20px;
}
.el-collapse {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,308 @@
import dayjs from "dayjs";
import Detail from "./components/form.vue";
import { message } from "@/utils/message";
import { addDialog } from "@/components/ReDialog";
import { getKeyList } from "@pureadmin/utils";
import { getUserOperationsAPI } from "@/api/monitor";
import { usePublicHooks } from "@/views/system/hooks";
import type { PaginationProps } from "@pureadmin/table";
import { type Ref, reactive, ref, onMounted, h } from "vue";
import type { OperationLogInfo } from "types/monitor";
export function useOperation(tableRef: Ref) {
const form = reactive({
module: "",
status: "",
operatingTime: ""
});
const dataList = ref<OperationLogInfo[]>([]);
const loading = ref(true);
const selectedNum = ref(0);
const { tagStyle } = usePublicHooks();
const pagination = reactive<PaginationProps>({
total: 0,
pageSize: 10,
currentPage: 1,
background: true
});
const getOperationType = (type: number) => {
switch (type) {
case 1:
return "primary";
case 2:
return "success";
case 3:
return "warning";
case 4:
return "danger";
case 5:
return "success";
case 6:
return "primary";
case 7:
return "success";
case 8:
return "danger";
default:
return "info";
}
};
const getOperationName = (type: number) => {
switch (type) {
case 1:
return "查询";
case 2:
return "新增";
case 3:
return "修改";
case 4:
return "删除";
case 5:
return "授权";
case 6:
return "导出";
case 7:
return "导入";
case 8:
return "强退";
default:
return "其他";
}
};
const getRequestType = (method: string) => {
switch (method) {
case "GET":
return "primary";
case "POST":
return "success";
case "PUT":
return "warning";
case "DELETE":
return "danger";
case "PATCH":
return "info";
default:
return "info";
}
};
const columns: TableColumnList = [
{
label: "勾选列", // 如果需要表格多选此处label必须设置
type: "selection",
fixed: "left",
reserveSelection: true // 数据刷新后保留选项
},
{
label: "操作人员账号",
prop: "operator_name",
minWidth: 100
},
{
label: "操作人员昵称",
prop: "operator_nickname",
minWidth: 100
},
{
label: "操作名称",
prop: "operation_name",
minWidth: 100
},
{
label: "操作类型",
prop: "operation_type",
cellRenderer: ({ row, props }) => (
<el-tag size={props.size} type={getOperationType(row.operation_type)}>
{getOperationName(row.operation_type)}
</el-tag>
)
},
{
label: "操作IP",
prop: "host"
},
{
label: "操作地点",
prop: "location"
},
{
label: "请求方法",
prop: "request_method",
cellRenderer: ({ row, props }) => (
<el-tag size={props.size} type={getRequestType(row.request_method)}>
{row.request_method}
</el-tag>
)
},
{
label: "请求路径",
prop: "request_path"
},
{
label: "请求耗时",
prop: "cost_time",
cellRenderer: ({ row, props }) => (
<el-tag
size={props.size}
type={row.cost_time < 1000 ? "success" : "warning"}
effect="plain"
>
{row.cost_time.toFixed(2)} ms
</el-tag>
)
},
{
label: "操作系统",
prop: "os",
hide: true
},
{
label: "浏览器类型",
prop: "browser",
hide: true
},
{
label: "操作状态",
prop: "status",
cellRenderer: ({ row, props }) => (
<el-tag size={props.size} style={tagStyle.value(row.status)}>
{row.status === 1 ? "成功" : "失败"}
</el-tag>
)
},
{
label: "操作时间",
prop: "operation_time",
minWidth: 180,
formatter: ({ operation_time }) =>
dayjs(operation_time).format("YYYY-MM-DD HH:mm:ss")
},
{
label: "操作",
fixed: "right",
width: 100,
slot: "operation"
}
];
const handleSizeChange = async (val: number) => {
const res = await getUserOperationsAPI({
page: pagination.currentPage,
pageSize: val
});
if (res.success) {
dataList.value = res.data.result;
pagination.total = res.data.total;
pagination.currentPage = res.data.page;
}
};
const handleCurrentChange = async (val: number) => {
const res = await getUserOperationsAPI({
page: val,
pageSize: pagination.pageSize
});
if (res.success) {
dataList.value = res.data.result;
pagination.total = res.data.total;
pagination.currentPage = res.data.page;
}
};
/** 当CheckBox选择项发生变化时会触发该事件 */
const handleSelectionChange = val => {
selectedNum.value = val.length;
// 重置表格高度
tableRef.value.setAdaptive();
};
/** 取消选择 */
const onSelectionCancel = () => {
selectedNum.value = 0;
// 用于多选表格,清空用户的选择
tableRef.value.getTableRef().clearSelection();
};
/** 批量删除 */
const onbatchDel = () => {
// 返回当前选中的行
const curSelected = tableRef.value.getTableRef().getSelectionRows();
// 接下来根据实际业务通过选中行的某项数据比如下面的id调用接口进行批量删除
message(`已删除序号为 ${getKeyList(curSelected, "id")} 的数据`, {
type: "success"
});
tableRef.value.getTableRef().clearSelection();
onSearch();
};
/** 清空日志 */
const clearAll = async () => {
// 根据实际业务,调用接口删除所有日志数据
message("已删除所有日志数据", {
type: "success"
});
await onSearch();
};
const onSearch = async () => {
loading.value = true;
const res = await getUserOperationsAPI({
page: pagination.currentPage,
pageSize: pagination.pageSize
});
if (res.success) {
dataList.value = res.data.result;
pagination.total = res.data.total;
pagination.currentPage = res.data.page;
}
setTimeout(() => {
loading.value = false;
}, 500);
};
const resetForm = formEl => {
if (!formEl) return;
formEl.resetFields();
onSearch();
};
const onDetailHandle = () => {
addDialog({
title: `详情`,
props: {},
width: "40%",
draggable: true,
fullscreenIcon: true,
closeOnClickModal: false,
contentRenderer: () => h(Detail, {}),
beforeSure: (done, {}) => {
done();
}
});
};
onMounted(async () => {
await onSearch();
});
return {
form,
loading,
columns,
dataList,
pagination,
selectedNum,
onSearch,
clearAll,
resetForm,
onbatchDel,
getOperationName,
onDetailHandle,
handleSizeChange,
onSelectionCancel,
handleCurrentChange,
handleSelectionChange
};
}

View File

@@ -0,0 +1,320 @@
<script setup lang="ts">
import dayjs from "dayjs";
import { ref, reactive, computed } from "vue";
import { useOperation } from "./hook";
import { getPickerShortcuts } from "../utils";
import { PureTableBar } from "@/components/RePureTableBar";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { useI18n } from "vue-i18n";
import VueJsonPretty from "vue-json-pretty";
import "vue-json-pretty/lib/styles.css";
import View from "@iconify-icons/ep/view";
import Delete from "@iconify-icons/ep/delete";
import Refresh from "@iconify-icons/ep/refresh";
import { OperationLogInfo } from "types/monitor";
defineOptions({
name: "OperationLog"
});
const { t } = useI18n();
const formRef = ref();
const tableRef = ref();
/**
* 抽屉状态
*/
const drawerStatus = ref<boolean>(false);
// 折叠面板的激活项
const activeCollapse = ref(["requestinfo"]);
const logInfo = reactive<OperationLogInfo>({
id: "",
operation_name: "",
operation_type: 0,
request_path: "",
request_method: "",
request_params: "{}",
response_result: "{}",
host: "",
location: "",
browser: "",
os: "",
user_agent: "",
operator_id: "",
operator_name: "",
operator_nickname: "",
department_id: "",
department_name: "",
status: 1,
operation_time: "",
cost_time: 0
});
/**
* 操作日志详情展开
*/
const onDetailHandle = (row: OperationLogInfo) => {
activeCollapse.value = ["requestinfo"];
drawerStatus.value = true;
Object.assign(logInfo, row);
};
// 解析请求参数
const parsedRequestParams = computed(() => {
try {
return JSON.parse(logInfo.request_params);
} catch (e) {
return {};
}
});
// 解析响应结果
const parsedResponseResult = computed(() => {
try {
return JSON.parse(logInfo.response_result);
} catch (e) {
return {};
}
});
const {
form,
loading,
columns,
dataList,
pagination,
selectedNum,
onSearch,
clearAll,
resetForm,
onbatchDel,
getOperationName,
handleSizeChange,
onSelectionCancel,
handleCurrentChange,
handleSelectionChange
} = useOperation(tableRef);
</script>
<template>
<div class="main">
<el-form
ref="formRef"
:inline="true"
:model="form"
class="search-form bg-bg_color w-[99/100] pl-8 pt-[12px] overflow-auto"
>
<el-form-item label="所属模块" prop="module">
<el-input
v-model="form.module"
placeholder="请输入所属模块"
clearable
class="!w-[170px]"
/>
</el-form-item>
<el-form-item label="操作状态" prop="status">
<el-select
v-model="form.status"
placeholder="请选择"
clearable
class="!w-[150px]"
>
<el-option label="成功" value="1" />
<el-option label="失败" value="0" />
</el-select>
</el-form-item>
<el-form-item label="操作时间" prop="operatingTime">
<el-date-picker
v-model="form.operatingTime"
:shortcuts="getPickerShortcuts()"
type="datetimerange"
range-separator=""
start-placeholder="开始日期时间"
end-placeholder="结束日期时间"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:icon="useRenderIcon('ri:search-line')"
:loading="loading"
@click="onSearch"
>
{{ t("buttons:Search") }}
</el-button>
<el-button :icon="useRenderIcon(Refresh)" @click="resetForm(formRef)">
{{ t("buttons:Reset") }}
</el-button>
</el-form-item>
</el-form>
<PureTableBar title="操作日志" :columns="columns" @refresh="onSearch">
<template #buttons>
<!-- <el-popconfirm title="确定要删除所有日志数据吗?" @confirm="clearAll">
<template #reference>
<el-button type="danger" :icon="useRenderIcon(Delete)">
清空日志
</el-button>
</template>
</el-popconfirm> -->
</template>
<template v-slot="{ size, dynamicColumns }">
<div
v-if="selectedNum > 0"
v-motion-fade
class="bg-[var(--el-fill-color-light)] w-full h-[46px] mb-2 pl-4 flex items-center"
>
<div class="flex-auto">
<span
style="font-size: var(--el-font-size-base)"
class="text-[rgba(42,46,54,0.5)] dark:text-[rgba(220,220,242,0.5)]"
>
已选 {{ selectedNum }}
</span>
<el-button type="primary" text @click="onSelectionCancel">
{{ t("buttons:Deselect") }}
</el-button>
</div>
<el-popconfirm title="是否确认删除?" @confirm="onbatchDel">
<template #reference>
<el-button type="danger" text class="mr-1">
{{ t("buttons:DeleteInBatches") }}
</el-button>
</template>
</el-popconfirm>
</div>
<pure-table
ref="tableRef"
row-key="id"
align-whole="center"
table-layout="auto"
:loading="loading"
:size="size"
adaptive
:adaptiveConfig="{ offsetBottom: 108 }"
:data="dataList"
:columns="dynamicColumns"
:pagination="{ ...pagination, size }"
:header-cell-style="{
background: 'var(--el-fill-color-light)',
color: 'var(--el-text-color-primary)'
}"
@selection-change="handleSelectionChange"
@page-size-change="handleSizeChange"
@page-current-change="handleCurrentChange"
>
<template #operation="{ row }">
<el-button
class="reset-margin"
link
type="primary"
:size="size"
:icon="useRenderIcon(View)"
@click="onDetailHandle(row)"
>
{{ t("buttons:Details") }}
</el-button>
</template>
</pure-table>
</template>
</PureTableBar>
<el-drawer
v-model="drawerStatus"
title="操作日志详情"
:size="`50%`"
show-close
>
<el-collapse v-model="activeCollapse" :accordion="false">
<!-- 请求基本信息 -->
<el-collapse-item title="基本信息" name="requestinfo">
<el-descriptions title="日志基本信息" border>
<el-descriptions-item align="center" label="操作名称">{{
logInfo.operation_name
}}</el-descriptions-item>
<el-descriptions-item align="center" label="操作类型">{{
getOperationName(logInfo.operation_type)
}}</el-descriptions-item>
<el-descriptions-item align="center" label="请求路径">{{
logInfo.request_path
}}</el-descriptions-item>
<el-descriptions-item align="center" label="请求方法">{{
logInfo.request_method
}}</el-descriptions-item>
<el-descriptions-item align="center" label="操作人账号">{{
logInfo.operator_name
}}</el-descriptions-item>
<el-descriptions-item align="center" label="操作人昵称">{{
logInfo.operator_nickname
}}</el-descriptions-item>
<el-descriptions-item align="center" label="所属部门">{{
logInfo.department_name
}}</el-descriptions-item>
<el-descriptions-item align="center" label="操作时间">{{
dayjs(logInfo.operation_time).format("YYYY-MM-DD HH:mm:ss")
}}</el-descriptions-item>
<el-descriptions-item align="center" label="耗时"
>{{ logInfo.cost_time.toFixed(2) }} ms</el-descriptions-item
>
<el-descriptions-item align="center" label="请求IP">{{
logInfo.host
}}</el-descriptions-item>
<el-descriptions-item align="center" label="请求位置">{{
logInfo.location
}}</el-descriptions-item>
<el-descriptions-item align="center" label="请求浏览器">{{
logInfo.browser
}}</el-descriptions-item>
<el-descriptions-item align="center" label="请求系统">{{
logInfo.os
}}</el-descriptions-item>
<el-descriptions-item align="center" label="请求头">{{
logInfo.user_agent
}}</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
<!-- 请求参数 -->
<el-collapse-item title="请求参数" name="requestParams">
<vue-json-pretty
:data="parsedRequestParams"
showLineNumber
collapsedOnClickBrackets
showSelectController
showIcon
virtual
showLength
/>
</el-collapse-item>
<!-- 响应结果 -->
<el-collapse-item title="响应结果" name="responseResult">
<vue-json-pretty
:data="parsedResponseResult"
showLineNumber
collapsedOnClickBrackets
showSelectController
showIcon
virtual
showLength
/>
</el-collapse-item>
</el-collapse>
</el-drawer>
</div>
</template>
<style lang="scss" scoped>
:deep(.el-dropdown-menu__item i) {
margin: 0;
}
.main-content {
margin: 24px 24px 0 !important;
}
.search-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
</style>