feat: 新增系统日志管理(登录日志、操作日志)

This commit is contained in:
2024-04-09 21:34:56 +08:00
parent d0d1181c49
commit 20184c6e01
9 changed files with 487 additions and 3 deletions

View File

@@ -2,9 +2,11 @@ export * from './area'
export * from './auth'
export * from './common'
export * from './monitor'
export * from './system'
export * from './area/type'
export * from './auth/type'
export * from './common/type'
export * from './monitor/type'
export * from './system/type'

1
src/apis/system/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './log'

14
src/apis/system/log.ts Normal file
View File

@@ -0,0 +1,14 @@
import http from '@/utils/http'
import type * as System from './type'
const BASE_URL = '/system/log'
/** @desc 查询日志列表 */
export function listLog(query: System.LogQuery) {
return http.get<PageRes<System.LogResp[]>>(`${BASE_URL}`, query)
}
/** @desc 查询日志详情 */
export function getLog(id: string) {
return http.get<System.LogDetailResp>(`${BASE_URL}/${id}`)
}

33
src/apis/system/type.ts Normal file
View File

@@ -0,0 +1,33 @@
/** 系统日志类型 */
export interface LogResp {
id: string
description: string
module: string
timeTaken: number
ip: string
address: string
browser: string
os: string
status: number
errorMsg: string
createUserString: string
createTime: string
}
export interface LogDetailResp extends LogResp {
traceId: string
requestUrl: string
requestMethod: string
requestHeaders: string
requestBody: string
statusCode: number
responseHeaders: string
responseBody: string
}
export interface LogQuery extends PageQuery {
description?: string
module?: string
ip?: string
createUserString?: string
createTime?: string
status?: number
}

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import qs from 'query-string'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useUserStore } from '@/stores'
import { getToken } from '@/utils/auth'
@@ -8,7 +9,6 @@ import notificationErrorWrapper from '@/utils/notification-error-wrapper'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import router from '@/router'
import qs from 'query-string'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
@@ -114,7 +114,15 @@ const request = <T = unknown>(config: AxiosRequestConfig): Promise<ApiRes<T>> =>
http
.request<T>(config)
.then((res: AxiosResponse) => resolve(res.data))
.catch((err: { message: string }) => reject(err))
.catch((err: { msg: string }) => reject(err))
})
}
const requestNative = <T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse> => {
return new Promise((resolve, reject) => {
http
.request<T>(config)
.catch((err: { msg: string }) => reject(err))
})
}
@@ -166,4 +174,4 @@ const del = <T = any>(url: string, params?: object, config?: AxiosRequestConfig)
})
}
export default { get, post, put, patch, del }
export default { get, post, put, patch, del, request, requestNative }

View File

@@ -0,0 +1,115 @@
<template>
<GiTable
row-key="id"
:data="dataList"
:columns="columns"
:loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1000 }"
:pagination="pagination"
:disabledTools="['setting']"
@refresh="search"
>
<template #custom-left>
<a-input v-model="queryForm.createUserString" placeholder="请输入登录用户" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<a-input v-model="queryForm.ip" placeholder="请输入登录 IP 或地点" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<DateRangePicker v-model="queryForm.createTime" @change="search" />
<a-button @click="reset">重置</a-button>
</template>
<template #custom-right>
<a-tooltip content="导出">
<a-button>
<template #icon>
<icon-download />
</template>
</a-button>
</a-tooltip>
</template>
<template #status="{ record }">
<a-tag v-if="record.status === 1" color="green">
<GiDot type="success" style="width: 5px; height: 5px" />
<span style="margin-left: 5px">成功</span>
</a-tag>
<a-tooltip v-else :content="record.errorMsg">
<a-tag color="red" style="cursor: pointer">
<GiDot type="danger" style="width: 5px; height: 5px" />
<span style="margin-left: 5px">失败</span>
</a-tag>
</a-tooltip>
</template>
</GiTable>
</template>
<script setup lang="ts">
import { listLog } from '@/apis'
import type { TableInstance } from '@arco-design/web-vue'
import DateRangePicker from '@/components/DateRangePicker/index.vue'
import { useTable } from '@/hooks'
defineOptions({ name: 'LoginLog' })
const columns: TableInstance['columns'] = [
{
title: '序号',
width: 66,
align: 'center',
render: ({ rowIndex }) => h('span', {}, rowIndex + 1 + (pagination.current - 1) * pagination.pageSize)
},
{ title: '登录时间', dataIndex: 'createTime', width: 180 },
{ title: '用户昵称', dataIndex: 'createUserString', ellipsis: true, tooltip: true },
{ title: '登录行为', dataIndex: 'description' },
{
title: '状态',
slotName: 'status',
align: 'center',
filterable: {
filters: [
{
text: '成功',
value: 1
},
{
text: '失败',
value: 2
}
],
filter: (value, record) => record.status == value,
alignLeft: true
}
},
{ title: '登录 IP', dataIndex: 'ip', ellipsis: true, tooltip: true },
{ title: '登录地点', dataIndex: 'address', ellipsis: true, tooltip: true },
{ title: '浏览器', dataIndex: 'browser', ellipsis: true, tooltip: true },
{ title: '终端系统', dataIndex: 'os', ellipsis: true, tooltip: true }
]
const queryForm = reactive({
module: '登录',
ip: undefined,
createUserString: undefined,
createTime: undefined,
status: undefined,
sort: ['createTime,desc']
})
const {
tableData: dataList,
loading,
pagination,
search
} = useTable((p) => listLog({ ...queryForm, page: p.page, size: p.size }), { immediate: true })
// 重置
const reset = () => {
queryForm.ip = undefined
queryForm.createUserString = undefined
queryForm.createTime = undefined
queryForm.status = undefined
search()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,136 @@
<template>
<GiTable
row-key="id"
:data="dataList"
:columns="columns"
:loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1000 }"
:pagination="pagination"
column-resizable
:disabledTools="['setting']"
@refresh="search"
>
<template #custom-left>
<a-input v-model="queryForm.createUserString" placeholder="请输入操作人" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<a-input v-model="queryForm.ip" placeholder="请输入操作 IP 或地点" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<DateRangePicker v-model="queryForm.createTime" @change="search" />
<a-button @click="reset">重置</a-button>
</template>
<template #custom-right>
<a-tooltip content="导出">
<a-button>
<template #icon>
<icon-download />
</template>
</a-button>
</a-tooltip>
</template>
<template #createTime="{ record }">
<a-link @click="openDetail(record)">{{ record.createTime }}</a-link>
</template>
<template #status="{ record }">
<a-tag v-if="record.status === 1" color="green">
<GiDot type="success" style="width: 5px; height: 5px"></GiDot>
<span style="margin-left: 5px">成功</span>
</a-tag>
<a-tooltip v-else :content="record.errorMsg">
<a-tag color="red" style="cursor: pointer">
<GiDot type="danger" style="width: 5px; height: 5px"></GiDot>
<span style="margin-left: 5px">失败</span>
</a-tag>
</a-tooltip>
</template>
<template #timeTaken="{ record }">
<a-tag v-if="record.timeTaken > 500" color="red">{{ record.timeTaken }}ms</a-tag>
<a-tag v-else-if="record.timeTaken > 200" color="orange">{{ record.timeTaken }}ms</a-tag>
<a-tag v-else color="green">{{ record.timeTaken }} ms</a-tag>
</template>
</GiTable>
<OperationLogDetailDrawer ref="OperationLogDetailDrawerRef" />
</template>
<script setup lang="ts">
import { listLog, type LogResp } from '@/apis'
import type { TableInstance } from '@arco-design/web-vue'
import DateRangePicker from '@/components/DateRangePicker/index.vue'
import OperationLogDetailDrawer from './OperationLogDetailDrawer.vue'
import { useTable } from '@/hooks'
defineOptions({ name: 'OperationLog' })
const columns: TableInstance['columns'] = [
{
title: '序号',
width: 66,
align: 'center',
render: ({ rowIndex }) => h('span', {}, rowIndex + 1 + (pagination.current - 1) * pagination.pageSize)
},
{ title: '操作时间', slotName: 'createTime', width: 180 },
{ title: '操作人', dataIndex: 'createUserString', ellipsis: true, tooltip: true },
{ title: '操作内容', dataIndex: 'description', ellipsis: true, tooltip: true },
{ title: '所属模块', dataIndex: 'module', align: 'center', ellipsis: true, tooltip: true },
{
title: '状态',
slotName: 'status',
align: 'center',
filterable: {
filters: [
{
text: '成功',
value: 1
},
{
text: '失败',
value: 2
}
],
filter: (value, record) => record.status == value,
alignLeft: true
}
},
{ title: '操作 IP', dataIndex: 'ip', ellipsis: true, tooltip: true },
{ title: '操作地点', dataIndex: 'address', ellipsis: true, tooltip: true },
{ title: '耗时', slotName: 'timeTaken', align: 'center' },
{ title: '浏览器', dataIndex: 'browser', ellipsis: true, tooltip: true },
{ title: '终端系统', dataIndex: 'os', ellipsis: true, tooltip: true }
]
const queryForm = reactive({
description: undefined,
ip: undefined,
createUserString: undefined,
createTime: undefined,
status: undefined,
sort: ['createTime,desc']
})
const {
loading,
tableData: dataList,
pagination,
search
} = useTable((p) => listLog({ ...queryForm, page: p.page, size: p.size }), { immediate: true })
// 重置查询
const reset = () => {
queryForm.description = undefined
queryForm.ip = undefined
queryForm.createUserString = undefined
queryForm.createTime = undefined
queryForm.status = undefined
search()
}
const OperationLogDetailDrawerRef = ref<InstanceType<typeof OperationLogDetailDrawer>>()
// 查询详情
const openDetail = (item: LogResp) => {
OperationLogDetailDrawerRef.value?.open(item.id)
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,127 @@
<template>
<a-drawer v-model:visible="visible" title="日志详情" :width="720" :footer="false">
<a-descriptions title="基本信息" :column="2" size="large" class="general-description">
<a-descriptions-item label="日志 ID">{{ operationLog?.id }}</a-descriptions-item>
<a-descriptions-item label="Trace ID">{{ operationLog?.traceId }}</a-descriptions-item>
<a-descriptions-item label="操作人">{{ operationLog?.createUserString }}</a-descriptions-item>
<a-descriptions-item label="操作时间">{{ operationLog?.createTime }}</a-descriptions-item>
<a-descriptions-item label="操作内容">{{ operationLog?.description }}</a-descriptions-item>
<a-descriptions-item label="所属模块">{{ operationLog?.module }}</a-descriptions-item>
<a-descriptions-item label="操作 IP">{{ operationLog?.ip }}</a-descriptions-item>
<a-descriptions-item label="操作地点">{{ operationLog?.address }}</a-descriptions-item>
<a-descriptions-item label="浏览器">{{ operationLog?.browser }}</a-descriptions-item>
<a-descriptions-item label="终端系统">{{ operationLog?.os }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="operationLog?.status === 1" color="green">成功</a-tag>
<a-tag v-else color="red">失败</a-tag>
</a-descriptions-item>
<a-descriptions-item label="耗时">
<a-tag v-if="operationLog?.timeTaken > 500" color="red">
{{ operationLog?.timeTaken }}ms
</a-tag>
<a-tag v-else-if="operationLog?.timeTaken > 200" color="orange">
{{ operationLog?.timeTaken }}ms
</a-tag>
<a-tag v-else color="green">{{ operationLog?.timeTaken }} ms</a-tag>
</a-descriptions-item>
<a-descriptions-item label="请求 URI" :span="2">
{{ operationLog?.requestUrl }}
</a-descriptions-item>
</a-descriptions>
<a-descriptions
title="响应信息"
:column="2"
size="large"
class="general-description http"
style="margin-top: 20px; position: relative"
>
<a-descriptions-item :span="2">
<a-tabs type="card">
<a-tab-pane key="1" title="响应头">
<VueJsonPretty
v-if="operationLog?.responseHeaders"
:path="'res'"
:data="JSON.parse(operationLog?.responseHeaders)"
:show-length="true"
/>
<span v-else></span>
</a-tab-pane>
<a-tab-pane key="2" title="响应体">
<VueJsonPretty
v-if="operationLog?.responseBody"
:path="'res'"
:data="JSON.parse(operationLog?.responseBody)"
:show-length="true"
/>
<span v-else></span>
</a-tab-pane>
</a-tabs>
</a-descriptions-item>
</a-descriptions>
<a-descriptions
title="请求信息"
:column="2"
size="large"
class="general-description http"
style="margin-top: 20px; position: relative"
>
<a-descriptions-item :span="2">
<a-tabs type="card">
<a-tab-pane key="1" title="请求头">
<VueJsonPretty
v-if="operationLog?.requestHeaders"
:path="'res'"
:data="JSON.parse(operationLog?.requestHeaders)"
:show-length="true"
/>
<span v-else></span>
</a-tab-pane>
<a-tab-pane key="2" title="请求体">
<VueJsonPretty
v-if="operationLog?.requestBody"
:path="'res'"
:data="JSON.parse(operationLog?.requestBody)"
:show-length="true"
/>
<span v-else></span>
</a-tab-pane>
</a-tabs>
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</template>
<script lang="ts" setup>
import { getLog, type LogDetailResp } from '@/apis'
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
const logId = ref('')
const operationLog = ref<LogDetailResp | null>()
// 查询详情
const getOperationLogDetail = async () => {
const res = await getLog(logId.value)
operationLog.value = res.data
}
const visible = ref(false)
// 打开详情
const open = async (id: string) => {
logId.value = id
await getOperationLogDetail()
visible.value = true
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.http :deep(.arco-descriptions-item-label-block) {
padding-right: 0;
}
:deep(.arco-tabs-content) {
padding-top: 5px;
padding-left: 15px;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="gi_page">
<a-card title="系统日志" class="general-card">
<a-tabs type="card-gutter" size="large">
<a-tab-pane key="1" title="登录日志">
<LoginLog />
</a-tab-pane>
<a-tab-pane key="2" title="操作日志">
<OperationLog />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
import LoginLog from './LoginLog.vue'
import OperationLog from './OperationLog.vue'
</script>
<style lang="scss" scoped>
:deep(.arco-tabs .arco-tabs-nav-type-card-gutter .arco-tabs-tab-active) {
box-shadow: inset 0 2px 0 rgb(var(--primary-6)), inset -1px 0 0 var(--color-border-2),
inset 1px 0 0 var(--color-border-2);
position: relative;
}
:deep(.arco-tabs-nav-type-card-gutter .arco-tabs-tab) {
border-radius: var(--border-radius-medium) var(--border-radius-medium) 0 0;
}
:deep(.arco-tabs-type-card-gutter > .arco-tabs-content) {
border: none;
}
:deep(.arco-tabs-nav::before) {
left: -20px;
right: -20px;
}
:deep(.arco-tabs) {
overflow: visible;
}
:deep(.arco-tabs-nav) {
overflow: visible;
}
</style>