feat: 新增操作日志列表

This commit is contained in:
valarchie 2023-07-15 17:50:38 +08:00
parent b262de72fb
commit 61a980a37d
11 changed files with 788 additions and 6 deletions

View File

@ -7,6 +7,7 @@
const include = [
"qs",
"mitt",
"xlsx",
"dayjs",
"axios",
"pinia",

View File

@ -55,7 +55,8 @@
"typeit": "^8.7.1",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"vue-types": "^5.1.0"
"vue-types": "^5.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@commitlint/cli": "^17.6.6",

89
pnpm-lock.yaml generated
View File

@ -88,6 +88,7 @@ specifiers:
vue-router: ^4.2.2
vue-tsc: ^1.8.1
vue-types: ^5.1.0
xlsx: ^0.18.5
dependencies:
"@pureadmin/descriptions": 1.1.1_element-plus@2.3.6
@ -117,6 +118,7 @@ dependencies:
vue: 3.3.4
vue-router: 4.2.2_vue@3.3.4
vue-types: 5.1.0_vue@3.3.4
xlsx: 0.18.5
devDependencies:
"@commitlint/cli": 17.6.6
@ -2310,6 +2312,14 @@ packages:
engines: { node: ">=0.4.0" }
hasBin: true
/adler-32/1.3.1:
resolution:
{
integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
}
engines: { node: ">=0.8" }
dev: false
/agent-base/6.0.2:
resolution:
{
@ -2732,6 +2742,17 @@ packages:
integrity: sha512-sdQZOJdmt3GJs1UMNpCCCyeuS2IEGLXnHyAo9yIO5JJDjbjoVRij4M1qep6P6gFpptD1PqIYgzM+gwJbOi92mw==
}
/cfb/1.2.2:
resolution:
{
integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
}
engines: { node: ">=0.8" }
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
dev: false
/chalk/2.4.2:
resolution:
{
@ -2870,6 +2891,14 @@ packages:
hasBin: true
dev: true
/codepage/1.15.0:
resolution:
{
integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
}
engines: { node: ">=0.8" }
dev: false
/color-convert/1.9.3:
resolution:
{
@ -3105,6 +3134,15 @@ packages:
path-type: 4.0.0
dev: true
/crc-32/1.2.2:
resolution:
{
integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
}
engines: { node: ">=0.8" }
hasBin: true
dev: false
/create-require/1.1.1:
resolution:
{
@ -4343,6 +4381,14 @@ packages:
mime-types: 2.1.35
dev: false
/frac/1.1.2:
resolution:
{
integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
}
engines: { node: ">=0.8" }
dev: false
/fraction.js/4.2.0:
resolution:
{
@ -7965,6 +8011,16 @@ packages:
readable-stream: 3.6.2
dev: true
/ssf/0.11.2:
resolution:
{
integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
}
engines: { node: ">=0.8" }
dependencies:
frac: 1.1.2
dev: false
/stable/0.1.8:
resolution:
{
@ -9210,6 +9266,14 @@ packages:
isexe: 2.0.0
dev: true
/wmf/1.0.2:
resolution:
{
integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
}
engines: { node: ">=0.8" }
dev: false
/word-wrap/1.2.3:
resolution:
{
@ -9218,6 +9282,14 @@ packages:
engines: { node: ">=0.10.0" }
dev: true
/word/0.3.0:
resolution:
{
integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
}
engines: { node: ">=0.8" }
dev: false
/wrap-ansi/6.2.0:
resolution:
{
@ -9271,6 +9343,23 @@ packages:
signal-exit: 4.0.2
dev: true
/xlsx/0.18.5:
resolution:
{
integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
}
engines: { node: ">=0.8" }
hasBin: true
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
dev: false
/xml-name-validator/4.0.0:
resolution:
{

52
src/api/system/log.ts Normal file
View File

@ -0,0 +1,52 @@
import { http } from "@/utils/http";
export interface OperationLogsQuery extends BasePageQuery {
businessType?: string;
requestModule?: string;
status?: string;
username?: string;
}
export interface OperationLogDTO {
businessType?: number;
businessTypeStr?: string;
calledMethod?: string;
deptId?: number;
deptName?: string;
errorStack?: string;
operationId?: number;
operationParam?: string;
operationResult?: string;
operationTime?: Date;
operatorIp?: string;
operatorLocation?: string;
operatorType?: number;
operatorTypeStr?: string;
requestMethod?: string;
requestModule?: string;
requestUrl?: string;
status?: number;
statusStr?: string;
userId?: number;
username?: string;
}
/** 获取操作日志列表 */
export const getOperationLogListApi = (params?: OperationLogsQuery) => {
return http.request<ResponseData<PageDTO<OperationLogDTO>>>(
"get",
"/logs/operationLogs",
{
params
}
);
};
export const deleteOperationLogApi = (data: Array<number>) => {
return http.request<ResponseData<void>>("delete", "/logs/operationLogs", {
params: {
// 需要将数组转换为字符串 否则Axios会将参数变成 noticeIds[0]:1 noticeIds[1]:2 这种格式,后端接收参数不成功
operationIds: data.toString()
}
});
};

View File

@ -9,7 +9,7 @@ import { MotionPlugin } from "@vueuse/motion";
import { injectResponsiveStorage } from "@/utils/responsive";
import Table from "@pureadmin/table";
// import PureDescriptions from "@pureadmin/descriptions";
import PureDescriptions from "@pureadmin/descriptions";
// 引入重置样式
import "./style/reset.scss";
@ -53,7 +53,7 @@ getServerConfig(app).then(async config => {
.use(MotionPlugin)
.use(ElementPlus)
// .use(useEcharts);
.use(Table);
// .use(PureDescriptions);
.use(Table)
.use(PureDescriptions);
app.mount("#app");
});

View File

@ -1,5 +1,7 @@
import { PaginationProps } from "@pureadmin/table";
import { PaginationProps, TableColumn } from "@pureadmin/table";
import { Sort } from "element-plus";
import { utils, writeFile } from "xlsx";
import { message } from "./message";
export class CommonUtils {
static getBeginTimeSafely(timeRange: string[]): string {
@ -50,6 +52,68 @@ export class CommonUtils {
baseQuery.orderDirection = sort.order;
}
/** 适用于BaseQuery中固定的时间参数 beginTime和endTime参数 */
static fillTimeRangeParams(baseQuery: any, timeRange: string[]) {
if (timeRange == null || timeRange.length == 0 || timeRange === undefined) {
baseQuery["beginTime"] = undefined;
baseQuery["endTime"] = undefined;
return;
}
if (baseQuery == null || baseQuery === undefined) {
return;
}
baseQuery["beginTime"] = CommonUtils.getBeginTimeSafely(timeRange);
baseQuery["endTime"] = CommonUtils.getEndTimeSafely(timeRange);
}
static exportExcel(
columns: TableColumnList,
originalDataList: any[],
excelName: string
) {
if (
!Array.isArray(columns) ||
!Array.isArray(originalDataList) ||
typeof excelName !== "string"
) {
message("参数异常,导出失败", { type: "error" });
return;
}
// columns和dataList为空的话 弹出提示 不执行导出
if (columns.length === 0 || originalDataList.length === 0) {
message("无法导出空列表", { type: "warning" });
return;
}
const titleList: string[] = [];
const dataKeyList: string[] = [];
// 把columns里面的label取出来作为excel的列标题把prop取出来等下从dataList里面根据作为key取对象中的值
columns.forEach((column: TableColumn) => {
if (column.label && column.prop) {
titleList.push(column.label);
dataKeyList.push(column.prop as string);
}
});
const excelDataList: string[][] = originalDataList.map(item => {
const arr = [];
dataKeyList.forEach(dataKey => {
arr.push(item[dataKey]);
});
return arr;
});
excelDataList.unshift(titleList);
const workSheet = utils.aoa_to_sheet(excelDataList);
const workBook = utils.book_new();
utils.book_append_sheet(workBook, workSheet, excelName);
writeFile(workBook, `${excelName}.xlsx`);
}
// 私有构造函数,防止类被实例化
private constructor() {}
}

View File

@ -125,6 +125,8 @@ class PureHttp {
.catch(() => {
message("取消重新登录", { type: "info" });
});
NProgress.done();
return Promise.reject(response.data.msg);
} else {
// 其余情况弹出错误提示框
message(response.data.msg, { type: "error" });

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { useUserStoreHook } from "@/store/modules/user";
import { OperationLogDTO } from "../../../../api/system/log";
/** TODO 有其他方式 来换掉这个props 父子组件传值吗? */
const props = defineProps<OperationLogDTO>();
const operationLogStatusMap =
useUserStoreHook().dictionaryMap["sysOperationLog.status"];
</script>
<template>
<el-descriptions
direction="horizontal"
:column="2"
:labelStyle="'white-space:nowrap;'"
:contentStyle="'word-break:break-all;'"
:size="'large'"
>
<!-- 开头前两列设置宽度 -->
<el-descriptions-item label="操作编号:" :width="'25%'">{{
props.operationId
}}</el-descriptions-item>
<el-descriptions-item label="请求模块:" :width="'25%'">{{
props.requestModule
}}</el-descriptions-item>
<el-descriptions-item :span="2" label="操作类型:">{{
props.businessTypeStr
}}</el-descriptions-item>
<el-descriptions-item label="操作人:">{{
props.username
}}</el-descriptions-item>
<el-descriptions-item label="操作人ID:">{{
props.userId
}}</el-descriptions-item>
<el-descriptions-item label="操作人类型:">{{
props.operatorTypeStr
}}</el-descriptions-item>
<el-descriptions-item label="操作人部门:">{{
props.deptName
}}</el-descriptions-item>
<el-descriptions-item label="操作人IP:">{{
props.operatorIp
}}</el-descriptions-item>
<el-descriptions-item :span="2" label="操作人地址:">{{
props.operatorLocation
}}</el-descriptions-item>
<el-descriptions-item label="请求链接:">{{
props.requestUrl
}}</el-descriptions-item>
<el-descriptions-item label="请求方式:">{{
props.requestMethod
}}</el-descriptions-item>
<el-descriptions-item :span="2" label="请求参数:">
<!-- 长度可能较长的字符串使用el-text包住 避免超出框 -->
<el-text>
{{ props.operationParam }}
</el-text>
</el-descriptions-item>
<el-descriptions-item :span="2" label="调用方法:">
<el-text>
{{ props.calledMethod }}
</el-text>
</el-descriptions-item>
<el-descriptions-item :span="2" label="返回结果:">
<el-text>
{{ props.operationResult }}
</el-text>
</el-descriptions-item>
<el-descriptions-item :span="2" label="错误详情:">
<el-text>
{{ props.errorStack }}
</el-text>
</el-descriptions-item>
<el-descriptions-item label="状态:"
><el-tag
:type="operationLogStatusMap[props.status].cssTag"
effect="plain"
>
{{ operationLogStatusMap[props.status].label }}
</el-tag></el-descriptions-item
>
<el-descriptions-item label="操作时间:">{{
props.operationTime
}}</el-descriptions-item>
</el-descriptions>
</template>
<style>
.el-descriptions {
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref } from "vue";
import { useOperationLogHook } from "./utils/hook";
import { PureTableBar } from "@/components/RePureTableBar";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Delete from "@iconify-icons/ep/delete";
import View from "@iconify-icons/ep/view";
import Search from "@iconify-icons/ep/search";
import Refresh from "@iconify-icons/ep/refresh";
import { useUserStoreHook } from "@/store/modules/user";
//
import { CommonUtils } from "../../../../utils/common";
/** 组件name最好和菜单表中的router_name一致 */
defineOptions({
name: "SystemOperationLog"
});
const businessTypeList =
useUserStoreHook().dictionaryList["sysOperationLog.businessType"];
const operationStatusList =
useUserStoreHook().dictionaryList["sysOperationLog.status"];
const tableRef = ref();
const searchFormRef = ref();
const {
searchFormParams,
pageLoading,
columns,
dataList,
pagination,
timeRange,
defaultSort,
multipleSelection,
onSearch,
resetForm,
openDialog,
getOperationLogList,
handleDelete,
handleBulkDelete
} = useOperationLogHook();
</script>
<template>
<div class="main">
<!-- 搜索栏 -->
<el-form
ref="searchFormRef"
:inline="true"
:model="searchFormParams"
class="search-form bg-bg_color w-[99/100] pl-8 pt-[12px]"
>
<el-form-item label="系统模块:" prop="requestModule">
<el-input
v-model="searchFormParams.requestModule"
placeholder="请输入系统模块"
clearable
class="!w-[200px]"
/>
</el-form-item>
<el-form-item label="操作类型:" prop="businessType">
<el-select
v-model="searchFormParams.businessType"
placeholder="请选择状态"
clearable
class="!w-[180px]"
>
<el-option
v-for="dict in businessTypeList"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="操作人员:" prop="username">
<el-input
v-model="searchFormParams.username"
placeholder="请输入创建者"
clearable
class="!w-[180px]"
/>
</el-form-item>
<el-form-item label="状态:" prop="status">
<el-select
v-model="searchFormParams.status"
placeholder="请选择状态"
clearable
class="!w-[180px]"
>
<el-option
v-for="dict in operationStatusList"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<label class="el-form-item__label is-required font-bold"
>操作时间</label
>
<!-- TODO 如何消除这个v-model的warning -->
<el-date-picker
class="!w-[240px]"
v-model="timeRange"
value-format="YYYY-MM-DD"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:icon="useRenderIcon(Search)"
:loading="pageLoading"
@click="onSearch"
>
搜索
</el-button>
<el-button
:icon="useRenderIcon(Refresh)"
@click="resetForm(searchFormRef, tableRef)"
>
重置
</el-button>
</el-form-item>
</el-form>
<!-- table bar 包裹 table -->
<PureTableBar title="通知列表" :columns="columns" @refresh="onSearch">
<!-- 表格操作栏 -->
<template #buttons>
<el-button
type="danger"
:icon="useRenderIcon(Delete)"
@click="handleBulkDelete(tableRef)"
>
批量删除
</el-button>
<el-button
type="primary"
@click="CommonUtils.exportExcel(columns, dataList, '操作日志列表')"
>单页导出</el-button
>
<el-button
type="primary"
@click="CommonUtils.exportExcel(columns, dataList, '操作日志列表')"
>全部导出</el-button
>
</template>
<template v-slot="{ size, dynamicColumns }">
<pure-table
border
ref="tableRef"
align-whole="center"
showOverflowTooltip
table-layout="auto"
:loading="pageLoading"
:size="size"
adaptive
:data="dataList"
:columns="dynamicColumns"
:default-sort="defaultSort"
:pagination="pagination"
:paginationSmall="size === 'small' ? true : false"
:header-cell-style="{
background: 'var(--el-table-row-hover-bg-color)',
color: 'var(--el-text-color-primary)'
}"
@page-size-change="getOperationLogList"
@page-current-change="getOperationLogList"
@sort-change="getOperationLogList"
@selection-change="
rows => (multipleSelection = rows.map(item => item.operationId))
"
>
<template #operation="{ row }">
<el-button
class="reset-margin"
link
type="primary"
:size="size"
:icon="useRenderIcon(View)"
@click="openDialog(row)"
>
详情
</el-button>
<el-popconfirm
:title="`是否确认删除编号为${row.operationId}的这条日志`"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button
class="reset-margin"
link
type="danger"
:size="size"
:icon="useRenderIcon(Delete)"
>
删除
</el-button>
</template>
</el-popconfirm>
</template>
</pure-table>
</template>
</PureTableBar>
</div>
</template>
<style scoped lang="scss">
:deep(.el-dropdown-menu__item i) {
margin: 0;
}
.search-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
</style>

View File

@ -0,0 +1,254 @@
import dayjs from "dayjs";
import descriptionForm from "../description.vue";
import { message } from "@/utils/message";
import { addDialog, closeDialog } from "@/components/ReDialog";
import { ElMessageBox, Sort } from "element-plus";
import { OperationLogsQuery, getOperationLogListApi } from "@/api/system/log";
import { reactive, ref, onMounted, h, toRaw } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { deleteOperationLogApi } from "@/api/system/log";
import { CommonUtils } from "@/utils/common";
const operationLogStatusMap =
useUserStoreHook().dictionaryMap["sysOperationLog.status"];
const businessTypeMap =
useUserStoreHook().dictionaryMap["sysOperationLog.businessType"];
export function useOperationLogHook() {
const defaultSort: Sort = {
prop: "operationTime",
order: "descending"
};
const pagination: PaginationProps = {
total: 0,
pageSize: 10,
currentPage: 1,
background: true
};
const timeRange = ref([]);
const searchFormParams = reactive<OperationLogsQuery>({
beginTime: undefined,
endTime: undefined,
businessType: undefined,
requestModule: undefined,
status: undefined,
username: undefined,
timeRangeColumn: defaultSort.prop
});
const dataList = ref([]);
const pageLoading = ref(true);
const multipleSelection = ref([]);
const columns: TableColumnList = [
{
type: "selection",
align: "left"
},
{
label: "操作编号",
prop: "operationId",
minWidth: 100
},
{
label: "业务模块",
prop: "requestModule",
minWidth: 120
},
{
label: "操作类型",
prop: "businessType",
minWidth: 120,
cellRenderer: ({ row, props }) => (
<el-tag
size={props.size}
type={businessTypeMap[row.businessType].cssTag}
effect="plain"
>
{businessTypeMap[row.businessType].label}
</el-tag>
)
},
{
label: "请求方式",
prop: "requestMethod",
minWidth: 120
},
{
label: "操作人员",
prop: "username",
minWidth: 120
},
{
label: "登录地址",
prop: "operatorIp",
minWidth: 120
},
{
label: "状态",
prop: "status",
minWidth: 120,
cellRenderer: ({ row, props }) => (
<el-tag
size={props.size}
type={operationLogStatusMap[row.status].cssTag}
effect="plain"
>
{operationLogStatusMap[row.status].label}
</el-tag>
)
},
{
label: "状态名",
prop: "statusStr",
minWidth: 120,
hide: true
},
{
label: "操作时间",
minWidth: 160,
prop: "operationTime",
sortable: "custom",
formatter: ({ operationTime }) =>
dayjs(operationTime).format("YYYY-MM-DD HH:mm:ss")
},
{
label: "操作",
fixed: "right",
width: 140,
slot: "operation"
}
];
async function onSearch() {
// 点击搜索的时候 需要重置分页
pagination.currentPage = 1;
getOperationLogList();
}
function resetForm(formEl, tableRef) {
if (!formEl) return;
// 清空查询参数
formEl.resetFields();
// 清空排序
searchFormParams.orderColumn = undefined;
searchFormParams.orderDirection = undefined;
// 清空时间查询 TODO 这块有点繁琐 有可以优化的地方吗?
// Form组件的resetFields方法无法清除datepicker里面的数据。
timeRange.value = [];
searchFormParams.beginTime = undefined;
searchFormParams.endTime = undefined;
tableRef.getTableRef().clearSort();
// 重置分页并查询
onSearch();
}
async function getOperationLogList(sort: Sort = defaultSort) {
pageLoading.value = true;
if (sort != null) {
CommonUtils.fillSortParams(searchFormParams, sort);
}
CommonUtils.fillPaginationParams(searchFormParams, pagination);
CommonUtils.fillTimeRangeParams(searchFormParams, timeRange.value);
const { data } = await getOperationLogListApi(toRaw(searchFormParams));
dataList.value = data.rows;
pagination.total = data.total;
setTimeout(() => {
pageLoading.value = false;
}, 500);
}
async function handleDelete(row) {
await deleteOperationLogApi([row.operationId]).then(() => {
message(`您删除了操作编号为${row.operationId}的这条数据`, {
type: "success"
});
// 刷新列表
getOperationLogList();
});
}
async function handleBulkDelete(tableRef) {
if (multipleSelection.value.length === 0) {
message("请选择需要删除的数据", { type: "warning" });
return;
}
ElMessageBox.confirm(
`确认要<strong>删除</strong>编号为<strong style='color:var(--el-color-primary)'>[ ${multipleSelection.value} ]</strong>的日志吗?`,
"系统提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
draggable: true
}
)
.then(async () => {
await deleteOperationLogApi(multipleSelection.value).then(() => {
message(`您删除了日志编号为[ ${multipleSelection.value} ]的数据`, {
type: "success"
});
// 刷新列表
getOperationLogList();
});
})
.catch(() => {
message("取消删除", {
type: "info"
});
// 清空checkbox选择的数据
tableRef.getTableRef().clearSelection();
});
}
function openDialog(row) {
addDialog({
title: "日志详情",
width: "60%",
draggable: true,
fullscreenIcon: false,
closeOnClickModal: true,
contentRenderer: () => h(descriptionForm, toRaw(row)),
footerButtons: [
{
label: "关闭",
text: true,
size: "large",
bg: true,
btnClick: ({ dialog: { options, index } }) => {
closeDialog(options, index);
}
}
]
});
}
onMounted(() => {
getOperationLogList();
});
return {
searchFormParams,
pageLoading,
columns,
dataList,
pagination,
defaultSort,
timeRange,
multipleSelection,
onSearch,
// exportExcel,
getOperationLogList,
resetForm,
openDialog,
handleDelete,
handleBulkDelete
};
}

View File

@ -190,7 +190,7 @@ export function useNoticeHook() {
)
.then(async () => {
await deleteSystemNoticeApi(multipleSelection.value).then(() => {
message(`您删除了通知编号为[ ${multipleSelection.value} ]的数据`, {
message(`您删除了通知编号为[ ${multipleSelection.value} ]的数据`, {
type: "success"
});
// 刷新列表